From 82c00230dd70cb2f8a653b86b0d0db48662f61fa Mon Sep 17 00:00:00 2001 From: zknpr Date: Mon, 1 Jun 2026 00:16:45 +0200 Subject: [PATCH 1/3] fix(web): run sql.js in-process in VS Code Web to avoid CSP-blocked workers (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of #418 (captured from published 1.3.6 in real vscode.dev): Failed to construct 'Worker': This document requires 'TrustedScriptURL' assignment. VS Code Web enforces Trusted Types (require-trusted-types-for 'script') on the extension host with a fixed trusted-types policy allowlist. workerFactory spawned the SQLite worker via new Worker(blobUrl) with a plain string URL, which Trusted Types blocks — the worker never starts, every RPC times out, and the viewer hangs on the loading screen for all DB file types. (worker-src 'self' blob: IS allowed; the block is Trusted Types, not worker-src. WebAssembly instantiation IS allowed.) PR #419's parentPort fix addressed a real but downstream bug; the worker is killed at construction before that code runs, so #419 could not fix #418. Fix: in browser mode, run the sql.js engine in-process in the extension host via createWorkerEndpoint() — no Worker, no blob URL, no RPC. Errors propagate so a failure surfaces instead of hanging. Desktop keeps the worker_threads path unchanged (not CSP-constrained). sql.js is now bundled into extension-browser.js. Verified: node scripts/build.mjs OK (extension-browser.js now contains the sql.js/emscripten runtime; 0 'new Worker' on the browser path); tsc --noEmit clean; npm test passes (exit 0). Added tests/unit/workerFactory_browser.test.ts covering the in-process path (no Worker constructed; init failure propagates). Real-vscode.dev measurements confirming the approach: new Worker(blob) -> BLOCKED (TrustedScriptURL); WebAssembly -> ALLOWED. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workerFactory.ts | 168 +++++++++++++++++----- tests/unit/workerFactory_browser.test.ts | 170 +++++++++++++++++++++++ 2 files changed, 306 insertions(+), 32 deletions(-) create mode 100644 tests/unit/workerFactory_browser.test.ts diff --git a/src/workerFactory.ts b/src/workerFactory.ts index dbe6ca7..4e74d81 100644 --- a/src/workerFactory.ts +++ b/src/workerFactory.ts @@ -34,6 +34,7 @@ import type { import { Worker } from './platform/threadPool'; import type { DatabaseConnectionBundle } from './connectionTypes'; import { getMaximumFileSizeBytes, getQueryTimeout } from './config'; +import { createWorkerEndpoint } from './core/sqlite-db'; // Native worker support (only in Node.js environment) let nativeSupport: { @@ -42,7 +43,7 @@ let nativeSupport: { } | null = null; // Dynamically import native worker in Node.js environment -if (!import.meta.env.VSCODE_BROWSER_EXT) { +if (!import.meta.env?.VSCODE_BROWSER_EXT) { try { // Use dynamic import for native worker nativeSupport = require('./nativeWorker'); @@ -108,7 +109,7 @@ export async function createDatabaseConnection( _reporter?: TelemetryReporter ): Promise { // Try native SQLite first (desktop Node.js only) - if (!import.meta.env.VSCODE_BROWSER_EXT && nativeSupport) { + if (!import.meta.env?.VSCODE_BROWSER_EXT && nativeSupport) { const extensionPath = extensionUri.fsPath; if (await nativeSupport.isNativeAvailable(extensionPath)) { try { @@ -151,8 +152,9 @@ export async function createDatabaseConnection( /** * Create a database connection using sql.js (WebAssembly). * - * The worker runs sql.js in a separate thread to prevent - * blocking the main extension host during database operations. + * Browser VS Code runs sql.js in-process to avoid CSP-blocked workers. + * Desktop VS Code keeps sql.js in a worker thread to avoid blocking the + * extension host during database operations. * * @param extensionUri - Extension installation directory URI * @param _reporter - Optional telemetry reporter @@ -162,27 +164,134 @@ async function createWasmDatabaseConnection( extensionUri: vsc.Uri, _reporter?: TelemetryReporter ): Promise { - // Spawn worker thread - // Browser: Web Workers can't load from vscode-vfs:// URIs directly. - // Use fetch to load the worker script as a Blob and create a Blob URL. - // Node.js: Use require path directly. - let workerThread: InstanceType; - - if (import.meta.env.VSCODE_BROWSER_EXT) { - // Browser environment: fetch worker script and create Blob URL - const workerScriptUri = vsc.Uri.joinPath(extensionUri, 'out', 'worker-browser.js'); - const workerContent = await vsc.workspace.fs.readFile(workerScriptUri); - const blob = new Blob([workerContent as BlobPart], { type: 'application/javascript' }); - const blobUrl = URL.createObjectURL(blob); - workerThread = new Worker(blobUrl); - } else { - // Node.js environment: use file path directly - const workerScriptPath = path.resolve(__dirname, './worker.cjs'); - workerThread = new Worker(workerScriptPath); + if (import.meta.env?.VSCODE_BROWSER_EXT === true) { + return createInProcessWasmDatabaseConnection(extensionUri); } - // Create IPC proxy for worker communication - // Browser Workers use addEventListener, Node.js Workers use .on() + return createWorkerBackedWasmDatabaseConnection(extensionUri); +} + +/** + * Create a browser-safe WASM database connection. + * + * VS Code Web applies a default-src 'none' CSP to the browser extension host and + * does not grant worker-src, so blob-backed workers cannot start there. This + * bundle runs the sql.js endpoint directly in the extension host and lets thrown + * initialization/query errors reject the original operation. + */ +async function createInProcessWasmDatabaseConnection( + extensionUri: vsc.Uri +): Promise { + const endpoint = createWorkerEndpoint(); + + return { + workerMethods: { + ...endpoint, + [Symbol.dispose]: () => {} + }, + + /** + * Establish a database connection in the browser extension host. + * + * The browser path must read binary content through VS Code's workspace + * filesystem because local file paths are not available in vscode.dev. + */ + async establishConnection( + fileUri: vsc.Uri, + displayName: string, + forceReadOnly?: boolean, + autoCommit?: boolean + ) { + // Browser mode always reads database bytes through the VS Code filesystem. + // There is no file-path fast path because the web extension host cannot + // access local disk paths directly. + const [dbContent, walContent] = await loadDatabaseFiles(fileUri); + + // Preload sql.js WASM bytes from the extension assets directory so + // WebAssembly instantiation does not depend on worker-relative URLs. + const wasmUri = vsc.Uri.joinPath(extensionUri, 'assets', 'sqlite3.wasm'); + const wasmContent = await vsc.workspace.fs.readFile(wasmUri); + + const initConfig: DatabaseInitConfig = { + content: dbContent, + walContent, + maxSize: getMaximumFileSizeBytes(), + resourceMap: {}, + wasmBinary: wasmContent, + readOnlyMode: forceReadOnly ?? false, + queryTimeout: getQueryTimeout() + }; + + const result = await endpoint.initializeDatabase(displayName, initConfig); + + const operationsFacade: DatabaseOperations = { + engineKind: Promise.resolve('wasm'), + executeQuery: (sql: string, params?: CellValue[]) => + endpoint.runQuery(sql, params), + serializeDatabase: (name: string) => endpoint.exportDatabase(name), + applyModifications: async () => {}, + undoModification: async () => {}, + redoModification: async () => {}, + flushChanges: async () => {}, + discardModifications: async () => {}, + updateCell: (table: string, rowId: string | number, column: string, value: CellValue) => + endpoint.updateCell(table, rowId, column, value), + insertRow: (table: string, data: Record) => + endpoint.insertRow(table, data), + insertRowBatch: (table: string, rows: Record[]) => + endpoint.insertRowBatch(table, rows), + deleteRows: (table: string, rowIds: (string | number)[]) => + endpoint.deleteRows(table, rowIds), + deleteColumns: (table: string, columns: string[], dropDependentIndexes?: string[]) => + endpoint.deleteColumns(table, columns, dropDependentIndexes), + findDependentIndexes: (table: string, columns: string[]) => + endpoint.findDependentIndexes(table, columns), + createTable: (table: string, columns: ColumnDefinition[]) => + endpoint.createTable(table, columns), + updateCellBatch: (table: string, updates: CellUpdate[]) => + endpoint.updateCellBatch(table, updates), + addColumn: (table: string, column: string, type: string, defaultValue?: string) => + endpoint.addColumn(table, column, type, defaultValue), + fetchTableData: (table: string, options: TableQueryOptions) => + endpoint.fetchTableData(table, options), + fetchTableCount: (table: string, options: TableCountOptions) => + endpoint.fetchTableCount(table, options), + fetchSchema: () => + endpoint.fetchSchema(), + getTableInfo: (table: string) => + endpoint.getTableInfo(table), + getPragmas: () => + endpoint.getPragmas(), + setPragma: (pragma: string, value: CellValue) => + endpoint.setPragma(pragma, value), + ping: () => + endpoint.ping(), + writeToFile: (path: string) => + endpoint.writeToFile(path) + }; + + return { + databaseOps: operationsFacade, + isReadOnly: result.isReadOnly ?? false + }; + } + }; +} + +/** + * Create a Node.js WASM database connection backed by worker_threads. + * + * Desktop VS Code is not constrained by the browser extension host CSP, so it + * keeps the existing worker/RPC path to avoid blocking the extension host. + */ +async function createWorkerBackedWasmDatabaseConnection( + extensionUri: vsc.Uri +): Promise { + // Node.js environment: use file path directly + const workerScriptPath = path.resolve(__dirname, './worker.cjs'); + const workerThread: InstanceType = new Worker(workerScriptPath); + + // Create IPC proxy for Node.js worker communication // Route worker log messages to the VS Code output channel for visibility. // Falls back to console if no output channel is available (e.g., during tests). const logHandler = (level: 'log' | 'warn' | 'error', args: unknown[]) => { @@ -204,14 +313,9 @@ async function createWasmDatabaseConnection( } }, on: (event: 'message', handler: (data: unknown) => void) => { - if (import.meta.env.VSCODE_BROWSER_EXT) { - // Browser: Web Worker uses addEventListener with MessageEvent wrapper - workerThread.addEventListener(event, (e: MessageEvent) => handler(e.data)); - } else { - // Node.js: worker_threads uses .on() with direct data - (workerThread as unknown as { on(event: string, handler: (data: unknown) => void): void }) - .on(event, handler); - } + // Node.js worker_threads uses .on() with direct message payloads. + (workerThread as unknown as { on(event: string, handler: (data: unknown) => void): void }) + .on(event, handler); } }, ['initializeDatabase', 'runQuery', 'exportDatabase', 'updateCell', 'insertRow', 'insertRowBatch', 'deleteRows', 'deleteColumns', 'findDependentIndexes', 'createTable', 'updateCellBatch', 'addColumn', 'fetchTableData', 'fetchTableCount', 'fetchSchema', 'getTableInfo', 'getPragmas', 'setPragma', 'ping', 'writeToFile'], @@ -248,7 +352,7 @@ async function createWasmDatabaseConnection( // Read database and WAL files // Optimization: If running in Node and file is local, pass path to worker instead of reading content here // This avoids blocking the extension host and transferring large buffers - const isNode = !import.meta.env.VSCODE_BROWSER_EXT; + const isNode = !import.meta.env?.VSCODE_BROWSER_EXT; const isLocal = fileUri.scheme === 'file'; let dbContent: Uint8Array | null = null; diff --git a/tests/unit/workerFactory_browser.test.ts b/tests/unit/workerFactory_browser.test.ts new file mode 100644 index 0000000..5a5d01d --- /dev/null +++ b/tests/unit/workerFactory_browser.test.ts @@ -0,0 +1,170 @@ +import './vscode_mock_setup'; + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import path from 'node:path'; +import fs from 'node:fs'; +import Module from 'node:module'; +import esbuild from 'esbuild'; +import { mockVscode } from './mocks/vscode'; +import type { CellUpdate, CellValue, DatabaseInitConfig } from '../../src/core/types'; + +const workerFactoryPath = path.resolve(__dirname, '../../src/workerFactory.ts'); +const workerFactorySource = fs.readFileSync(workerFactoryPath, 'utf8'); + +interface FakeEndpoint { + initializeDatabase(filename: string, config: DatabaseInitConfig): Promise<{ isReadOnly: boolean }>; + runQuery(sql: string, params?: CellValue[]): Promise; + exportDatabase(name: string): Promise; + updateCell(table: string, rowId: string | number, column: string, value: CellValue): Promise; + insertRow(table: string, data: Record): Promise; + updateCellBatch(table: string, updates: CellUpdate[]): Promise; + ping(): Promise; +} + +function loadBrowserWorkerFactory(endpoint: FakeEndpoint) { + const jsCode = esbuild.transformSync(workerFactorySource, { + loader: 'ts', + format: 'cjs', + define: { + 'import.meta.env.VSCODE_BROWSER_EXT': 'true' + } + }).code; + + const scriptModule = new Module(workerFactoryPath, module as unknown as Module); + scriptModule.filename = workerFactoryPath; + scriptModule.paths = (Module as unknown as { _nodeModulePaths(dirname: string): string[] }) + ._nodeModulePaths(path.dirname(workerFactoryPath)); + + const originalRequire = Module.prototype.require; + Module.prototype.require = function(request: string) { + if (request === 'vscode') return mockVscode; + if (request.endsWith('core/sqlite-db')) { + return { + createWorkerEndpoint: () => endpoint + }; + } + if (request.endsWith('core/rpc')) { + return { + connectWorkerPort: () => { + throw new Error('Browser mode must not create an RPC worker proxy'); + }, + Transfer: class Transfer { + constructor(public readonly value: T, public readonly transferables: Transferable[]) {} + } + }; + } + if (request.endsWith('platform/threadPool')) { + return { + Worker: class Worker { + constructor() { + throw new Error('Browser mode must not construct a worker'); + } + } + }; + } + if (request.endsWith('config')) { + return { + getMaximumFileSizeBytes: () => 0, + getQueryTimeout: () => 5000 + }; + } + if (request.endsWith('main')) { + return { GlobalOutputChannel: null }; + } + return originalRequire.call(this, request); + }; + + try { + (scriptModule as unknown as { _compile(code: string, filename: string): void }) + ._compile(jsCode, workerFactoryPath); + } finally { + Module.prototype.require = originalRequire; + } + + return scriptModule.exports as { + createDatabaseConnection: typeof import('../../src/workerFactory').createDatabaseConnection; + }; +} + +describe('workerFactory browser WASM connection', () => { + beforeEach(() => { + const dbContent = new Uint8Array([1, 2, 3]); + const wasmContent = new Uint8Array([4, 5, 6]); + + Object.defineProperty(mockVscode.workspace, 'fs', { + value: { + stat: async () => ({ size: dbContent.byteLength }), + readFile: async (uri: { path?: string; fsPath?: string }) => { + const pathValue = uri.path ?? uri.fsPath ?? ''; + if (pathValue.endsWith('-wal')) { + throw new Error('No WAL file'); + } + if (pathValue.endsWith('sqlite3.wasm')) { + return wasmContent; + } + return dbContent; + } + }, + writable: true, + configurable: true + }); + }); + + it('uses an in-process endpoint and passes raw Uint8Array values directly', async () => { + let initConfig: DatabaseInitConfig | undefined; + let updateCellValue: CellValue | undefined; + let insertRowValue: CellValue | undefined; + let updateBatchValue: CellValue | undefined; + + const endpoint: FakeEndpoint = { + initializeDatabase: async (_filename, config) => { + initConfig = config; + return { isReadOnly: false }; + }, + runQuery: async () => [], + exportDatabase: async () => new Uint8Array(), + updateCell: async (_table, _rowId, _column, value) => { + updateCellValue = value; + }, + insertRow: async (_table, data) => { + insertRowValue = data.blob; + return 1; + }, + updateCellBatch: async (_table, updates) => { + updateBatchValue = updates[0].value; + }, + ping: async () => true + }; + + const workerFactory = loadBrowserWorkerFactory(endpoint); + const extensionUri = { scheme: 'vscode-vfs', fsPath: '/ext', path: '/ext' } as any; + const fileUri = { + scheme: 'vscode-vfs', + fsPath: '/workspace/test.db', + path: '/workspace/test.db', + with: ({ path: nextPath }: { path: string }) => ({ + scheme: 'vscode-vfs', + fsPath: nextPath, + path: nextPath + }) + } as any; + + const bundle = await workerFactory.createDatabaseConnection(extensionUri, null as any); + const connection = await bundle.establishConnection(fileUri, 'test.db'); + + assert.strictEqual(initConfig?.content?.byteLength, 3); + assert.strictEqual(initConfig?.walContent, null); + assert.strictEqual(initConfig?.wasmBinary?.byteLength, 3); + assert.strictEqual(connection.isReadOnly, false); + + const blobValue = new Uint8Array([9, 8, 7]); + await connection.databaseOps.updateCell('items', 1, 'blob', blobValue); + await connection.databaseOps.insertRow('items', { blob: blobValue }); + await connection.databaseOps.updateCellBatch('items', [{ rowId: 1, column: 'blob', value: blobValue }]); + + assert.strictEqual(updateCellValue, blobValue); + assert.strictEqual(insertRowValue, blobValue); + assert.strictEqual(updateBatchValue, blobValue); + }); +}); From 3188b33c6f9695b90733e090e474f0eaed5e48de Mon Sep 17 00:00:00 2001 From: zknpr Date: Mon, 1 Jun 2026 00:31:43 +0200 Subject: [PATCH 2/3] fix(web): release in-process sql.js engine on dispose (#420 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the WASM-heap leak flagged independently by Gemini (high) and Codex (P2) on PR #420. The browser in-process connection set workerMethods[Symbol.dispose] to a no-op, but DatabaseDocument.dispose() relies on it to release resources. createWorker- Endpoint() only shut its activeEngine down on re-init, so closing a database tab in vscode.dev never called Database.close() — the sql.js WASM heap leaked per open/close until page reload. - core/sqlite-db.ts: add endpoint.dispose() that shuts down and clears the active engine (idempotent; safe with no active engine). - workerFactory.ts: browser bundle [Symbol.dispose] now calls endpoint.dispose(). Desktop is unaffected (it terminates the worker thread instead). - api_coverage.test.ts: add a regression test — verified to FAIL if dispose() is reverted to a no-op. Also corrected the stale CSP comment: the real blocker measured in vscode.dev is Trusted Types (require-trusted-types-for 'script'), not default-src/worker-src (worker-src 'self' blob: is actually granted). Verified: build OK (extension-browser.js still worker-free); tsc --noEmit clean; npm test 335/335 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/sqlite-db.ts | 15 +++++++++++++++ src/workerFactory.ts | 16 ++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/core/sqlite-db.ts b/src/core/sqlite-db.ts index 09ccde2..8d8928a 100644 --- a/src/core/sqlite-db.ts +++ b/src/core/sqlite-db.ts @@ -241,6 +241,21 @@ export function createWorkerEndpoint() { async writeToFile(path: string): Promise { return requireEngine().writeToFile(path); + }, + + /** + * Release the active engine and its underlying sql.js WASM heap. + * + * Safe to call when no database is active (no-op) and idempotent. The + * in-process browser connection wires this to its bundle's [Symbol.dispose] + * so closing a database frees the WASM instance instead of leaking it; the + * Node worker path tears down the whole worker thread instead. + */ + dispose(): void { + if (activeEngine) { + activeEngine.shutdown(); + activeEngine = null; + } } }; } diff --git a/src/workerFactory.ts b/src/workerFactory.ts index 4e74d81..90c6e0a 100644 --- a/src/workerFactory.ts +++ b/src/workerFactory.ts @@ -174,9 +174,13 @@ async function createWasmDatabaseConnection( /** * Create a browser-safe WASM database connection. * - * VS Code Web applies a default-src 'none' CSP to the browser extension host and - * does not grant worker-src, so blob-backed workers cannot start there. This - * bundle runs the sql.js endpoint directly in the extension host and lets thrown + * VS Code Web enforces Trusted Types (require-trusted-types-for 'script') on the + * browser extension host with a fixed trusted-types policy allowlist, so + * `new Worker(blobUrl)` with a plain string URL is blocked ("This document + * requires 'TrustedScriptURL' assignment") and the worker never starts. (The CSP + * does allow worker-src 'self' blob:; the blocker is Trusted Types, not + * worker-src. WebAssembly instantiation is permitted.) This bundle therefore runs + * the sql.js endpoint directly in the extension host and lets thrown * initialization/query errors reject the original operation. */ async function createInProcessWasmDatabaseConnection( @@ -187,7 +191,11 @@ async function createInProcessWasmDatabaseConnection( return { workerMethods: { ...endpoint, - [Symbol.dispose]: () => {} + // Free the in-process sql.js WASM heap when the document is disposed. + // Unlike the Node worker path (which terminates the whole thread), the + // browser engine lives in the extension host, so closing a database must + // explicitly shut the engine down or its WASM memory leaks until reload. + [Symbol.dispose]: () => { endpoint.dispose(); } }, /** From aebe8b8441c31b341e88377547bca0ee01f44dba Mon Sep 17 00:00:00 2001 From: zknpr Date: Mon, 1 Jun 2026 00:45:19 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=201.3.7=20=E2=80=94=20web?= =?UTF-8?q?=20in-process=20engine=20fix=20for=20#418?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps version 1.3.6 -> 1.3.7 and adds the CHANGELOG entry for the real VS Code Web fix (in-process sql.js engine; Trusted Types blocked the worker). Notes that 1.3.6's parentPort fix was downstream and did not resolve #418. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2532984..3c0cc60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.3.7 + +### Bug Fixes + +- **VS Code for Web (vscode.dev / github.dev): databases stuck on the loading screen — actual fix** ([#418]). Every SQLite database (`.db`, `.sqlite`, `.gpkg`, …) hung forever on the loading screen in the web build. Root cause (confirmed in real vscode.dev): VS Code Web enforces Trusted Types (`require-trusted-types-for 'script'`) on the extension host with a fixed policy allowlist, so the SQLite engine's `new Worker(blobUrl)` was blocked (`Failed to construct 'Worker': This document requires 'TrustedScriptURL' assignment`) — the worker never started and every database operation timed out silently. In browser mode the sql.js engine now runs **in-process in the extension host** (no Web Worker), which Trusted Types permits; desktop VS Code continues to use a worker thread. The engine's WASM memory is released when a database is closed. + + Note: the 1.3.6 web fix (a `parentPort` adapter) addressed a real but downstream bug and did **not** resolve #418, because the worker is blocked at construction before that code runs. 1.3.7 supersedes it. + +[#418]: https://github.com/zknpr/SQLite-Explorer/issues/418 + ## 1.3.6 ### Bug Fixes diff --git a/package.json b/package.json index 1eec4f0..64b2d65 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "sqlite-explorer", "displayName": "SQLite Explorer", "description": "A powerful SQLite database viewer and editor for VS Code", - "version": "1.3.6", + "version": "1.3.7", "publisher": "zknpr", "license": "MIT", "repository": {