Skip to content

JavaScript

One package for Node, Bun, Deno, and browsers: @sanbus/galley binds the runtime-neutral bindings/js/core (Session, Node, diagnostics, tree editing) to a native backend where one loads (Node, Bun, Deno) and to WebAssembly otherwise, with no native dependencies beyond the built parser artifacts.

Construct a Session and parse. Languages come from the galley object — load (an explicit artifact file), loadBytes (raw wasm module bytes), loadUrl (fetched) — or from a generated package entry, which opens its own directory with bundled hooks. Sessions open from the language through openSession and share its hook table. A factory either resolves a usable language or rejects — there is no unready state. When no native library is found the WebAssembly backend serves instead, with a one-time performance notice (opt out with GALLEY_QUIET=1); when nothing is found at all, construction explains how to build an artifact. language.backend reports the serving leg ("native" or "wasm").

ts
import { galley } from "@sanbus/galley";

const language = await galley.load("./my-language/libgalley-js-node.dylib");
console.log(language.backend); // "native" or "wasm"
const session = await language.openSession({ maxErrors: 10 });
try {
  session.parse("alpha:12,beta:3");
} finally {
  session.close();
}

The direct package import is the only path where bundled procedures hooks wire automatically. Generated by the build next to your grammar, it mirrors the built Python package (import kv):

ts
import * as kv from "./kv/index.mjs";

await kv.initialize(); // required on Deno; a no-op elsewhere
const session = await kv.openSession({ maxErrors: 10 });

Bare loads never scan: hooks arrive explicitly only.

Browsers use the wasm-only entry, resolved automatically through the browser export condition (verified under vite and webpack with no shims), or imported explicitly:

ts
import { galley } from "@sanbus/galley/browser";

const language = await galley.loadUrl("/parsers/language.wasm");
const session = await language.openSession();

Build

One entry builds both artifacts next to the grammar — the shared native library (serves Node, Bun, and Deno) and the wasm module (serves browsers and the fallback leg):

sh
npx galley build <language-dir>              # both legs
npx galley build <language-dir> --native-only
npx galley build <language-dir> --wasm-only

Generator flags forward verbatim ahead of --emit-metadata: every flag galley build does not own goes to the generator, which owns its surface (documented in Configuration). Anything else is a usage error.

Requires zig 0.16.0 to compile (ZIG_EXECUTABLE names an explicit binary, else zig on PATH, else uvx provisioning the pinned ziglang — neither installed is a loud error naming both install pages). No checkout: the compile inputs ride inside @sanbus/galley-core (compile-kit/). Generating the parser needs neither: galley build runs a prebuilt generator CLI that rides along as a platform optionalDependencies package (@sanbus/galley-cli-<os>-<arch>, same lockstep version), and only falls back to a GALLEY_CHECKOUT bootstrap when that package is absent. GALLEY_CLI names an explicit generator binary. The per-adapter builders (npx galley-js-node, npx galley-js-bun, npx galley-js-wasm, the Deno build.ts) remain as thin wrappers over the same shared gate for single-leg builds.

The command generates the parser (--emit-metadata), builds the artifact through Galley's generic consumer build file, and detects optional hook files next to your grammar (procedures.ts for TypeScript hooks, procedures.c for legacy C hooks, procedures.zig, ll_error_messages.zig). Regenerate after changing the grammar; commit nothing the command generates. One shared library embeds one parser — split grammars across language directories exactly like the other bindings.

Pass a renamed artifact to galley.load (versioned names, staging dirs, caches — anything that is not the standard file in a language directory). The Bun and Deno adapters first try their adapter-named file, then the shared file; either resolves silently to native. Nothing else is searched: a missing artifact is a loud error naming the directory or file.

Every build also writes a package entry (package.json + index.mjs + index.d.mts) so the directory imports directly, with bundled hooks, instead of opening it by path:

ts
import * as kv from "./kv/index.mjs";

await kv.initialize(); // required on Deno; a no-op elsewhere
const session = await kv.openSession();

initialize() loads the bundled hooks on Deno, which has no synchronous module scan; on Node, Bun, and WebAssembly legs the adapter scans synchronously at handle-creation time, so initialize() is a no-op there and one program runs on every runtime. galley.load never scans on any runtime.

Two ways to install, depending on what you are doing:

sh
npm install --install-links   # consuming: self-contained copy, nothing else to install
npm install                   # contributing: live symlink into the checkout

A plain npm install links the bindings without their dependencies, so a contributor must also install inside the adapter package. With --install-links the package is copied with its whole subtree and works with no second install. Copies go stale: after changing binding sources, delete the copied @sanbus directory under node_modules and install again.

Performance Notes

The FFI boundary is the only overhead over the C API:

  • Every method is a direct call into the backend; no JSON or subprocess marshalling.
  • Node handles are Node objects that wrap a stable address in the library's non-relocating storage and keep a strong reference to their owning Session; plain bigint addresses are also accepted wherever a Node is expected, and Number(node) / BigInt(node) recovers the address. Iteration and indexing are zero-copy (for (const child of node), node.at(0), node.length).
  • Text accessors (text, symbolNameBytes, diagnostic tokens) return Uint8Array copies with no UTF-8 decoding; decode on demand (new TextDecoder().decode(bytes) — global on every runtime).
  • parse() accepts string, any buffer view, or a bare ArrayBuffer. Bytes are passed by pointer and length with no UTF-16 transcode; a string is encoded to UTF-8 once per call. The session still copies into its own storage so node text stays valid after return. parseFile accepts a path string, a file: URL, or path bytes. Message texts accept strings or raw bytes, never silently re-encoded.
  • All calls are synchronous and hold no additional threads; sessions are not thread-safe. Use one session per thread or guard externally.
  • One artifact file loads one backend port shared by every session opened from it (spellings included: symlinks resolve to the same port). close() destroys the session only; ports stay cached for the process lifetime, so opening many distinct artifacts accumulates one loaded library or module instance each.
  • A missing artifact reports MissingArtifactError with a build hint. An artifact that exists but cannot be read surfaces the underlying I/O error instead; it is never misreported as missing.

Node text, diagnostics, and expected-token data remain valid only until the next parse on the same session; every accessor copies before returning. Node methods check that their session is still open and throw after session.close() or exiting a using block.

Procedures

Set pub const procedures = true; in your grammar's config.zig and implement the hooks in TypeScript in a procedures.ts file next to your grammar — an ordinary TypeScript module imported by your project and dispatched through a generated shim at runtime. No C anywhere on the consumer side, mirroring Python's procedures.py and Rust's procedures.rs:

ts
// procedures.ts
import type { ProcedureArguments } from "@sanbus/galley";

// Hook bodies stay runtime-neutral: TextDecoder and a probed stderr sink
// exist on Node, Bun, Deno, and browsers alike.
const utf8 = new TextDecoder();
const stderr = (globalThis as { process?: { stderr?: unknown } }).process?.stderr as
  | { write(chunk: string): void }
  | undefined;
function emit(line: string): void {
  if (stderr) stderr.write(`${line}\n`);
  else console.error(line);
}

export function reduction_Pair(args: ProcedureArguments): void {
  const node = args.currentNode();
  if (node === null) return;
  const [line, column] = node.lineColumn() ?? [0, 0];
  const text = utf8.decode(node.text() ?? []);
  emit(`Pair ${text} (${node.length} children) at ${line}:${column}`);
}

export function reduction_KeyTail(args: ProcedureArguments): void {
  args.dropIfEmpty();
}

export function hook_print(args: ProcedureArguments): void {
  const node = args.currentNode();
  if (node === null) return;
  const [line, column] = node.lineColumn() ?? [0, 0];
  const text = utf8.decode(node.text() ?? []);
  emit(`@print "${text}" at ${line}:${column}`);
}

Every language owns its hooks: the generated package entry (and the internal openLanguageDirectory behind it) loads the language directory's procedures module into that language's registry where the runtime can load modules synchronously. On Deno the entry loads the bundled module in initialize() instead. galley.load never scans: hooks arrive explicitly only. Which entries scan:

EntryAuto-scanWithout a scan
Package entry / openLanguageDirectory (Node, Bun, wasm on Node)procedures.* beside the artifactinstall explicitly on the language
galley.load (every runtime)none (bare loads never scan)install explicitly on the language
Package entry on Denovia initialize() (no synchronous loader)warns once when a file is present but unloaded; install explicitly
Browsers, galley.loadBytes, galley.loadUrlnone (no filesystem)install explicitly on the language
ts
import * as kv from "./kv/index.mjs";
import * as procedures from "./procedures.js";

await kv.initialize();
const language = await kv.language();
language.installProcedures(procedures);
const session = await language.openSession();
// or for a single hook:
// language.installProcedure("reduction_KeyTail", (args) => args.dropIfEmpty());

The build detects procedures.ts / procedures.js and generates a shim that routes every grammar hook through one callback, exactly like Python's procedures_python.zig and Go's procedures_go.zig. Unregistered hooks are silent no-ops. Hooks never cross languages: two handles — even on two grammars in one process — resolve same-named hooks independently. Manage them on the language at runtime:

ts
language.installProcedure("reduction_Pair", (args) => { args.currentNode()?.text(); });
language.listProcedures(); // { reduction_Pair: [Function], ... }
language.procedureHook("reduction_Pair"); // the callable, or undefined
language.clearProcedures();

Reduction hooks keep their reduction_<VariableName> names (plus the general reduction); author-defined grammar hooks are declared as hook_<name>. A legacy procedures.c / procedures.cpp file next to the grammar is a fatal build error naming the host file to use instead. Semantic payloads are unavailable through bindings.

Semantic Errors

A hook reports a semantic error when the input parses but its meaning is invalid. reportSemanticError records the diagnostic, marks the node, and returns the running total so hooks can limit themselves. Parsing continues; a syntax-clean parse with any semantic error throws with code Status.ErrorSemantic (-12):

ts
if (value > 999) {
  args.reportSemanticError("value out of range");
}

Read them through session.diagnostic() / session.diagnostics(); the snapshot carries kind === Kind.Semantic and a semantic pair of [variable, message].

Tree Walking

session.walk(root) returns a pre-order Walker over the last successful parse, yielding one { node, depth, isSemanticError } per step with the root at depth 0 — the shared runtime walker, so order and depths match every other binding. The walker is iterable and closable (using supported); skipChildren() prunes the last yielded node's children. Pass true to prune semantic-error subtrees:

ts
using walker = session.walk(session.rootNode()!)!;
for (const step of walker) {
  console.error(`${"  ".repeat(step.depth)}${step.node.symbolName()}`);
}

Error Messages

Run galley --fill-error-messages <language-dir> and edit the generated ll_error_messages.zig next to your grammar. The build command detects it and compiles it into the shared library; session.diagnostic().message then returns your hooks' text instead of the built-in generic renderer. LR grammars use lr_error_messages.zig.

Sessions

ts
import { galley, GalleyError } from "@sanbus/galley";

const language = await galley.load("./my-language/libgalley-js-node.dylib");
await using session = await language.openSession({
  maxErrors: 10,
  recoveryWindow: 500,
});
try {
  const parsed = session.parse("alpha:12,beta:3");
} catch (err) {
  const galleyErr = err as GalleyError;
  console.error(`${galleyErr.diagnostic?.line}:${galleyErr.diagnostic?.column}: ${galleyErr.diagnostic?.message}`);
}

Options mirror the runtime defaults: maxErrors: 10, recoveryWindow: 500, stackOverflowRecovery: false, syntaxErrorStackDepth: 0, verbosity: 0, astPreallocationRatio: -1.0, astPreallocationCap: 0. Overrides register per session through the method:

ts
session.setMessageOverride("Number", "expected a number at {line}:{column}");

Failures throw GalleyError, whose code and diagnostic carry the raw status code and the snapshot for that failure (error.diagnostic is null when no diagnostic, otherwise a Diagnostic; session.diagnostic() returns the last diagnostic). Use after close throws SessionClosedError instead.

Session implements Symbol.dispose so using/await using closes on exit, and close() is idempotent. Every session method that takes a node also accepts Node | bigint; session methods that return nodes return Node. Nodes are bound to their session: root = session.rootNode() then root.text(), root.symbolName(), root.span(), root.lineColumn(), root.parent(), root.firstChild() / root.lastChild() / root.nextSibling() / root.priorSibling(), root.children() (Node[]), root.length, root.at(i), and for (const child of root) all read directly from the node. Editing helpers are available both ways: root.cleanChildren() / session.cleanChildren(root) and root.appendChildren(chain) / session.appendChildren(root, chain) (where chain is a detached head); the remaining tree edits (insertBefore, removeSelf, removeSiblings, insertChildrenAt, removeChildrenAt, promoteChildrenOverWrapper, unlinkWrapper) live on Session and accept Node | bigint. Missing links return null. session.diagnostics() returns every recorded diagnostic. Nodes compare by identity (a.equals(b) checks same session and address), and support Number(node) / BigInt(node) to recover the raw address.

session.diagnostic() returns a frozen snapshot (Diagnostic) with kind, line, column, message, messageAnsi, unexpectedToken, expectedTokens, context, syntaxErrorCount, indentation details, and the full structured recovery information — or null when the last parse succeeded.

Appendix: backends

Node

@sanbus/galley-node over a per-grammar NAPI addon (bindings/js/node/addon.c, raw node_api.h, compiled by the builder with zig cc); TypeScript keeps the neutral FfiPort over the addon. Requires Node 18+. A complete consumer lives in examples/js, built and executed by CI on every push, byte-for-byte identical in output to the C, C++, Rust, Go, and Python examples.

sh
npm install
npx galley-js-node <language-dir>
ts
import { galley } from "@sanbus/galley";

const language = await galley.load("./my-language/libgalley-js-node.dylib");
language.version(); // every grammar query lives on the language
language.hasAst();

ZIG_EXECUTABLE selects zig (else zig on PATH, else uvx provisioning zig 0.16.0). No checkout needed: contributors running from the Galley repository without an assembled kit fall back to GALLEY_CHECKOUT pointing at the checkout — for convenience, GALLEY_CHECKOUT=$(examples/scripts/fetch-galley.sh) fetches one into the system cache, but that cache is examples-only, not part of the bindings. The suite mirrors the universal behavior claim for claim:

sh
cd examples/js
node ../../bindings/js/node/tests/test_bindings.mjs

Bun

@sanbus/galley-bun over zero-dependency bun:ffi, with no native dependencies beyond the built parser library. Requires Bun 1. No extra permissions: unlike Deno, bun:ffi needs no capability flags. A complete consumer lives in examples/js, built and executed by CI on every push, byte-for-byte identical in output to every other example.

sh
cd examples/js
bun install
bunx galley-js-bun .
ts
import { galley } from "@sanbus/galley";

const language = await galley.load("./my-language/libgalley-js-bun.dylib");
const session = await language.openSession();

ZIG_EXECUTABLE selects zig. Bun runs TypeScript directly — the adapter itself needs no build step to run, though bun run build typechecks (and emits dist/ for publishing) via tsc. Hook files work exactly like Node; Bun loads TypeScript synchronously, so procedures.* next to the shared library loads into the language handle — explicit installs compose on top. The suite mirrors the Node suite behavior by behavior:

sh
cd examples/js
bun install
bun ../../bindings/js/bun/tests/test_bindings.mjs

Deno

@sanbus/galley-deno over zero-dependency Deno.dlopen, with no subprocess or code-generation at runtime. Requires Deno 2. Two permissions: --allow-ffi (loading the library) and --allow-read (library discovery, parseFile). A complete consumer lives in examples/js, built and executed by CI on every push, byte-for-byte identical in output to every other example.

sh
cd examples/js
deno task build
ts
import * as kv from "./kv/index.mjs";
import * as procedures from "./procedures.ts";

await kv.initialize();
const language = await kv.language();
language.installProcedures(procedures);
const session = await language.openSession();

ZIG_EXECUTABLE selects zig. Deno runs the adapter's TypeScript sources directly — no build step. Sources use explicit .ts import specifiers, so plain strict deno run / deno check work with no extra flags (already wired into the deno task entries). One difference from Node: Deno has no synchronous module load, so the generated package entry loads its bundled hooks in initialize() instead of at open time:

ts
import * as kv from "./kv/index.mjs";
import * as procedures from "./procedures.ts";

await kv.initialize();
const language = await kv.language();
language.installProcedures(procedures);
const session = await language.openSession();

The suite mirrors the Node suite behavior by behavior. It typechecks the adapter (deno check src/index.ts) and runs the suite with --no-check, matching the Node setup where tests are excluded from tsconfig.json:

sh
cd bindings/js/deno
deno task test

WebAssembly

@sanbus/galley-wasm over a WASI reactor module built from the same C API. Requires Node 18. No extra permissions and no WASI runtime: the adapter embeds a minimal wasi_snapshot_preview1 stub (real entropy and clocks; filesystem calls report unavailable — the file is read by the host and parsed from memory). It runs anywhere WebAssembly runs. A complete consumer lives in examples/js, built and executed by CI on every push, byte-for-byte identical in output to every other example.

sh
cd examples/js
npm install
npx galley-js-wasm .
ts
import { galley } from "@sanbus/galley";

const language = await galley.load("./my-language/libgalley-js-wasm.wasm");
const session = await language.openSession();

ZIG_EXECUTABLE selects zig. Byte and URL sources serve every runtime: galley.loadBytes (raw module bytes) and galley.loadUrl (fetched). Under Node procedures.* next to the module loads into the language handle; elsewhere install explicitly. The suite mirrors the Node suite behavior by behavior:

sh
cd examples/js
npm install
node ../../bindings/js/wasm/tests/test_bindings.mjs

Browsers (wasm only)

No FFI exists in browsers, so the browser entry is a separate wasm-only surface (the same galley object, minus filesystem loads) with no node: specifier anywhere in its graph, so vite and webpack resolve it with no shims (verified: both bundlers pick the browser file for the default import too). @sanbus/galley-wasm ships the matching @sanbus/galley-wasm/browser port helpers.

ts
import { galley } from "@sanbus/galley/browser";

const language = await galley.loadUrl("/parsers/language.wasm");
const session = await language.openSession();

loadBytes works the same way; hooks install explicitly on the language.

Development builds

Every green main push publishes dev versions to the static registry. Dev versions look like 0.1.3-dev.42.gabc123456789:

// .npmrc (replace <R2_NPM_HOSTNAME> with the registry's public host)
@sanbus:registry=https://<R2_NPM_HOSTNAME>/
sh
npm install @sanbus/[email protected]

Dev versions are ephemeral: they expire after about 48 hours, and only the newest ~20 per package are kept. Stable releases stay on npmjs and are served through the same registry; a fresh stable appears here on the next main push after tagging. Pin a stable release for anything durable.