From 405db7cedbb8abb30301614fe0265dbde57b6460 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 6 Jan 2026 21:05:53 +0200 Subject: [PATCH] chore(client/lightweight): fix errors in SQL provider & implement crypto provider --- .../client/src/lightweight/crypto_provider.ts | 153 ++++++++++++++++++ apps/client/src/lightweight/sql_provider.ts | 73 ++++++++- apps/client/src/local-server-worker.ts | 147 ++++++++--------- 3 files changed, 296 insertions(+), 77 deletions(-) create mode 100644 apps/client/src/lightweight/crypto_provider.ts diff --git a/apps/client/src/lightweight/crypto_provider.ts b/apps/client/src/lightweight/crypto_provider.ts new file mode 100644 index 0000000000..55b5084e02 --- /dev/null +++ b/apps/client/src/lightweight/crypto_provider.ts @@ -0,0 +1,153 @@ +import type { CryptoProvider } from "@triliumnext/core"; + +interface Cipher { + update(data: Uint8Array): Uint8Array; + final(): Uint8Array; +} + +const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/** + * Crypto provider for browser environments using the Web Crypto API. + */ +export default class BrowserCryptoProvider implements CryptoProvider { + + createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array { + // Web Crypto API is async, but the interface expects sync. + // We'll use a synchronous fallback or throw if not available. + // For now, we'll implement a simple synchronous hash using SubtleCrypto + // Note: This is a limitation - we may need to make the interface async + throw new Error( + "Synchronous hash not available in browser. " + + "Use createHashAsync() instead or refactor to support async hashing." + ); + } + + /** + * Async version of createHash using Web Crypto API. + */ + async createHashAsync(algorithm: "sha1" | "sha512", content: string | Uint8Array): Promise { + const webAlgorithm = algorithm === "sha1" ? "SHA-1" : "SHA-512"; + const data = typeof content === "string" + ? new TextEncoder().encode(content) + : new Uint8Array(content); + + const hashBuffer = await crypto.subtle.digest(webAlgorithm, data); + return new Uint8Array(hashBuffer); + } + + createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher { + // Web Crypto API doesn't support streaming cipher like Node.js + // We need to implement a wrapper that collects data and encrypts on final() + return new WebCryptoCipher(algorithm, key, iv, "encrypt"); + } + + createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher { + return new WebCryptoCipher(algorithm, key, iv, "decrypt"); + } + + randomBytes(size: number): Uint8Array { + const bytes = new Uint8Array(size); + crypto.getRandomValues(bytes); + return bytes; + } + + randomString(length: number): string { + const bytes = this.randomBytes(length); + let result = ""; + for (let i = 0; i < length; i++) { + result += CHARS[bytes[i] % CHARS.length]; + } + return result; + } +} + +/** + * A cipher implementation that wraps Web Crypto API. + * Note: This buffers all data until final() is called, which differs from + * Node.js's streaming cipher behavior. + */ +class WebCryptoCipher implements Cipher { + private chunks: Uint8Array[] = []; + private algorithm: string; + private key: Uint8Array; + private iv: Uint8Array; + private mode: "encrypt" | "decrypt"; + private finalized = false; + + constructor( + algorithm: "aes-128-cbc", + key: Uint8Array, + iv: Uint8Array, + mode: "encrypt" | "decrypt" + ) { + this.algorithm = algorithm; + this.key = key; + this.iv = iv; + this.mode = mode; + } + + update(data: Uint8Array): Uint8Array { + if (this.finalized) { + throw new Error("Cipher has already been finalized"); + } + // Buffer the data - Web Crypto doesn't support streaming + this.chunks.push(data); + // Return empty array since we process everything in final() + return new Uint8Array(0); + } + + final(): Uint8Array { + if (this.finalized) { + throw new Error("Cipher has already been finalized"); + } + this.finalized = true; + + // Web Crypto API is async, but we need sync behavior + // This is a fundamental limitation that requires architectural changes + // For now, throw an error directing users to use async methods + throw new Error( + "Synchronous cipher finalization not available in browser. " + + "The Web Crypto API is async-only. Use finalizeAsync() instead." + ); + } + + /** + * Async version that actually performs the encryption/decryption. + */ + async finalizeAsync(): Promise { + if (this.finalized) { + throw new Error("Cipher has already been finalized"); + } + this.finalized = true; + + // Concatenate all chunks + const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const data = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of this.chunks) { + data.set(chunk, offset); + offset += chunk.length; + } + + // Copy key and iv to ensure they're plain ArrayBuffer-backed + const keyBuffer = new Uint8Array(this.key); + const ivBuffer = new Uint8Array(this.iv); + + // Import the key + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyBuffer, + { name: "AES-CBC" }, + false, + [this.mode] + ); + + // Perform encryption/decryption + const result = this.mode === "encrypt" + ? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data) + : await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data); + + return new Uint8Array(result); + } +} diff --git a/apps/client/src/lightweight/sql_provider.ts b/apps/client/src/lightweight/sql_provider.ts index 9749ee1fe4..a8d9419b80 100644 --- a/apps/client/src/lightweight/sql_provider.ts +++ b/apps/client/src/lightweight/sql_provider.ts @@ -1,5 +1,5 @@ import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core"; -import type sqlite3InitModule from "@sqlite.org/sqlite-wasm"; +import sqlite3InitModule from "@sqlite.org/sqlite-wasm"; import type { BindableValue } from "@sqlite.org/sqlite-wasm"; // Type definitions for SQLite WASM (the library doesn't export these directly) @@ -116,18 +116,81 @@ class WasmStatement implements Statement { * * This provider wraps the official @sqlite.org/sqlite-wasm package to provide * a DatabaseProvider implementation compatible with trilium-core. + * + * @example + * ```typescript + * const provider = new BrowserSqlProvider(); + * await provider.initWasm(); // Initialize SQLite WASM module + * provider.loadFromMemory(); // Open an in-memory database + * // or + * provider.loadFromBuffer(existingDbBuffer); // Load from existing data + * ``` */ export default class BrowserSqlProvider implements DatabaseProvider { private db?: Sqlite3Database; private sqlite3?: Sqlite3Module; private _inTransaction = false; + private initPromise?: Promise; + private initError?: Error; /** - * Initialize the provider with an already-initialized SQLite WASM module. - * This must be called before using any database operations. + * Get the SQLite WASM module version info. + * Returns undefined if the module hasn't been initialized yet. */ - initialize(sqlite3: Sqlite3Module): void { - this.sqlite3 = sqlite3; + get version(): { libVersion: string; sourceId: string } | undefined { + return this.sqlite3?.version; + } + + /** + * Initialize the SQLite WASM module. + * This must be called before using any database operations. + * Safe to call multiple times - subsequent calls return the same promise. + * + * @returns A promise that resolves when the module is initialized + * @throws Error if initialization fails + */ + async initWasm(): Promise { + // Return existing promise if already initializing/initialized + if (this.initPromise) { + return this.initPromise; + } + + // Fail fast if we already tried and failed + if (this.initError) { + throw this.initError; + } + + this.initPromise = this.doInitWasm(); + return this.initPromise; + } + + private async doInitWasm(): Promise { + try { + console.log("[BrowserSqlProvider] Initializing SQLite WASM..."); + const startTime = performance.now(); + + this.sqlite3 = await sqlite3InitModule({ + print: console.log, + printErr: console.error, + }); + + const initTime = performance.now() - startTime; + console.log( + `[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`, + this.sqlite3.version.libVersion + ); + } catch (e) { + this.initError = e instanceof Error ? e : new Error(String(e)); + console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError); + throw this.initError; + } + } + + /** + * Check if the SQLite WASM module has been initialized. + */ + get isInitialized(): boolean { + return this.sqlite3 !== undefined; } loadFromFile(_path: string, _isReadOnly: boolean): void { diff --git a/apps/client/src/local-server-worker.ts b/apps/client/src/local-server-worker.ts index 949a597c29..960dcee0b2 100644 --- a/apps/client/src/local-server-worker.ts +++ b/apps/client/src/local-server-worker.ts @@ -2,9 +2,9 @@ // This will eventually import your core server and DB provider. // import { createCoreServer } from "@trilium/core"; (bundled) -import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; import BrowserExecutionContext from './lightweight/cls_provider'; import BrowserSqlProvider from './lightweight/sql_provider'; +import BrowserCryptoProvider from './lightweight/crypto_provider'; // Global error handlers - MUST be set up before any async imports self.onerror = (message, source, lineno, colno, error) => { @@ -45,6 +45,54 @@ self.onunhandledrejection = (event) => { console.log("[Worker] Error handlers installed"); +// Shared SQL provider instance +const sqlProvider = new BrowserSqlProvider(); +let sqlInitPromise: Promise | null = null; +let sqlInitError: string | null = null; + +// Initialize SQLite WASM via the provider +async function initSQLite(): Promise { + if (sqlProvider.isInitialized && sqlProvider.isOpen()) { + return; // Already initialized and database open + } + if (sqlInitError) { + throw new Error(sqlInitError); // Failed before, don't retry + } + if (sqlInitPromise) { + return sqlInitPromise; // Already initializing + } + + sqlInitPromise = (async () => { + try { + // Initialize the WASM module + await sqlProvider.initWasm(); + + // Open an in-memory database + sqlProvider.loadFromMemory(); + console.log("[Worker] Database opened via provider"); + + // Create a simple test table + sqlProvider.exec(` + CREATE TABLE IF NOT EXISTS options ( + name TEXT PRIMARY KEY, + value TEXT + ); + INSERT INTO options (name, value) VALUES + ('theme', 'dark'), + ('layoutOrientation', 'vertical'), + ('headingStyle', 'default'); + `); + console.log("[Worker] Test table created and populated"); + } catch (error) { + sqlInitError = String(error); + console.error("[Worker] SQLite initialization failed:", error); + throw error; + } + })(); + + return sqlInitPromise; +} + // Deferred import for @triliumnext/core to catch initialization errors let coreModule: typeof import("@triliumnext/core") | null = null; let coreInitError: Error | null = null; @@ -54,12 +102,16 @@ async function loadCoreModule() { if (coreInitError) throw coreInitError; try { + // Ensure SQLite is initialized before loading core + await initSQLite(); + console.log("[Worker] Loading @triliumnext/core..."); coreModule = await import("@triliumnext/core"); coreModule.initializeCore({ executionContext: new BrowserExecutionContext(), + crypto: new BrowserCryptoProvider(), dbConfig: { - provider: new BrowserSqlProvider(), + provider: sqlProvider, isReadOnly: false, onTransactionCommit: () => { // No-op for now @@ -80,13 +132,7 @@ async function loadCoreModule() { const encoder = new TextEncoder(); -// SQLite WASM instance -let sqlite3: any = null; -let db: any = null; -let sqliteInitPromise: Promise | null = null; -let sqliteInitError: string | null = null; - -function jsonResponse(obj, status = 200, extraHeaders = {}) { +function jsonResponse(obj: unknown, status = 200, extraHeaders = {}) { const body = encoder.encode(JSON.stringify(obj)).buffer; return { status, @@ -95,7 +141,7 @@ function jsonResponse(obj, status = 200, extraHeaders = {}) { }; } -function textResponse(text, status = 200, extraHeaders = {}) { +function textResponse(text: string, status = 200, extraHeaders = {}) { const body = encoder.encode(text).buffer; return { status, @@ -104,62 +150,15 @@ function textResponse(text, status = 200, extraHeaders = {}) { }; } -// Initialize SQLite WASM -async function initSQLite() { - if (sqlite3) return; // Already initialized - if (sqliteInitError) return; // Failed before, don't retry - if (sqliteInitPromise) return sqliteInitPromise; // Already initializing - - sqliteInitPromise = (async () => { - try { - console.log("[Worker] Initializing SQLite WASM..."); - const startTime = performance.now(); - - // Just call the init module without custom locateFile - // The module will use import.meta.url to find sqlite3.wasm - sqlite3 = await sqlite3InitModule({ - print: console.log, - printErr: console.error, - }); - - const initTime = performance.now() - startTime; - console.log(`[Worker] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`, sqlite3.version); - - // Open a database in memory for now - db = new sqlite3.oo1.DB(':memory:', 'c'); - console.log("[Worker] Database opened"); - - // Create a simple test table - db.exec(` - CREATE TABLE IF NOT EXISTS options ( - name TEXT PRIMARY KEY, - value TEXT - ); - INSERT INTO options (name, value) VALUES - ('theme', 'dark'), - ('layoutOrientation', 'vertical'), - ('headingStyle', 'default'); - `); - console.log("[Worker] Test table created and populated"); - } catch (error) { - sqliteInitError = String(error); - console.error("[Worker] SQLite initialization failed:", error); - throw error; - } - })(); - - return sqliteInitPromise; -} - // Example: your /bootstrap handler placeholder async function handleBootstrap() { console.log("[Worker] Bootstrap request received"); // Try to initialize SQLite with timeout - let dbInfo: any = { dbStatus: 'not initialized' }; + let dbInfo: Record = { dbStatus: 'not initialized' }; - if (sqliteInitError) { - dbInfo = { dbStatus: 'failed', error: sqliteInitError }; + if (sqlInitError) { + dbInfo = { dbStatus: 'failed', error: sqlInitError }; } else { try { // Don't wait too long for SQLite initialization @@ -169,17 +168,16 @@ async function handleBootstrap() { ]); // Query the database if initialized - if (db) { - const stmt = db.prepare('SELECT * FROM options'); + if (sqlProvider.isOpen()) { + const stmt = sqlProvider.prepare('SELECT * FROM options'); + const rows = stmt.all() as Array<{ name: string; value: string }>; const options: Record = {}; - while (stmt.step()) { - const row = stmt.get({}); + for (const row of rows) { options[row.name] = row.value; } - stmt.finalize(); dbInfo = { - sqliteVersion: sqlite3.version.libVersion, + sqliteVersion: sqlProvider.version?.libVersion, optionsFromDB: options, dbStatus: 'connected' }; @@ -212,8 +210,13 @@ async function handleBootstrap() { }); } +interface LocalRequest { + method: string; + url: string; +} + // Main dispatch -async function dispatch(request) { +async function dispatch(request: LocalRequest) { const url = new URL(request.url); console.log("[Worker] Dispatch:", url.pathname); @@ -271,18 +274,18 @@ self.onmessage = async (event) => { const response = await dispatch(request); console.log("[Worker] Dispatch completed, sending response:", id); - // Transfer body back (if any) - self.postMessage({ + // Transfer body back (if any) - use options object for proper typing + (self as unknown as Worker).postMessage({ type: "LOCAL_RESPONSE", id, response - }, response.body ? [response.body] : []); + }, { transfer: response.body ? [response.body] : [] }); } catch (e) { console.error("[Worker] Dispatch error:", e); - self.postMessage({ + (self as unknown as Worker).postMessage({ type: "LOCAL_RESPONSE", id, - error: String(e?.message || e) + error: String((e as Error)?.message || e) }); } };