From 265abd281592a0578b3d0934e484434d5b503d8d Mon Sep 17 00:00:00 2001 From: Gldywn <14254051+Gldywn@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:07:24 +0100 Subject: [PATCH] feat(v18): accept instantiateWasm and locateFile in loadModule() Hosts that cannot fetch their own files (an Electron custom protocol registered without supportFetchAPI, as for Beekeeper Studio plugins) had no way to hand libpg-query its WASM: the module started loading at import and loadModule() took no options. - Nothing loads at import; the first loadModule() or async call starts the load, so its options apply. - loadModule(options?) accepts instantiateWasm, adapted to Emscripten's callback form and raced against the hook's failure, and locateFile, forwarded unchanged. A call that sets a hook after the first load started rejects with "initialization already started". - The options are reused for every load, including retries and the fresh instance loaded after a WASM exception; an instance the hook already returned is rejected. - loadModule keeps arity 0 and a zero-argument overload, so it can still be passed as a callback; the hook types are structural, so the declarations need no dom lib. - Docs in the v18 README, LOADING_WASM.md and the @pgsql/parser README; tests in versions/18/test/load-module.test.js. --- LOADING_WASM.md | 36 +++- parser/README.md | 5 +- templates/full/index.ts | 87 ++++++++- versions/18/README.md | 26 ++- versions/18/package.json | 2 +- versions/18/src/index.ts | 87 ++++++++- versions/18/test/load-module.test.js | 254 +++++++++++++++++++++++++++ 7 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 versions/18/test/load-module.test.js diff --git a/LOADING_WASM.md b/LOADING_WASM.md index 52462af0..90e0e63f 100644 --- a/LOADING_WASM.md +++ b/LOADING_WASM.md @@ -1,7 +1,7 @@ ### ⚙️ TL;DR — Serving the `.wasm` File The core issue isn’t the library — it’s that the **`.wasm` file lives in `node_modules` and isn’t automatically served** by Next.js, Webpack, or Turbopack. -You just need to make sure it’s fetchable at runtime. +With the default loader, you just need to make sure it’s fetchable at runtime. #### ✅ Easiest Fix @@ -19,4 +19,36 @@ Bundlers don’t emit `.wasm` files by default — they stay inside `node_module * **Next.js + Webpack:** Add a `.wasm` asset rule or use the copy method. * **Turbopack:** Only the copy method works for now. -* **Dev mode:** Optionally use a watcher script to auto-copy on rebuilds. \ No newline at end of file +* **Dev mode:** Optionally use a watcher script to auto-copy on rebuilds. + +#### Serving it from another URL + +PostgreSQL 18 builds only (`libpg-query@pg18`, the default): `loadModule()` takes no options on 13 to 17. + +Pass `locateFile` to `loadModule()` before any other call: + +```ts +import { loadModule } from 'libpg-query'; + +await loadModule({ locateFile: (path) => `/static/${path}` }); +``` + +#### When the host cannot fetch at all + +PostgreSQL 18 builds only, as above. + +Some hosts have no `fetch` for the page's own files, for example an Electron custom protocol registered without the `supportFetchAPI` privilege. Bundle the bytes of `libpg-query.wasm` with your code and instantiate the module yourself: + +```ts +import { loadModule, parseSync } from 'libpg-query'; + +// wasmBytes: the contents of libpg-query.wasm, bundled with your code +await loadModule({ + instantiateWasm: async (imports) => + (await WebAssembly.instantiate(wasmBytes, imports)).instance, +}); + +parseSync('SELECT 1'); +``` + +`instantiateWasm` must resolve with the `WebAssembly.Instance` (not the `{ instance, module }` pair). `locateFile` is not used when `instantiateWasm` is given. Options are only accepted by the first `loadModule()` call, before any `parse()`/`scan()`/... call. The hook runs again whenever a fresh module is needed (a failed load being retried, or a WASM call that threw), and must create a new instance from the imports it receives each time. Keep the bytes (or a compiled `WebAssembly.Module`) in memory, never the instance: an instance returned twice is rejected. diff --git a/parser/README.md b/parser/README.md index 2b63ba60..ff7ea0c3 100644 --- a/parser/README.md +++ b/parser/README.md @@ -171,7 +171,8 @@ Each PostgreSQL version can be imported directly for better tree-shaking: Each version export provides: - `parse(query: string): Promise` - Parse a query asynchronously -- `parseSync(query: string): ParseResult` - Parse a query synchronously (auto-loads if needed) +- `parseSync(query: string): ParseResult` - Parse a query synchronously (call `await loadModule()` or an async method first; from v18 the module is no longer loaded at import) +- `loadModule(): Promise` - Load the WASM module - `SqlError` - The error class for parsing errors - All TypeScript types for that PostgreSQL version @@ -182,7 +183,7 @@ import { parse, parseSync } from '@pgsql/parser/v17'; // Async parsing const result = await parse('SELECT 1'); -// Sync parsing (auto-loads on first use) +// Sync parsing (the module is loaded once parse() or loadModule() has resolved) const result2 = parseSync('SELECT 2'); ``` diff --git a/templates/full/index.ts b/templates/full/index.ts index e97b6096..a0379bd0 100644 --- a/templates/full/index.ts +++ b/templates/full/index.ts @@ -134,6 +134,19 @@ interface WasmModule { HEAPU8: Uint8Array; } +// Structural stand-ins for WebAssembly.Imports/Instance, which only the dom/webworker libs declare; +// `any` keeps `imports` assignable to WebAssembly.Imports when a hook passes it to WebAssembly.instantiate. +type WasmImports = Record>; +type WasmInstance = { readonly exports: object }; + +export interface LoadModuleOptions { + /** For hosts that cannot fetch the .wasm. Runs on every load, retries and rebuilds included, + * so it must resolve with a new instance each time; a rejection rejects loadModule(). */ + instantiateWasm?: (imports: WasmImports) => Promise; + /** Forwarded to Emscripten unchanged. */ + locateFile?: (path: string, scriptDirectory: string) => string; +} + let wasmModule: WasmModule; // After a WASM call throws, the instance is stale: async calls wait for its @@ -148,10 +161,19 @@ let failures = 0; // about 45, a stack overflow can make the next one spin forever. const MAX_STALE_FAILURES = 10; let staleFailures = 0; +// Fixed by the first loadModule() or async call; every load uses them, reloads included. +let started = false; +let moduleOptions: LoadModuleOptions = {}; +// A hook that returns an instance it already returned would bring back the one a WASM +// exception just corrupted, with imports bound to an older module: refuse it loudly. +const hookInstances = new WeakSet(); function instantiate(): Promise { if (loading) return loading; - const promise = PgQueryModule().then( + let startLoad!: () => void; + const promise = new Promise((resolve) => { + startLoad = () => resolve(createModule()); + }).then( (module: WasmModule) => { wasmModule = module; stale = false; @@ -166,10 +188,42 @@ function instantiate(): Promise { loading = promise; // Rejects only for the callers awaiting it. promise.catch(() => {}); + // Started only now: Emscripten calls locateFile synchronously, and a re-entrant + // loadModule() must find `loading` set and join instead of starting another load. + startLoad(); return promise; } -instantiate(); +async function createModule(): Promise { + const { instantiateWasm, locateFile } = moduleOptions; + const moduleArg: Record = {}; + if (locateFile) moduleArg.locateFile = locateFile; + if (!instantiateWasm) return PgQueryModule(moduleArg); + // Emscripten's instantiateWasm path has no failure callback, so a rejected hook + // would leave the factory pending forever: race it against the hook's failure. + const hookFailed = new Promise((_, reject) => { + moduleArg.instantiateWasm = ( + imports: WasmImports, + receiveInstance: (instance: WasmInstance) => void + ) => { + Promise.resolve() + .then(() => instantiateWasm(imports)) + .then((instance) => { + if (!instance?.exports) { + throw new TypeError('libpg-query: instantiateWasm must resolve with a WebAssembly.Instance'); + } + if (hookInstances.has(instance)) { + throw new TypeError('libpg-query: instantiateWasm must create a new instance on each call'); + } + hookInstances.add(instance); + receiveInstance(instance); + }) + .catch(reject); + return {}; + }; + }); + return Promise.race([PgQueryModule(moduleArg), hookFailed]); +} // An exception out of a WASM call (V8 stack overflow on deeply nested SQL, a // trap, or exit() once the heap limit is hit) skips libpg_query's cleanup and @@ -200,12 +254,41 @@ function ensureLoaded() { if (!wasmModule) throw new Error("WASM module not initialized. Call `loadModule()` first."); } +/** Options are only accepted by the call that starts initialization; later calls without options join it. */ +export function loadModule(): Promise; +// Listed first so `before(loadModule)` or `.then(loadModule)` still type-check, as with 18.1.5. +export function loadModule(options?: LoadModuleOptions): Promise; +// No declared parameter, as in 18.1.5: test runners inspect a hook's arity and source and pass it +// their own context, so only an argument that sets a hook counts as options. export async function loadModule(): Promise { + const options = arguments[0]; + // Read by name once (prototype hooks count), before the state check, so a getter that calls + // loadModule() starts the initialization and the outer call is then rejected as late. + const { instantiateWasm, locateFile } = Object(options); + const hasOptions = instantiateWasm !== undefined || locateFile !== undefined; + if (hasOptions) { + for (const key of Object.keys(options)) { + if (key !== 'instantiateWasm' && key !== 'locateFile') { + throw new TypeError(`libpg-query: unknown loadModule() option "${key}"`); + } + } + for (const [key, value] of Object.entries({ instantiateWasm, locateFile })) { + if (value !== undefined && typeof value !== 'function') { + throw new TypeError(`libpg-query: loadModule() option "${key}" must be a function`); + } + } + if (started) { + throw new Error('libpg-query: initialization already started; pass loadModule() options to the first call'); + } + moduleOptions = { instantiateWasm, locateFile }; + } + started = true; while (!wasmModule || stale) await instantiate(); } function awaitInit Promise>(fn: T): T { return (async (...args: Parameters) => { + started = true; // Re-checked after every await: another call may have failed in between. while (!wasmModule || stale) await instantiate(); return fn(...args); diff --git a/versions/18/README.md b/versions/18/README.md index be93bf2f..4fd84cb7 100644 --- a/versions/18/README.md +++ b/versions/18/README.md @@ -82,11 +82,12 @@ const result = await parse('SELECT * FROM users WHERE active = true'); ### `parseSync(query: string): ParseResult` -Synchronous version that returns the parse tree directly. May throw a parse error. +Synchronous version that returns the parse tree directly. May throw a parse error. Requires `await loadModule()` (or an async call) first. ```typescript -import { parseSync } from 'libpg-query'; +import { loadModule, parseSync } from 'libpg-query'; +await loadModule(); const result = parseSync('SELECT * FROM users WHERE active = true'); // Returns: ParseResult - parsed query object ``` @@ -171,9 +172,9 @@ await loadModule(); const result = parseSync('SELECT * FROM users'); ``` -### `loadModule(): Promise` +### `loadModule(options?: LoadModuleOptions): Promise` -Explicitly initializes the WASM module. Required before using any sync methods. +Explicitly initializes the WASM module. Required before using any sync methods. WASM initialization does not start at import time: the first `loadModule()` or async call starts the initialization. ```typescript import { loadModule, parseSync } from 'libpg-query'; @@ -183,6 +184,23 @@ await loadModule(); const result = parseSync('SELECT * FROM users'); ``` +Both options are optional, and no other key is accepted next to them: + +- `instantiateWasm(imports) => Promise`: instantiate the module yourself, for hosts that cannot fetch `libpg-query.wasm` (for example an Electron custom protocol registered without `supportFetchAPI`). A rejection rejects `loadModule()`. +- `locateFile(path, scriptDirectory) => string`: forwarded to Emscripten, to load `libpg-query.wasm` from another URL. Emscripten does not call it when `instantiateWasm` is given. + +```typescript +import { loadModule } from 'libpg-query'; + +// wasmBytes: the contents of libpg-query.wasm, bundled with your code +await loadModule({ + instantiateWasm: async (imports) => + (await WebAssembly.instantiate(wasmBytes, imports)).instance, +}); +``` + +Options are only accepted by the call that starts initialization: after `loadModule()` or an async method has started it, a call that sets `instantiateWasm` or `locateFile` rejects with an `initialization already started` error, while other calls join it. An argument that sets neither hook (for example a test runner's context, as in `before(loadModule)`) counts as no options. A failed load is retried by the next call with the same options, so retry with `loadModule()` (no arguments). The options are also used when the module is rebuilt after a WASM call throws (for example a stack overflow on very deeply nested SQL). `instantiateWasm` therefore runs on every load and must create a new instance from the imports it receives each time: keep the bytes, or a compiled `WebAssembly.Module`, never the instance. A hook that returns an instance it already returned is rejected. + Note: We recommend using async methods as they handle initialization automatically. Use sync methods only when necessary, and always call `loadModule()` first. ### Type Definitions diff --git a/versions/18/package.json b/versions/18/package.json index 1ee183f3..8a2424ba 100644 --- a/versions/18/package.json +++ b/versions/18/package.json @@ -31,7 +31,7 @@ "wasm:rebuild": "pnpm wasm:make rebuild", "wasm:clean": "pnpm wasm:make clean", "wasm:clean-cache": "pnpm wasm:make clean-cache", - "test": "node --test test/parsing.test.js test/pg18.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js test/input-encoding.test.js test/wasm-recovery.test.js" + "test": "node --test test/parsing.test.js test/pg18.test.js test/fingerprint.test.js test/normalize.test.js test/plpgsql.test.js test/scan.test.js test/errors.test.js test/input-encoding.test.js test/wasm-recovery.test.js test/load-module.test.js" }, "author": "Constructive ", "license": "MIT", diff --git a/versions/18/src/index.ts b/versions/18/src/index.ts index 56c93d61..2fd03095 100644 --- a/versions/18/src/index.ts +++ b/versions/18/src/index.ts @@ -141,6 +141,19 @@ interface WasmModule { HEAPU8: Uint8Array; } +// Structural stand-ins for WebAssembly.Imports/Instance, which only the dom/webworker libs declare; +// `any` keeps `imports` assignable to WebAssembly.Imports when a hook passes it to WebAssembly.instantiate. +type WasmImports = Record>; +type WasmInstance = { readonly exports: object }; + +export interface LoadModuleOptions { + /** For hosts that cannot fetch the .wasm. Runs on every load, retries and rebuilds included, + * so it must resolve with a new instance each time; a rejection rejects loadModule(). */ + instantiateWasm?: (imports: WasmImports) => Promise; + /** Forwarded to Emscripten unchanged. */ + locateFile?: (path: string, scriptDirectory: string) => string; +} + let wasmModule: WasmModule; // After a WASM call throws, the instance is stale: async calls wait for its @@ -155,10 +168,19 @@ let failures = 0; // about 45, a stack overflow can make the next one spin forever. const MAX_STALE_FAILURES = 10; let staleFailures = 0; +// Fixed by the first loadModule() or async call; every load uses them, reloads included. +let started = false; +let moduleOptions: LoadModuleOptions = {}; +// A hook that returns an instance it already returned would bring back the one a WASM +// exception just corrupted, with imports bound to an older module: refuse it loudly. +const hookInstances = new WeakSet(); function instantiate(): Promise { if (loading) return loading; - const promise = PgQueryModule().then( + let startLoad!: () => void; + const promise = new Promise((resolve) => { + startLoad = () => resolve(createModule()); + }).then( (module: WasmModule) => { wasmModule = module; stale = false; @@ -173,10 +195,42 @@ function instantiate(): Promise { loading = promise; // Rejects only for the callers awaiting it. promise.catch(() => {}); + // Started only now: Emscripten calls locateFile synchronously, and a re-entrant + // loadModule() must find `loading` set and join instead of starting another load. + startLoad(); return promise; } -instantiate(); +async function createModule(): Promise { + const { instantiateWasm, locateFile } = moduleOptions; + const moduleArg: Record = {}; + if (locateFile) moduleArg.locateFile = locateFile; + if (!instantiateWasm) return PgQueryModule(moduleArg); + // Emscripten's instantiateWasm path has no failure callback, so a rejected hook + // would leave the factory pending forever: race it against the hook's failure. + const hookFailed = new Promise((_, reject) => { + moduleArg.instantiateWasm = ( + imports: WasmImports, + receiveInstance: (instance: WasmInstance) => void + ) => { + Promise.resolve() + .then(() => instantiateWasm(imports)) + .then((instance) => { + if (!instance?.exports) { + throw new TypeError('libpg-query: instantiateWasm must resolve with a WebAssembly.Instance'); + } + if (hookInstances.has(instance)) { + throw new TypeError('libpg-query: instantiateWasm must create a new instance on each call'); + } + hookInstances.add(instance); + receiveInstance(instance); + }) + .catch(reject); + return {}; + }; + }); + return Promise.race([PgQueryModule(moduleArg), hookFailed]); +} // An exception out of a WASM call (V8 stack overflow on deeply nested SQL, a // trap, or exit() once the heap limit is hit) skips libpg_query's cleanup and @@ -207,12 +261,41 @@ function ensureLoaded() { if (!wasmModule) throw new Error("WASM module not initialized. Call `loadModule()` first."); } +/** Options are only accepted by the call that starts initialization; later calls without options join it. */ +export function loadModule(): Promise; +// Listed first so `before(loadModule)` or `.then(loadModule)` still type-check, as with 18.1.5. +export function loadModule(options?: LoadModuleOptions): Promise; +// No declared parameter, as in 18.1.5: test runners inspect a hook's arity and source and pass it +// their own context, so only an argument that sets a hook counts as options. export async function loadModule(): Promise { + const options = arguments[0]; + // Read by name once (prototype hooks count), before the state check, so a getter that calls + // loadModule() starts the initialization and the outer call is then rejected as late. + const { instantiateWasm, locateFile } = Object(options); + const hasOptions = instantiateWasm !== undefined || locateFile !== undefined; + if (hasOptions) { + for (const key of Object.keys(options)) { + if (key !== 'instantiateWasm' && key !== 'locateFile') { + throw new TypeError(`libpg-query: unknown loadModule() option "${key}"`); + } + } + for (const [key, value] of Object.entries({ instantiateWasm, locateFile })) { + if (value !== undefined && typeof value !== 'function') { + throw new TypeError(`libpg-query: loadModule() option "${key}" must be a function`); + } + } + if (started) { + throw new Error('libpg-query: initialization already started; pass loadModule() options to the first call'); + } + moduleOptions = { instantiateWasm, locateFile }; + } + started = true; while (!wasmModule || stale) await instantiate(); } function awaitInit Promise>(fn: T): T { return (async (...args: Parameters) => { + started = true; // Re-checked after every await: another call may have failed in between. while (!wasmModule || stale) await instantiate(); return fn(...args); diff --git a/versions/18/test/load-module.test.js b/versions/18/test/load-module.test.js new file mode 100644 index 00000000..70e3bc10 --- /dev/null +++ b/versions/18/test/load-module.test.js @@ -0,0 +1,254 @@ +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const wrapperPath = require.resolve('../wasm/index.cjs'); +const gluePath = require.resolve('../wasm/libpg-query.js'); +const wasmPath = path.join(path.dirname(gluePath), 'libpg-query.wasm'); +const realFactory = require(gluePath); + +// Initialization state is module-level, so each case loads its own copy of the +// wrapper, wired to a spy that counts calls to the Emscripten factory. +function freshLibrary() { + const calls = []; + require.cache[gluePath].exports = (moduleArg) => { + calls.push(moduleArg); + return realFactory(moduleArg); + }; + delete require.cache[wrapperPath]; + return { lib: require(wrapperPath), calls }; +} + +async function instantiateFromDisk(imports) { + const { instance } = await WebAssembly.instantiate(fs.readFileSync(wasmPath), imports); + return instance; +} + +const alreadyStarted = /initialization already started/; + +describe('loadModule', { timeout: 30000 }, () => { + it('does not initialize on import', async () => { + const { lib, calls } = freshLibrary(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls.length, 0); + assert.throws(() => lib.parseSync('SELECT 1'), /not initialized/); + }); + + it('initializes once for concurrent calls', async () => { + const { lib, calls } = freshLibrary(); + await Promise.all([lib.loadModule(), lib.loadModule(), lib.parse('SELECT 1')]); + assert.equal(calls.length, 1); + assert.equal(lib.parseSync('SELECT 1').stmts.length, 1); + }); + + it('keeps parse() working without loadModule()', async () => { + const { lib, calls } = freshLibrary(); + assert.equal((await lib.parse('SELECT 1')).stmts.length, 1); + assert.equal(lib.parseSync('SELECT 2').stmts.length, 1); + assert.equal(calls.length, 1); + }); + + it('loads from bytes through instantiateWasm', async () => { + const { lib, calls } = freshLibrary(); + let hookCalls = 0; + await lib.loadModule({ + instantiateWasm: (imports) => { + hookCalls++; + return instantiateFromDisk(imports); + }, + }); + assert.equal(hookCalls, 1); + assert.equal(calls.length, 1); + assert.equal(lib.parseSync('SELECT 1').stmts.length, 1); + assert.ok(lib.scanSync('SELECT 1').tokens.length > 0); + }); + + it('forwards locateFile to Emscripten', async () => { + const { lib } = freshLibrary(); + const requested = []; + await lib.loadModule({ + locateFile: (file, scriptDirectory) => { + requested.push(file); + return path.join(scriptDirectory, file); + }, + }); + assert.deepEqual(requested, ['libpg-query.wasm']); + assert.equal(lib.parseSync('SELECT 1').stmts.length, 1); + }); + + it('rejects options once an async call started initialization', async () => { + const { lib, calls } = freshLibrary(); + const parsing = lib.parse('SELECT 1'); + await assert.rejects(lib.loadModule({ instantiateWasm: instantiateFromDisk }), alreadyStarted); + await lib.loadModule(); + await parsing; + assert.equal(calls.length, 1); + }); + + it('rejects options once loadModule() started initialization, even after it finished', async () => { + const { lib, calls } = freshLibrary(); + const loading = lib.loadModule(); + await assert.rejects(lib.loadModule({ instantiateWasm: instantiateFromDisk }), alreadyStarted); + await loading; + await assert.rejects(lib.loadModule({ instantiateWasm: instantiateFromDisk }), alreadyStarted); + assert.equal(calls.length, 1); + }); + + it('rejects loadModule() when instantiateWasm rejects, then retries the same hook on the next call', async () => { + const { lib, calls } = freshLibrary(); + const failure = new Error('no bytes'); + let attempts = 0; + const hook = async (imports) => { + if (++attempts === 1) throw failure; + return instantiateFromDisk(imports); + }; + await assert.rejects(lib.loadModule({ instantiateWasm: hook }), (error) => error === failure); + assert.throws(() => lib.parseSync('SELECT 1'), /not initialized/); + await assert.rejects(lib.loadModule({ instantiateWasm: instantiateFromDisk }), alreadyStarted); + await lib.loadModule(); + assert.equal(attempts, 2); + assert.equal(calls.length, 2); + assert.equal(lib.parseSync('SELECT 1').stmts.length, 1); + }); + + it('reuses the options when a WASM exception forces a fresh instance', async () => { + const { lib, calls } = freshLibrary(); + let hookCalls = 0; + await lib.loadModule({ + instantiateWasm: (imports) => { + hookCalls++; + return instantiateFromDisk(imports); + }, + }); + await assert.rejects(lib.parse('SELECT 1' + '+1'.repeat(100000))); + assert.equal((await lib.parse('SELECT 1')).stmts.length, 1); + assert.equal(hookCalls, 2); + assert.equal(calls.length, 2); + assert.ok(calls.every((moduleArg) => typeof moduleArg.instantiateWasm === 'function')); + }); + + it('refuses a hook that returns the instance a WASM exception corrupted', async () => { + const { lib, calls } = freshLibrary(); + let instance; + await lib.loadModule({ + instantiateWasm: async (imports) => (instance ??= await instantiateFromDisk(imports)), + }); + assert.throws(() => lib.parseSync('SELECT 1' + '+1'.repeat(100000))); + await assert.rejects(lib.loadModule(), /new instance on each call/); + await assert.rejects(lib.fingerprint('SELECT 1'), /new instance on each call/); + assert.equal(calls.length, 3); + }); + + it('rejects loadModule() when instantiateWasm throws or resolves with a non-instance', async () => { + const throwing = freshLibrary().lib; + await assert.rejects(throwing.loadModule({ instantiateWasm: () => { throw new Error('sync'); } }), /sync/); + + const wrongShape = freshLibrary().lib; + await assert.rejects( + wrongShape.loadModule({ + instantiateWasm: (imports) => WebAssembly.instantiate(fs.readFileSync(wasmPath), imports), + }), + /must resolve with a WebAssembly.Instance/ + ); + }); + + it('joins the pending initialization from re-entrant hooks', async () => { + const joined = []; + // Emscripten calls locateFile synchronously from the factory, instantiateWasm through our adapter. + const viaLocateFile = freshLibrary(); + await viaLocateFile.lib.loadModule({ + locateFile: (file, scriptDirectory) => { + joined.push(viaLocateFile.lib.loadModule()); + return path.join(scriptDirectory, file); + }, + }); + const viaInstantiateWasm = freshLibrary(); + await viaInstantiateWasm.lib.loadModule({ + instantiateWasm: (imports) => { + joined.push(viaInstantiateWasm.lib.loadModule()); + return instantiateFromDisk(imports); + }, + }); + assert.equal(joined.length, 2); + await Promise.all(joined); + assert.equal(viaLocateFile.calls.length, 1); + assert.equal(viaInstantiateWasm.calls.length, 1); + }); + + it('uses the options as passed, not as mutated after the call', async () => { + const { lib } = freshLibrary(); + let passedHookCalls = 0; + const options = { + instantiateWasm: (imports) => { + passedHookCalls++; + return instantiateFromDisk(imports); + }, + }; + const loading = lib.loadModule(options); + options.instantiateWasm = async () => { throw new Error('mutated'); }; + await loading; + assert.equal(passedHookCalls, 1); + }); + + it('rejects unknown keys or non-function hooks without starting initialization', async () => { + const { lib, calls } = freshLibrary(); + await assert.rejects( + lib.loadModule({ instantiateWasm: instantiateFromDisk, wasmBinary: new Uint8Array(0) }), + TypeError + ); + await assert.rejects(lib.loadModule({ instantiateWasm: 'not a function' }), TypeError); + assert.equal(calls.length, 0); + await lib.loadModule(); + assert.equal(calls.length, 1); + }); + + it('treats an argument that sets no hook as no options', async () => { + const { lib, calls } = freshLibrary(); + assert.equal(lib.loadModule.length, 0); + assert.match(String(lib.loadModule), /^[^(]*\(\)/); + await lib.loadModule('ready'); + await Promise.all([lib.loadModule(null), lib.loadModule({}), lib.loadModule({ unrelated: true })]); + assert.equal(calls.length, 1); + }); + + describe('passed by reference to test hooks', () => { + const { lib, calls } = freshLibrary(); + for (const name of ['first suite', 'second suite']) { + describe(name, () => { + before(lib.loadModule); + it('can parse synchronously', () => { + assert.equal(lib.parseSync('SELECT 1').stmts.length, 1); + assert.equal(calls.length, 1); + }); + }); + } + }); + + it('joins from a getter that calls loadModule() while the options are read', async () => { + const { lib, calls } = freshLibrary(); + let joined; + const loading = lib.loadModule({ + get instantiateWasm() { + joined = lib.loadModule(); + return instantiateFromDisk; + }, + }); + await assert.rejects(loading, alreadyStarted); + await joined; + assert.equal(calls.length, 1); + }); + + it('reads hooks defined on a prototype', async () => { + const { lib } = freshLibrary(); + let hookCalls = 0; + class Options { + instantiateWasm(imports) { + hookCalls++; + return instantiateFromDisk(imports); + } + } + await lib.loadModule(new Options()); + assert.equal(hookCalls, 1); + }); +});