ADR-0003: napi-rs v3 dual-target binding
- Status: Accepted
- Date: 2026-06-22
Context
The Rust core must be consumable from Node (LSP servers, including guitkx's, and CLIs that live in the JS ecosystem) and from the browser (web playgrounds, in-page analysis). These are two different runtime targets with different constraints:
- Node wants a native addon — full speed, no WASM overhead, free filesystem access on the host side.
- The browser needs WebAssembly, and ideally a small artifact that does not
require
SharedArrayBuffer/ COOP-COEP headers (which many static hosts can't set).
The naive approach is to write and maintain two separate bindings (a napi
addon and a wasm-bindgen module), duplicating the FFI surface and its
serialization glue. That doubles maintenance and invites the two surfaces to
drift. See plans/01-ARCHITECTURE.md §4 and
the WASM/bindings research it cites.
A key enabling fact: napi-rs v3 can compile the same binding source to both a
Node native .node addon and a wasm32-wasip1-threads target — "you don't
need to write two different bindings." This collapses the duplication, at the
cost of pinning the MSRV to napi-rs v3's floor (Rust 1.88.0).
Decision
We will write one binding crate, gdscript-ffi, on napi-rs v3, and compile it
to both the Node .node addon and the wasm32 target from a single source.
wasm-bindgen is kept as a documented, optional fallback — not the primary path.
gdscript-ffiis the only crate with napi/wasm glue. It holds a statefulAnalysisHandle(so the analysis cache survives edits across calls), exposes a small, flat surface (applyChange, per-feature queries, plus a stateless one-shotanalyze), and passes JSON POD by copy across the boundary (serde/serde-wasm-bindgen). It never returns a whole AST per call — only the feature result.- Node consumers get the native addon (
@gdscript-analyzer/core) with per-platform prebuilt binaries viaoptionalDependencies. - Browser consumers get the napi-rs wasm target as the primary route.
- Fallback: a dedicated
bindings/wasmcrate using wasm-bindgen (wasm-pack build --target web) is retained and documented, to be used if and when we want a smaller artifact with no SharedArrayBuffer requirement (Biome / Ruff's approach). The choice between the napi-wasm target and the wasm-bindgen fallback is made per measured bundle size in Phase 5. - MSRV is pinned to 1.88.0 — napi-rs v3's floor — for the whole workspace, and CI enforces it.
Consequences
Easier / positive.
- One FFI surface to write, test, and evolve — Node and browser cannot drift apart because they are the same source.
- Native speed on Node (the guitkx / LSP path) with no WASM penalty.
- The stateful
AnalysisHandlekeeps the incremental cache alive across edits on both targets, which is what makes keystroke-latency analysis possible from JS. - Keeping wasm-bindgen as a documented fallback means we are not locked in if the napi-wasm artifact turns out too large or too constrained for static hosting.
Harder / negative — the constraints this creates.
- MSRV is dictated by the binding (1.88.0). A core-crate dependency that
raises its MSRV above ours fails the
msrvCI job; MSRV bumps are deliberate and ADR-worthy. - The napi cross-compile matrix (zig, musl, QEMU, per-platform packaging) is fragile toolchain-wise; we mitigate by wiring it in Phase 0 against an empty binding so failures are toolchain-only, not logic.
- Maintaining the wasm-bindgen fallback is a second (if dormant) path to keep compiling.
- The "strings and structs cross by copy" rule must be respected by every query — returning large payloads (e.g. a full AST) per call would be a performance cliff on the WASM boundary.
Amendment (as-shipped, post-Phase-5)
The napi-wasm route described above was not what shipped. In practice the
browser binding is a separate crate (bindings/wasm, published as
@gdscript-analyzer/wasm) built with wasm-bindgen, not the napi-rs
wasm32-wasip1-threads target — i.e. the "documented fallback" above became
the actual, only browser path. What was kept from this decision: one
shared, engine-neutral core (gdscript-session) that both the napi binding
(gdscript-ffi) and the wasm-bindgen binding (bindings/wasm) delegate to, so
the two surfaces still can't drift apart — the sharing just moved one layer
down (a session crate, not a single FFI crate compiled twice). The MSRV
consequence (1.88.0, napi-rs v3's floor) still applies, since gdscript-ffi
is still on napi-rs v3 for Node. This ADR's Context and Decision are kept
verbatim as the historical record; treat this section, not the body above, as
authoritative for the current architecture.