Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions LOADING_WASM.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
* **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.
5 changes: 3 additions & 2 deletions parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ Each PostgreSQL version can be imported directly for better tree-shaking:

Each version export provides:
- `parse(query: string): Promise<ParseResult>` - 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<void>` - Load the WASM module
- `SqlError` - The error class for parsing errors
- All TypeScript types for that PostgreSQL version

Expand All @@ -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');
```

Expand Down
87 changes: 85 additions & 2 deletions templates/full/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, any>>;
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<WasmInstance>;
/** 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
Expand All @@ -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<object>();

function instantiate(): Promise<void> {
if (loading) return loading;
const promise = PgQueryModule().then(
let startLoad!: () => void;
const promise = new Promise<WasmModule>((resolve) => {
startLoad = () => resolve(createModule());
}).then(
(module: WasmModule) => {
wasmModule = module;
stale = false;
Expand All @@ -166,10 +188,42 @@ function instantiate(): Promise<void> {
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<WasmModule> {
const { instantiateWasm, locateFile } = moduleOptions;
const moduleArg: Record<string, unknown> = {};
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<never>((_, 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
Expand Down Expand Up @@ -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<void>;
// Listed first so `before(loadModule)` or `.then(loadModule)` still type-check, as with 18.1.5.
export function loadModule(options?: LoadModuleOptions): Promise<void>;
// 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<void> {
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<T extends (...args: any[]) => Promise<any>>(fn: T): T {
return (async (...args: Parameters<T>) => {
started = true;
// Re-checked after every await: another call may have failed in between.
while (!wasmModule || stale) await instantiate();
return fn(...args);
Expand Down
26 changes: 22 additions & 4 deletions versions/18/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -171,9 +172,9 @@ await loadModule();
const result = parseSync('SELECT * FROM users');
```

### `loadModule(): Promise<void>`
### `loadModule(options?: LoadModuleOptions): Promise<void>`

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';
Expand All @@ -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<WebAssembly.Instance>`: 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
Expand Down
2 changes: 1 addition & 1 deletion versions/18/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <developers@constructive.io>",
"license": "MIT",
Expand Down
87 changes: 85 additions & 2 deletions versions/18/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, any>>;
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<WasmInstance>;
/** 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
Expand All @@ -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<object>();

function instantiate(): Promise<void> {
if (loading) return loading;
const promise = PgQueryModule().then(
let startLoad!: () => void;
const promise = new Promise<WasmModule>((resolve) => {
startLoad = () => resolve(createModule());
}).then(
(module: WasmModule) => {
wasmModule = module;
stale = false;
Expand All @@ -173,10 +195,42 @@ function instantiate(): Promise<void> {
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<WasmModule> {
const { instantiateWasm, locateFile } = moduleOptions;
const moduleArg: Record<string, unknown> = {};
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<never>((_, 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
Expand Down Expand Up @@ -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<void>;
// Listed first so `before(loadModule)` or `.then(loadModule)` still type-check, as with 18.1.5.
export function loadModule(options?: LoadModuleOptions): Promise<void>;
// 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<void> {
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<T extends (...args: any[]) => Promise<any>>(fn: T): T {
return (async (...args: Parameters<T>) => {
started = true;
// Re-checked after every await: another call may have failed in between.
while (!wasmModule || stale) await instantiate();
return fn(...args);
Expand Down
Loading
Loading