Skip to content

Commit 6a70be2

Browse files
authored
fix: respect wasm load config params when instantiating sqlite (#24937)
The sqlite3mc we vendor clobbers config params used to find the sqlite wasm file to load. As a result, the bundler-visible default wasm URL was silently discarded on the first call in a given env and wasmBinary was never honored at all. We introduce an index.ts wrapper that takes care of ensuring options survive the first call and every call after. It honors `locateFile`, `wasmBinary`, and `instantiateWasm`, and it defaults to a static `SQLITE3_WASM_URL` that bundlers rewrite to their emitted asset. (We need to follow this up with some simplification of aztec-kit) Closes TRIAGE-200 Fixes #24895
1 parent 52c4d30 commit 6a70be2

4 files changed

Lines changed: 311 additions & 38 deletions

File tree

yarn-project/sqlite3mc-wasm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
]
6060
},
6161
"moduleNameMapper": {
62+
"^\\.\\./vendor/jswasm/(.*)$": "<rootDir>/../vendor/jswasm/$1",
6263
"^(\\.{1,2}/.*)\\.[cm]?js$": "$1"
6364
},
6465
"reporters": [
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
{}
1+
{
2+
"jest": {
3+
"moduleNameMapper": {
4+
"^\\.\\./vendor/jswasm/(.*)$": "<rootDir>/../vendor/jswasm/$1",
5+
"^(\\.{1,2}/.*)\\.[cm]?js$": "$1"
6+
}
7+
}
8+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type { SqlValue } from '@sqlite.org/sqlite-wasm';
2+
import { readFile } from 'node:fs/promises';
3+
4+
import sqlite3InitModule, { type Sqlite3Static } from './index.js';
5+
6+
/**
7+
* These are regression tests that simulate a bundled app: `sqlite3.wasm` is NOT fetchable relative to the module's
8+
* `import.meta.url` (bundlers relocate chunks, so the wasm asset never sits next to them), which is why callers must
9+
* supply the wasm through the standard Emscripten options `wasmBinary` or `locateFile`. The vendored sqlite3mc build
10+
* routes those options through `globalThis.sqlite3InitModuleState`, a handoff that never forwards `wasmBinary` and is
11+
* deleted after the first init call, so the wrapper in index.ts we're testing here must compensate for both.
12+
*/
13+
describe('sqlite3InitModule', () => {
14+
const BUNDLED_WASM_URL = 'https://bundled.example/assets/sqlite3-4f2a.wasm';
15+
const realFetch = globalThis.fetch;
16+
let wasmBinary: Uint8Array<ArrayBuffer>;
17+
let fetchCalls: string[];
18+
19+
beforeAll(async () => {
20+
// Copy into a fresh Uint8Array so the bytes are plain-ArrayBuffer-backed (BufferSource), not a Buffer.
21+
wasmBinary = new Uint8Array(await readFile(new URL('../vendor/jswasm/sqlite3.wasm', import.meta.url)));
22+
});
23+
24+
beforeEach(() => {
25+
fetchCalls = [];
26+
globalThis.fetch = ((input: RequestInfo | URL) => {
27+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
28+
fetchCalls.push(url);
29+
if (url === BUNDLED_WASM_URL) {
30+
return Promise.resolve(new Response(wasmBinary, { headers: { 'content-type': 'application/wasm' } }));
31+
}
32+
return Promise.reject(new Error(`unexpected fetch of ${url}: the wasm is not addressable by URL when bundled`));
33+
}) as typeof fetch;
34+
});
35+
36+
afterEach(() => {
37+
globalThis.fetch = realFetch;
38+
});
39+
40+
/**
41+
* The wrapper's built-in load paths reject on failure, but a regression (or a caller-supplied `instantiateWasm`,
42+
* whose failures Emscripten's hook contract cannot report) hangs forever, so bound the wait and fail with the
43+
* observed fetch calls as evidence instead of hitting the jest timeout.
44+
*/
45+
const settleWithin = <T>(promise: Promise<T>, ms = 20_000): Promise<T> =>
46+
Promise.race([
47+
promise,
48+
new Promise<never>((_, reject) =>
49+
setTimeout(
50+
() =>
51+
reject(
52+
new Error(`sqlite3InitModule did not settle within ${ms}ms; fetch calls: ${JSON.stringify(fetchCalls)}`),
53+
),
54+
ms,
55+
),
56+
),
57+
]);
58+
59+
const expectWorkingDb = (sqlite3: Sqlite3Static) => {
60+
// Open a disposable in-memory SQLite
61+
const db = new sqlite3.oo1.DB(':memory:');
62+
try {
63+
db.exec('CREATE TABLE t(a INTEGER); INSERT INTO t VALUES (40), (2);');
64+
const rows: SqlValue[][] = [];
65+
db.exec({ sql: 'SELECT SUM(a) FROM t', rowMode: 'array', resultRows: rows });
66+
expect(rows).toEqual([[42]]);
67+
} finally {
68+
db.close();
69+
}
70+
};
71+
72+
it('resolves the wasm via a bundler-visible static URL on a bare first call', async () => {
73+
// Must match the literal `new URL('../vendor/jswasm/sqlite3.wasm', import.meta.url)` in src/index.ts, the static
74+
// form bundlers detect and rewrite. This test file sits next to index.ts, so the same expression yields the URL
75+
// the wrapper must produce.
76+
const expectedUrl = new URL('../vendor/jswasm/sqlite3.wasm', import.meta.url).href;
77+
globalThis.fetch = ((input: RequestInfo | URL) => {
78+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
79+
fetchCalls.push(url);
80+
if (url === expectedUrl) {
81+
return Promise.resolve(new Response(wasmBinary, { headers: { 'content-type': 'application/wasm' } }));
82+
}
83+
return Promise.reject(new Error(`unexpected fetch of ${url}`));
84+
}) as typeof fetch;
85+
86+
// In this unbundled test env the vendored fallback (`new URL(path, import.meta.url)` relative to sqlite3.mjs)
87+
// resolves to the same file URL as the static default, so the fetched URL alone cannot distinguish them. Capture
88+
// the state object the wrapper installs to assert the resolution came from an installed locateFile, which is the
89+
// only mechanism bundlers can rewrite.
90+
let installedState: Record<string, unknown> | undefined;
91+
let stateSlot: unknown;
92+
Object.defineProperty(globalThis, 'sqlite3InitModuleState', {
93+
configurable: true,
94+
get: () => stateSlot,
95+
set: value => {
96+
installedState = value as Record<string, unknown>;
97+
stateSlot = value;
98+
},
99+
});
100+
try {
101+
const sqlite3 = await settleWithin(sqlite3InitModule());
102+
expectWorkingDb(sqlite3);
103+
expect(fetchCalls).toEqual([expectedUrl]);
104+
const locate = installedState?.emscriptenLocateFile as ((path: string, prefix: string) => string) | undefined;
105+
expect(typeof locate).toBe('function');
106+
expect(locate!('sqlite3.wasm', '')).toBe(expectedUrl);
107+
} finally {
108+
delete (globalThis as { sqlite3InitModuleState?: unknown }).sqlite3InitModuleState;
109+
}
110+
});
111+
112+
it('honors wasmBinary on the first call instead of fetching the wasm by URL', async () => {
113+
const sqlite3 = await settleWithin(sqlite3InitModule({ wasmBinary }));
114+
expect(fetchCalls).toEqual([]);
115+
expectWorkingDb(sqlite3);
116+
});
117+
118+
it('honors locateFile on the first call', async () => {
119+
const locateCalls: string[] = [];
120+
const sqlite3 = await settleWithin(
121+
sqlite3InitModule({
122+
locateFile: (path: string) => {
123+
locateCalls.push(path);
124+
return BUNDLED_WASM_URL;
125+
},
126+
}),
127+
);
128+
expect(locateCalls).toEqual(['sqlite3.wasm']);
129+
expect(fetchCalls).toEqual([BUNDLED_WASM_URL]);
130+
expectWorkingDb(sqlite3);
131+
});
132+
133+
it('rejects when the wasm cannot be fetched', async () => {
134+
// Bare call: the default locateFile resolves a file:// URL, which the stub rejects like a bundled app whose asset
135+
// pipeline is broken.
136+
await expect(settleWithin(sqlite3InitModule(), 5_000)).rejects.toThrow(/sqlite3 wasm instantiation failed/);
137+
});
138+
139+
it('rejects with the HTTP status when the wasm URL returns an error response', async () => {
140+
globalThis.fetch = ((input: RequestInfo | URL) => {
141+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
142+
fetchCalls.push(url);
143+
return Promise.resolve(new Response('not found', { status: 404, statusText: 'Not Found' }));
144+
}) as typeof fetch;
145+
await expect(settleWithin(sqlite3InitModule({ locateFile: () => BUNDLED_WASM_URL }), 5_000)).rejects.toThrow(
146+
/HTTP 404/,
147+
);
148+
});
149+
150+
it('rejects on corrupt wasmBinary bytes', async () => {
151+
await expect(settleWithin(sqlite3InitModule({ wasmBinary: new Uint8Array([0, 1, 2, 3]) }), 5_000)).rejects.toThrow(
152+
/sqlite3 wasm instantiation failed \(wasmBinary\)/,
153+
);
154+
});
155+
156+
it('keeps honoring caller options on calls after the first', async () => {
157+
await settleWithin(sqlite3InitModule({ wasmBinary }));
158+
const locateCalls: string[] = [];
159+
const sqlite3 = await settleWithin(
160+
sqlite3InitModule({
161+
locateFile: (path: string) => {
162+
locateCalls.push(path);
163+
return BUNDLED_WASM_URL;
164+
},
165+
}),
166+
);
167+
expect(locateCalls).toEqual(['sqlite3.wasm']);
168+
expect(fetchCalls).toEqual([BUNDLED_WASM_URL]);
169+
expectWorkingDb(sqlite3);
170+
});
171+
});
Lines changed: 131 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,134 @@
1+
import type { Sqlite3Static } from '@sqlite.org/sqlite-wasm';
2+
3+
import vendoredInit from '../vendor/jswasm/sqlite3.mjs';
4+
5+
export type { Database, SAHPoolUtil, Sqlite3Static } from '@sqlite.org/sqlite-wasm';
6+
17
/**
2-
* Re-exports sqlite3mc's ES module default (sqlite3InitModule) and the TypeScript types expected by downstream
3-
* consumers. Mirrors the `@sqlite.org/sqlite-wasm` package default export — sqlite3mc is a strict API-compatible
4-
* superset, so upstream types apply unchanged.
8+
* Bundler-visible static reference to the wasm binary. Because the URL argument is a string literal, bundlers detect
9+
* the expression, emit the wasm as an asset, and rewrite the URL, so the default `locateFile` below resolves to the
10+
* emitted asset instead of guessing a path relative to the (relocated) output chunk at runtime.
11+
*/
12+
export const SQLITE3_WASM_URL = new URL('../vendor/jswasm/sqlite3.wasm', import.meta.url);
13+
14+
/**
15+
* Emscripten module-loader options honored by {@link sqlite3InitModule}. Any further options are passed through to the
16+
* vendored module unchanged.
17+
*/
18+
export interface Sqlite3InitOptions {
19+
/** Resolves the URL from which a runtime asset (in practice always `sqlite3.wasm`) is fetched. */
20+
locateFile?: (path: string, prefix: string) => string;
21+
/** Pre-fetched wasm bytes. When set, the wasm is instantiated directly and never fetched by URL. */
22+
wasmBinary?: BufferSource;
23+
/** Custom wasm instantiation hook (standard Emscripten contract). Takes precedence over `wasmBinary`. */
24+
instantiateWasm?: (
25+
imports: WebAssembly.Imports,
26+
onSuccess: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void,
27+
) => object;
28+
[key: string]: unknown;
29+
}
30+
31+
/**
32+
* Initializes the sqlite3mc wasm module.
33+
*
34+
* With no options, the wasm is fetched from {@link SQLITE3_WASM_URL}, which bundlers rewrite to their emitted asset,
35+
* so bundled consumers work by default. Pass `locateFile`, `wasmBinary`, or `instantiateWasm` to override.
536
*
6-
* BUNDLER COMPATIBILITY: SQLite3MultipleCiphers 2.3.5 stopped shipping the `-bundler-friendly` build variant, so
7-
* this entry re-exports the plain `sqlite3.mjs`. That loader always resolves `sqlite3.wasm` through its
8-
* `Module['locateFile']` hook, which computes `new URL(path, import.meta.url)` with a *dynamic* `path` — invisible
9-
* to bundlers, so bundled consumers request an unhashed `sqlite3.wasm` relative to the emitted chunk and 404 at
10-
* runtime (pre-2.3.5, the bundler-friendly variant carried a statically-analyzable reference instead). To stay
11-
* bundler-friendly we wrap the init and inject `emscriptenLocateFile` — the vendored loader's supported escape
12-
* hatch (`Module['locateFile']` defers to it when present on the init-module state) — resolving the wasm via a
13-
* static `new URL('…/sqlite3.wasm', import.meta.url)` that bundlers detect, emit, and rewrite. Unbundled usage is
14-
* unaffected: the static URL resolves to the real vendored path.
37+
* If loading the wasm fails (unreachable URL, HTTP error, corrupt bytes), the returned promise rejects with the cause.
38+
* Exception: failures inside a caller-supplied `instantiateWasm` cannot be observed (Emscripten's hook contract has no
39+
* error channel), so with a custom hook the promise never settles on failure.
1540
*/
16-
import sqlite3InitModuleUnwrapped from '../vendor/jswasm/sqlite3.mjs';
17-
18-
/** Statically analyzable wasm reference: bundlers emit the asset and rewrite this URL to its final location. */
19-
const SQLITE3_WASM_URL = new URL('../vendor/jswasm/sqlite3.wasm', import.meta.url);
20-
21-
type SqliteInitModuleState = {
22-
emscriptenLocateFile?: (path: string, prefix: string) => string;
23-
debugModule?: (...args: unknown[]) => void;
24-
};
25-
26-
const sqlite3InitModule: typeof sqlite3InitModuleUnwrapped = (...args) => {
27-
// `sqlite3.mjs` creates `globalThis.sqlite3InitModuleState` at import time and the inner Emscripten factory
28-
// captures it (and deletes the global) on init, binding it as `this` of `Module['locateFile']`. Mutate the live
29-
// object; recreate it if a previous init already consumed it (`debugModule` must exist — the factory calls it).
30-
const g = globalThis as { sqlite3InitModuleState?: SqliteInitModuleState };
31-
const state = (g.sqlite3InitModuleState ??= Object.assign(Object.create(null), {
32-
debugModule: () => {},
33-
}));
34-
state.emscriptenLocateFile = (path: string, prefix: string) =>
35-
path === 'sqlite3.wasm' ? SQLITE3_WASM_URL.href : new URL(path, prefix || import.meta.url).href;
36-
return sqlite3InitModuleUnwrapped(...args);
37-
};
38-
39-
export default sqlite3InitModule;
40-
export type { Database, SAHPoolUtil, Sqlite3Static } from '@sqlite.org/sqlite-wasm';
41+
export default function sqlite3InitModule(options: Sqlite3InitOptions = {}): Promise<Sqlite3Static> {
42+
return new Promise((resolve, reject) => {
43+
const instantiateWasm =
44+
options.instantiateWasm ??
45+
(options.wasmBinary
46+
? wasmBinaryInstantiator(options.wasmBinary, reject)
47+
: urlInstantiator(options.locateFile ?? defaultLocateFile, reject));
48+
const callOptions = { ...options, instantiateWasm };
49+
installInitModuleState(callOptions);
50+
// The vendored init consumes the installed state synchronously (its pre-js runs before the first await), so
51+
// interleaved calls cannot observe each other's state. On instantiation failure the vendored promise never
52+
// settles (the hook has no error channel), so the instantiators report failure through `reject` instead.
53+
vendoredInit(callOptions).then(resolve, reject);
54+
});
55+
}
56+
57+
/** Builds an Emscripten `instantiateWasm` hook that instantiates the given bytes instead of fetching by URL. */
58+
function wasmBinaryInstantiator(
59+
wasmBinary: BufferSource,
60+
onFailure: (error: Error) => void,
61+
): Required<Sqlite3InitOptions>['instantiateWasm'] {
62+
return (imports, onSuccess) => {
63+
void WebAssembly.instantiate(wasmBinary, imports).then(
64+
({ instance, module }) => onSuccess(instance, module),
65+
error => onFailure(instantiationError('wasmBinary', error)),
66+
);
67+
return {};
68+
};
69+
}
70+
71+
/**
72+
* Builds an Emscripten `instantiateWasm` hook that fetches and instantiates the wasm from the located URL, replacing
73+
* the vendored fallback (which reports failures nowhere). Prefers streaming compilation, falling back to
74+
* buffer-based instantiation when streaming is unavailable or fails (e.g. a server responding without the
75+
* `application/wasm` MIME type, which `instantiateStreaming` rejects).
76+
*/
77+
function urlInstantiator(
78+
locate: (path: string, prefix: string) => string,
79+
onFailure: (error: Error) => void,
80+
): Required<Sqlite3InitOptions>['instantiateWasm'] {
81+
return (imports, onSuccess) => {
82+
const url = locate('sqlite3.wasm', '');
83+
const streaming = WebAssembly.instantiateStreaming
84+
? WebAssembly.instantiateStreaming(fetch(url, { credentials: 'same-origin' }), imports).catch(() =>
85+
fetchAndInstantiate(url, imports),
86+
)
87+
: fetchAndInstantiate(url, imports);
88+
void streaming.then(
89+
({ instance, module }) => onSuccess(instance, module),
90+
error => onFailure(instantiationError(url, error)),
91+
);
92+
return {};
93+
};
94+
}
95+
96+
/** Fetches the wasm and instantiates it from a buffer, surfacing HTTP errors that streaming instantiation obscures. */
97+
async function fetchAndInstantiate(
98+
url: string,
99+
imports: WebAssembly.Imports,
100+
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
101+
const response = await fetch(url, { credentials: 'same-origin' });
102+
if (!response.ok) {
103+
throw new Error(`HTTP ${response.status} ${response.statusText}`.trimEnd());
104+
}
105+
return WebAssembly.instantiate(await response.arrayBuffer(), imports);
106+
}
107+
108+
function instantiationError(source: string, cause: unknown): Error {
109+
const detail = cause instanceof Error ? cause.message : String(cause);
110+
return new Error(`sqlite3 wasm instantiation failed (${source}): ${detail}`, { cause });
111+
}
112+
113+
/**
114+
* Installs the global state object the vendored module's pre-js binds its `Module.locateFile` and
115+
* `Module.instantiateWasm` wrappers to.
116+
*/
117+
function installInitModuleState(options: Sqlite3InitOptions): void {
118+
const urlParams = globalThis.location?.href ? new URL(globalThis.location.href).searchParams : new URLSearchParams();
119+
const debugModule = urlParams.has('sqlite3.debugModule')
120+
? // eslint-disable-next-line no-console -- mirrors the vendored module's own console-based debug channel
121+
(...args: unknown[]) => console.warn('sqlite3.debugModule:', ...args)
122+
: () => {};
123+
(globalThis as { sqlite3InitModuleState?: object }).sqlite3InitModuleState = Object.assign(Object.create(null), {
124+
debugModule,
125+
wasmFilename: 'sqlite3.wasm',
126+
emscriptenLocateFile: options.locateFile ?? defaultLocateFile,
127+
emscriptenInstantiateWasm: options.instantiateWasm,
128+
});
129+
}
130+
131+
/** Resolves the wasm to {@link SQLITE3_WASM_URL} so bundled consumers load the bundler-emitted asset by default. */
132+
function defaultLocateFile(path: string, prefix: string): string {
133+
return path === 'sqlite3.wasm' ? SQLITE3_WASM_URL.href : new URL(path, prefix || import.meta.url).href;
134+
}

0 commit comments

Comments
 (0)