diff --git a/LOADING_WASM.md b/LOADING_WASM.md index 52462af..90e0e63 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 2b63ba6..ff7ea0c 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 e97b609..a0379bd 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 be93bf2..4fd84cb 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 1ee183f..8a2424b 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 56c93d6..2fd0309 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 0000000..70e3bc1 --- /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); + }); +});