refactor: improve error handling and initialization in frontend tasks

Updated PingViewModel to reset errorMessage on each task execution and provide detailed error messages. Enhanced SQLite worker initialization with manual WASM binary loading and improved error handling. Adjusted Gradle tasks and Webpack config for SQLite assets, ensuring seamless builds. Included dummy modules to bypass Webpack resolution issues.
This commit is contained in:
2026-01-26 14:37:09 +01:00
parent da876a0c21
commit 763dbc5eed
8 changed files with 433 additions and 62 deletions
@@ -3,15 +3,16 @@ package at.mocode.frontend.core.localdb
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.worker.WebWorkerDriver
import org.w3c.dom.Worker
import org.w3c.dom.WorkerOptions
import org.w3c.dom.WorkerType
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class DatabaseDriverFactory {
actual suspend fun createDriver(): SqlDriver {
// Load the worker script.
// We use a simple string path instead of `new URL(..., import.meta.url)` to prevent Webpack
// from trying to resolve/bundle this file at build time.
// The file 'sqlite.worker.js' is copied to the root of the distribution by the Gradle build script.
val worker = Worker("sqlite.worker.js")
// Wir nutzen eine Helper-Funktion, um den Worker zu erstellen.
// Dies ermöglicht uns, 'new URL(..., import.meta.url)' in JS zu verwenden,
// was Webpack dazu bringt, den Pfad korrekt aufzulösen.
val worker = createWorker()
val driver = WebWorkerDriver(worker)
// Initialize schema asynchronously
@@ -20,3 +21,10 @@ actual class DatabaseDriverFactory {
return driver
}
}
// Helper function to create the worker using proper URL resolution
private fun createWorker(): Worker {
return js("""
new Worker(new URL('sqlite.worker.js', import.meta.url), { type: 'module' })
""")
}
@@ -1,10 +1,14 @@
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
console.log("Worker: sqlite.worker.js loaded. Starting initialization...");
// Minimal worker protocol compatible with SQLDelight's `web-worker-driver`.
// Mirrors the message format used by SQLDelight's `sqljs.worker.js` implementation.
function runWorker({ driver }) {
console.log("Worker: runWorker called");
let db = null;
const open = (name) => {
console.log("Worker: Opening database", name);
db = driver.open(name);
};
@@ -41,28 +45,59 @@ function runWorker({ driver }) {
throw new Error(`Unsupported action: ${data && data.action}`);
}
} catch (err) {
console.error("Worker: Error processing message", err);
return postMessage({ id: data && data.id, error: err?.message ?? String(err) });
}
};
}
sqlite3InitModule({
print: console.log,
printErr: console.error,
}).then((sqlite3) => {
const opfsAvailable = 'opfs' in sqlite3;
// Error handling wrapper
self.onerror = function(event) {
console.error("Error in Web Worker (onerror):", event.message, event.filename, event.lineno);
// Optionally, send the error back to the main thread
self.postMessage({ type: 'error', message: event.message, filename: event.filename, lineno: event.lineno });
};
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
// Manually fetch the WASM file to bypass Webpack/sqlite-wasm loading issues
async function init() {
try {
console.log("Worker: Fetching sqlite3.wasm manually...");
const response = await fetch('/sqlite3.wasm');
if (!response.ok) {
throw new Error(`Failed to fetch sqlite3.wasm: ${response.status} ${response.statusText}`);
}
const wasmBinary = await response.arrayBuffer();
console.log("Worker: sqlite3.wasm fetched successfully, size:", wasmBinary.byteLength);
console.log("Worker: Calling sqlite3InitModule with wasmBinary...");
const sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error,
wasmBinary: wasmBinary // Provide the binary directly!
});
console.log("Worker: sqlite3InitModule resolved successfully");
const opfsAvailable = 'opfs' in sqlite3;
console.log("Worker: OPFS available:", opfsAvailable);
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
}
}
}
}
});
});
});
} catch (e) {
console.error("Database initialization error in worker:", e);
self.postMessage({ type: 'error', message: 'Database initialization failed: ' + e.message });
}
}
init();
@@ -44,23 +44,40 @@ function runWorker({ driver }) {
};
}
sqlite3InitModule({
print: console.log,
printErr: console.error,
}).then((sqlite3) => {
const opfsAvailable = 'opfs' in sqlite3;
// Error handling wrapper
self.onerror = function(event) {
console.error("Error in Web Worker:", event.message, event.filename, event.lineno);
// Optionally, send the error back to the main thread
self.postMessage({ type: 'error', message: event.message, filename: event.filename, lineno: event.lineno });
};
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
try {
sqlite3InitModule({
print: console.log,
printErr: console.error,
}).then((sqlite3) => {
try {
const opfsAvailable = 'opfs' in sqlite3;
runWorker({
driver: {
open: (name) => {
if (opfsAvailable) {
console.log("Initialisiere persistente OPFS Datenbank: " + name);
return new sqlite3.oo1.OpfsDb(name);
} else {
console.warn("OPFS nicht verfügbar, Fallback auf In-Memory");
return new sqlite3.oo1.DB(name);
}
}
}
}
});
} catch (e) {
console.error("Database initialization error in worker (inner):", e);
self.postMessage({ type: 'error', message: 'Database initialization failed (inner): ' + e.message });
}
});
});
} catch (e) {
console.error("Database initialization error in worker (outer):", e);
self.postMessage({ type: 'error', message: 'Database initialization failed (outer): ' + e.message });
}
@@ -50,7 +50,7 @@ class PingViewModel(
fun performSimplePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("SimplePing", "Sending request...")
try {
val response = apiClient.simplePing()
@@ -60,7 +60,8 @@ class PingViewModel(
)
addLog("SimplePing", "Success: ${response.status} from ${response.service}")
} catch (e: Exception) {
uiState = uiState.copy(isLoading = false)
val msg = "Simple ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("SimplePing", "Failed: ${e.message}", isError = true)
}
}
@@ -68,7 +69,7 @@ class PingViewModel(
fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("EnhancedPing", "Sending request (simulate=$simulate)...")
try {
val response = apiClient.enhancedPing(simulate)
@@ -78,7 +79,8 @@ class PingViewModel(
)
addLog("EnhancedPing", "Success: CB=${response.circuitBreakerState}, Time=${response.responseTime}ms")
} catch (e: Exception) {
uiState = uiState.copy(isLoading = false)
val msg = "Enhanced ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("EnhancedPing", "Failed: ${e.message}", isError = true)
}
}
@@ -86,7 +88,7 @@ class PingViewModel(
fun performHealthCheck() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("HealthCheck", "Checking system health...")
try {
val response = apiClient.healthCheck()
@@ -96,7 +98,8 @@ class PingViewModel(
)
addLog("HealthCheck", "Status: ${response.status}, Healthy: ${response.healthy}")
} catch (e: Exception) {
uiState = uiState.copy(isLoading = false)
val msg = "Health check failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("HealthCheck", "Failed: ${e.message}", isError = true)
}
}
@@ -104,7 +107,7 @@ class PingViewModel(
fun performSecurePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true)
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("SecurePing", "Sending authenticated request...")
try {
val response = apiClient.securePing()
@@ -114,7 +117,8 @@ class PingViewModel(
)
addLog("SecurePing", "Success: Authorized access granted.")
} catch (e: Exception) {
uiState = uiState.copy(isLoading = false)
val msg = "Secure ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("SecurePing", "Access Denied/Error: ${e.message}", isError = true)
}
}
@@ -122,7 +126,7 @@ class PingViewModel(
fun triggerSync() {
viewModelScope.launch {
uiState = uiState.copy(isSyncing = true)
uiState = uiState.copy(isSyncing = true, errorMessage = null)
addLog("Sync", "Starting delta sync...")
try {
syncService.syncPings()
@@ -133,7 +137,8 @@ class PingViewModel(
)
addLog("Sync", "Sync completed successfully.")
} catch (e: Exception) {
uiState = uiState.copy(isSyncing = false)
val msg = "Sync failed: ${e.message}"
uiState = uiState.copy(isSyncing = false, errorMessage = msg)
addLog("Sync", "Sync failed: ${e.message}", isError = true)
}
}
@@ -110,6 +110,8 @@ kotlin {
jsMain.dependencies {
implementation(compose.html.core)
// Benötigt für custom webpack config (wasm.js)
implementation(devNpm("copy-webpack-plugin", "11.0.0"))
}
/*
@@ -138,27 +140,100 @@ kotlin {
// relative to the Kotlin JS package folder (root build dir). We therefore copy the worker into
// that folder before webpack runs.
val copySqliteWorkerJs by tasks.registering(Copy::class) {
// HACK: Overwrite sqlite3.wasm in node_modules with a dummy JS file to fool Webpack
val patchSqliteWasmInNodeModules by tasks.registering(Copy::class) {
dependsOn(rootProject.tasks.named("kotlinNpmInstall"))
val localDb = project(":frontend:core:local-db")
dependsOn(localDb.tasks.named("jsProcessResources"))
// We take our dummy.js
from(localDb.layout.buildDirectory.file("processedResources/js/main/dummy.js")) {
rename { "sqlite3.wasm" } // Rename it to sqlite3.wasm
}
// And copy it OVER the original wasm file in node_modules
into(rootProject.layout.buildDirectory.dir("js/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm"))
// Force overwrite
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
val copySqliteAssetsToWebpackSource by tasks.registering(Copy::class) {
val localDb = project(":frontend:core:local-db")
dependsOn(localDb.tasks.named("jsProcessResources"), rootProject.tasks.named("kotlinNpmInstall"))
// Explicit dependency on the patch task to ensure we copy the REAL wasm file before it gets patched?
// NO! We want to copy the REAL wasm file to the output, but PATCH the one in node_modules.
// So we must copy the real one BEFORE patching.
// But wait, copySqliteAssetsToWebpackSource copies FROM node_modules.
// If we patch node_modules first, we copy the dummy file!
// So: copySqliteAssetsToWebpackSource must run BEFORE patchSqliteWasmInNodeModules?
// Or we copy from a different source (e.g. the original npm package cache? No access).
// Better: We copy the real wasm file from node_modules to a temporary location FIRST,
// then patch node_modules, then copy from temp to output.
// Actually, we can just copy from node_modules BEFORE the patch task runs.
// But Gradle task ordering is tricky.
// Let's change the source of the copy. We can't easily access the original npm package.
// But we know that `kotlinNpmInstall` restores the original files.
// So the order must be:
// 1. kotlinNpmInstall (restores original sqlite3.wasm)
// 2. copySqliteAssetsToWebpackSource (copies original sqlite3.wasm to output)
// 3. patchSqliteWasmInNodeModules (overwrites sqlite3.wasm with dummy.js)
// 4. webpack (uses dummy.js)
mustRunAfter(rootProject.tasks.named("kotlinNpmInstall"))
from(localDb.layout.buildDirectory.file("processedResources/js/main/sqlite.worker.js"))
from(rootProject.layout.buildDirectory.file("js/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm"))
// Root build directory where Kotlin JS packages are assembled.
// Use a concrete path (instead of a Provider) so the Copy task always materializes the directory.
// The package name is constructed from the project path: Meldestelle-frontend-shells-meldestelle-portal
// Note: We use rootProject.layout.buildDirectory because Kotlin JS plugin puts packages in root build dir.
// This is one of the directories served by webpack-dev-server for static content.
into(rootProject.layout.buildDirectory.dir("js/packages/${rootProject.name}-frontend-shells-meldestelle-portal/kotlin"))
}
// Ensure the worker is present for the production bundle.
tasks.named("jsBrowserProductionWebpack") {
dependsOn(copySqliteWorkerJs)
// Additional task to copy the worker and its wasm dependency to the distribution folder (for production build)
val copySqliteAssetsToDist by tasks.registering(Copy::class) {
val localDb = project(":frontend:core:local-db")
dependsOn(localDb.tasks.named("jsProcessResources"), rootProject.tasks.named("kotlinNpmInstall"))
// Same logic here: copy before patch
mustRunAfter(rootProject.tasks.named("kotlinNpmInstall"))
from(localDb.layout.buildDirectory.file("processedResources/js/main/sqlite.worker.js"))
from(rootProject.layout.buildDirectory.file("js/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm"))
// Copy to the distribution directory where index.html resides
into(layout.buildDirectory.dir("dist/js/productionExecutable"))
}
// Ensure the worker is present for the development bundle.
// Ensure the assets are present for the production bundle.
tasks.named("jsBrowserProductionWebpack") {
dependsOn(copySqliteAssetsToWebpackSource)
dependsOn(patchSqliteWasmInNodeModules)
// Enforce order: Copy real assets first, then patch node_modules
// patchSqliteWasmInNodeModules must run AFTER copySqliteAssetsToWebpackSource
// But wait, dependsOn doesn't guarantee order.
// We need to configure the tasks themselves.
finalizedBy(copySqliteAssetsToDist)
}
// Configure task ordering
patchSqliteWasmInNodeModules {
mustRunAfter(copySqliteAssetsToWebpackSource)
// Removed circular dependency: mustRunAfter(copySqliteAssetsToDist)
}
// Ensure the assets are present for the development bundle.
tasks.named("jsBrowserDevelopmentWebpack") {
dependsOn(copySqliteWorkerJs)
dependsOn(copySqliteAssetsToWebpackSource)
dependsOn(patchSqliteWasmInNodeModules)
}
// KMP Compile-Optionen
@@ -0,0 +1,14 @@
// This is a dummy file to satisfy Webpack's requirement for sqlite3.wasm and other modules.
// It mimics the structure of the sqlite3-wasm module to prevent build errors.
// The worker code imports it like this:
// import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
// So we need a default export that is a function.
// This function should mimic the behavior of sqlite3InitModule, which returns a Promise.
export default function dummySqlite3InitModule() {
// Since we are manually loading the WASM binary in the worker, this dummy module
// is primarily here to satisfy Webpack's resolution and prevent errors.
// It doesn't need to actually load the WASM.
return Promise.resolve({});
};
@@ -0,0 +1,104 @@
// This file contains Webpack configuration adjustments for WebAssembly modules,
// specifically to handle `skiko.wasm` and `sqlite3.wasm` correctly.
var pathModule;
try {
pathModule = path;
} catch (e) {
pathModule = require('path');
}
var webpackModule;
try {
webpackModule = webpack;
} catch (e) {
webpackModule = require('webpack');
}
// 1. Enable WebAssembly experiments in Webpack 5
config.experiments = config.experiments || {};
config.experiments.asyncWebAssembly = true;
config.module = config.module || {};
config.module.rules = config.module.rules || [];
// 2. Add a rule to correctly handle .wasm files (like skiko.wasm) as WebAssembly modules
config.module.rules.push({
test: /\.wasm$/,
type: 'webassembly/async'
});
// 3. NormalModuleReplacementPlugin to redirect 'sqlite3.wasm' AND other internal sqlite-wasm modules to our dummy JS file.
// This is needed because the `sqlite-wasm` library tries to `require` these files in a Webpack environment.
// We want these `require` calls to return an empty JS object (from dummy.js) instead of failing.
// Our worker will manually fetch the real sqlite3.wasm.
const dummyPath = pathModule.resolve(__dirname, "../../../../frontend/shells/meldestelle-portal/build/processedResources/js/main/dummy.js");
// Redirect sqlite3.wasm
config.plugins.push(
new webpackModule.NormalModuleReplacementPlugin(
/sqlite3\.wasm$/,
dummyPath
)
);
// Redirect other internal sqlite-wasm modules that might be causing issues
// The error log showed: Can't resolve './sqlite-wasm/jswasm/sqlite3.mjs' and 'sqlite3-worker1-promiser.mjs'
// We redirect them to dummy.js as well, assuming we don't need them for our manual loading approach.
// Be careful not to redirect the main entry point if it's needed.
// The errors seem to come from inside the node_modules package trying to resolve relative paths.
// Let's try to be more specific. If these are optional dependencies or part of the node-loading logic,
// replacing them with dummy.js should be fine.
config.plugins.push(
new webpackModule.NormalModuleReplacementPlugin(
/sqlite3\.mjs$/,
function(resource) {
// Only replace if it's inside the sqlite-wasm package structure we want to avoid
if (resource.context.includes('@sqlite.org/sqlite-wasm')) {
resource.request = dummyPath;
}
}
)
);
config.plugins.push(
new webpackModule.NormalModuleReplacementPlugin(
/sqlite3-worker1-promiser\.mjs$/,
dummyPath
)
);
// 4. Handle WASI imports for skiko.wasm (env, wasi_snapshot_preview1)
// Webpack needs to know how to resolve these "magic" imports.
// We can treat them as externals or empty modules.
// Since we are in a browser environment, these are often provided by the runtime or polyfilled.
// Mapping them to false tells Webpack to ignore them (empty module).
config.resolve = config.resolve || {};
config.resolve.fallback = config.resolve.fallback || {};
// Fallbacks for Node.js core modules that might be required by libraries
config.resolve.fallback.fs = false;
config.resolve.fallback.path = false;
config.resolve.fallback.crypto = false;
// Ignore WASI imports
config.ignoreWarnings = config.ignoreWarnings || [];
config.ignoreWarnings.push(/Critical dependency: the request of a dependency is an expression/);
// Use externals to handle WASI imports if fallback doesn't work
config.externals = config.externals || {};
// config.externals['env'] = 'env'; // This might expect a global 'env' variable
// config.externals['wasi_snapshot_preview1'] = 'wasi_snapshot_preview1';
// Better approach for WASI in Webpack 5 with asyncWebAssembly:
// Webpack should handle this if we don't interfere.
// The error "Can't resolve 'env'" suggests it's looking for a module named 'env'.
// We can provide a dummy module for these.
config.resolve.alias = config.resolve.alias || {};
config.resolve.alias['env'] = dummyPath;
config.resolve.alias['wasi_snapshot_preview1'] = dummyPath;