chore(client/lightweight): fix errors in SQL provider & implement crypto provider

This commit is contained in:
Elian Doran 2026-01-06 21:05:53 +02:00
parent ccf4df8e86
commit 405db7cedb
No known key found for this signature in database
3 changed files with 296 additions and 77 deletions

View File

@ -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<Uint8Array> {
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<Uint8Array> {
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);
}
}

View File

@ -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<void>;
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<void> {
// 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<void> {
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 {

View File

@ -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<void> | null = null;
let sqlInitError: string | null = null;
// Initialize SQLite WASM via the provider
async function initSQLite(): Promise<void> {
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<void> | 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<string, unknown> = { 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<string, string> = {};
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)
});
}
};