Node.js Binding (@corsa-bind/napi)

@corsa-bind/napi exposes the corsa Rust workspace to JavaScript runtimes through napi-rs. The performance-critical work stays in Rust; the package gives you an ergonomic, Promise-friendly TypeScript surface on top.

This guide covers the public API. For the authoring model of type-aware lint rules built on the same Rust core, see the Oxlint guide.

Install

bash
npm i @corsa-bind/napi

The published root package is JS-only and pulls in the matching native binary through platform-specific optional dependencies. Supported runtimes:

Runtime Minimum version
Node.js 22+
Deno 2.0+
Bun 1.2+

Deno needs npm resolution with a local node_modules directory and native addon permissions:

bash
deno run --node-modules-dir=auto --allow-ffi --allow-read --allow-env --allow-run ./main.ts

Bun installs and imports the package directly:

bash
bun add @corsa-bind/napi
bun run ./main.ts

IMPORTANT

The package never bundles a Corsa executable. You must provide a compatible binary yourself and pass its path through CorsaApiClient.spawn({ executable }). During local development the repo-local mock binary (vp run -w build_mock) is enough for most flows.

Sync vs. async

Every client method ships in two forms:

  • *Async (Promise-based) — the right choice for production Node services and editor integrations. Work runs off the JavaScript call path.
  • synchronous — convenient for short scripts and latency-bounded tooling, but it runs on the JS call path and will block the event loop.

The examples below use the synchronous form for brevity; prepend Async and await for the non-blocking variant.

Spawning an API client

CorsaApiClient.spawn(options) starts a Corsa process and returns a client.

ts
import { CorsaApiClient } from "@corsa-bind/napi";

const client = CorsaApiClient.spawn({
  executable: "/path/to/corsa",
  cwd: process.cwd(),
  mode: "msgpack",
});

ApiClientOptions

Field Type Default Notes
executable string (required) Path to the Corsa binary you provide.
cwd string process cwd Working directory the checker resolves projects against.
mode "jsonrpc" | "msgpack" "msgpack" Transport. msgpack is fastest on the pinned upstream commit.
requestTimeoutMs number 30000 Per-request timeout.
shutdownTimeoutMs number 2000 Graceful shutdown budget on close().
outboundCapacity number 256 Outbound request queue capacity.
allowUnstableUpstreamCalls boolean false Opt in to unstable upstream endpoints such as printNode.
runExternalCode boolean false Opt in to trusted TypeScript content mapper processes.

Always pair a spawn() with a close() (use try/finally):

ts
try {
  client.initialize();
  // ... work ...
} finally {
  client.close();
}

A complete checker roundtrip

ts
import { CorsaApiClient } from "@corsa-bind/napi";

const client = CorsaApiClient.spawn({ executable: process.env.CORSA_BIN!, mode: "jsonrpc" });

let snapshotHandle: string | undefined;
try {
  const init = client.initialize();

  const snapshot = client.updateSnapshot({ openProject: "./tsconfig.json" });
  snapshotHandle = snapshot.snapshot;
  const project = snapshot.projects[0];
  if (!project) throw new Error("no project resolved");

  // Read a source file as it sees it.
  const sourceFile = client.getSourceFile(snapshot.snapshot, project.id, "./src/index.ts");
  console.log(Buffer.from(sourceFile ?? []).toString("utf8"));

  // Query a type and stringify it.
  const stringType = client.getStringType(snapshot.snapshot, project.id);
  console.log(client.typeToString(snapshot.snapshot, project.id, stringType.id));

  console.log("checker cwd:", init.currentDirectory);
} finally {
  if (snapshotHandle) client.releaseHandle(snapshotHandle);
  client.close();
}

For TypeScript content mappers, keep runExternalCode unset unless the workspace is trusted. Setting it to true forwards TypeScript's --runExternalCode gate to the spawned checker so configured contentMappers may launch their mapper processes. Mapped files then need their positions translated, because the checker sees the mapper's virtual TypeScript rather than the file on disk:

ts
import { SpanMap, contentMappersFromConfig, spanMapForSourceFile } from "@corsa-bind/napi";

contentMappersFromConfig(client.parseConfigFile("./tsconfig.json"));
// -> [{ package: "vue-mapper", extensions: [".vue"] }]

const sourceFile = client.getEncodedSourceFile(snapshot.snapshot, project.id, "./src/App.vue");
sourceFile?.contentMapping?.contentMapper; // "vue-mapper@1.2.3"

const payload = client.getSourceFile(snapshot.snapshot, project.id, "./src/App.vue");
const spanMap = spanMapForSourceFile(payload!);
spanMap?.virtualToOriginalSpan(13, 18, SpanMap.Feature.Hover);
// -> { range: { pos: 15, end: 20 }, fidelity: 0 /* Exact */ }

Content mappers covers the span map semantics — fidelity, per-feature segments, and text that exists in only one of the two files. The same checker path understands TypeScript explicit resource management syntax, including using declarations and await using inside async scopes.

Snapshots and handles

updateSnapshot() returns a snapshot handle plus the resolved projects. Most query methods take (snapshot, projectId, …). Handles are owned by the Rust side — release them with releaseHandle(handle) when you are done, and always close() the client.

updateSnapshot() accepts:

  • openProject — a tsconfig.json to open
  • fileChanges — on-disk file change notifications
  • overlayChanges — in-memory overlay edits

Query methods

All of these have *Async counterparts.

Method Returns
initialize() InitializeResponse (e.g. currentDirectory)
parseConfigFile(file) ConfigResponse
updateSnapshot(params?) { snapshot, projects }
getSourceFile(snapshot, project, file) Buffer | null
getEncodedSourceFile(snapshot, project, file) EncodedSourceFile | null
getStringType(snapshot, project) TypeResponse
getTypeAtPosition(snapshot, project, file, position) TypeResponse | null
getTypesAtPositions(snapshot, project, file, positions) type response list
getSymbolAtPosition(snapshot, project, file, position) symbol response
getSymbolsAtPositions(snapshot, project, file, positions) symbol response list
getAliasedSymbol(snapshot, project, symbol) fully resolved symbol or unknown symbol
getImmediateAliasedSymbol(snapshot, project, symbol) next alias target or null
getExportsOfModule(snapshot, project, symbol) checker-owned module exports
getPropertyOfType(snapshot, project, type, name) symbol response or null
isTypeAssignableTo(snapshot, project, source, target) boolean
getTypeArguments(snapshot, project, type) type list
getTypeOfSymbol(snapshot, project, symbol) TypeResponse | null
getDeclaredTypeOfSymbol(snapshot, project, symbol) TypeResponse | null
typeToString(snapshot, project, type) string

The batch form performs one checker round trip for all positions in one source file. getTypesAtPositions is the type-side counterpart of getSymbolsAtPositions. Alias methods require an alias symbol handle; check the returned symbol's flags before calling getAliasedSymbol, matching the upstream checker contract. Module exports likewise use the checker symbol handle rather than a source name. For endpoints without a typed wrapper, drop down to the raw calls below.

For N+1-prone checker workflows, keep these primitives but run them through the orchestrator helpers:

ts
import { resolveCheckerBatch } from "@corsa-bind/napi/orchestrator";

const facts = resolveCheckerBatch(client, { snapshot, project: project.id }, [
  { key: "nodes", kind: "typesAtPositions", file, positions: nodeStarts },
  { key: "value", kind: "propertyOfType", type: valueType.id, name: "value" },
  { key: "assignable", kind: "isTypeAssignableTo", source: valueType.id, target: targetType.id },
]);

resolveCheckerBatch coalesces repeated positions per file, uses getTypesOfSymbols for symbol-type fan-out, and sends the remaining relation queries (propertyOfType, typeArguments, constraintOfType, alias resolution, assignability, and similar follow-ups) through upstream batchRequests. Use batchCheckerRequests from @corsa-bind/napi when you need the lower-level raw batch primitive directly.

Warning: resolveCheckerBatch is still exported from @corsa-bind/napi for compatibility and warns once at runtime. New code should import it from @corsa-bind/napi/orchestrator so the root entry point stays close to the upstream binding surface.

Raw calls (escape hatch)

When you need an endpoint that does not have a dedicated method, call it directly. callJson is the readable path; callBinary returns the raw msgpack payload for hot loops.

ts
const result = client.callBinary("getSomethingCustom", { snapshot, project: project.id });

callJson/callBinary are how the advanced checker_queries and raw_calls examples reach symbol, signature, and relation endpoints.

NOTE

printNode and other unstable upstream endpoints are disabled unless you pass allowUnstableUpstreamCalls: true. The pinned Corsa commit can panic in internal/printer on real project data — opt in only when you accept that.

Virtual documents

CorsaVirtualDocument edits in-memory documents without any Corsa process. This is the fastest way to prototype rules and editor flows.

ts
import { CorsaVirtualDocument } from "@corsa-bind/napi";

const document = CorsaVirtualDocument.untitled(
  "/virtual/example.ts",
  "typescript",
  "const answer = 41;\n",
);

document.applyChanges([
  {
    range: { start: { line: 0, character: 15 }, end: { line: 0, character: 17 } },
    text: "42",
  },
]);

console.log(document.state()); // { uri, languageId, version, text }
  • CorsaVirtualDocument.untitled(path, languageId, text) — untitled document
  • CorsaVirtualDocument.inMemory(authority, path, languageId, text) — authority-scoped document
  • document.replace(text) — full-text replace
  • document.applyChanges(changes) — incremental LSP-style edits
  • document.state() — current { uri, languageId, version, text }

A VirtualChange with a range splices that range; omit the range to replace the whole document.

Type helpers and Utils

The binding re-exports the Rust-backed type-text helpers used by the lint layer. They work on type-text strings (e.g. "Promise<any>") with no checker process:

ts
import { isUnsafeAssignment, isUnsafeReturn, classifyTypeText, Utils } from "@corsa-bind/napi";

isUnsafeAssignment({ sourceTypeTexts: ["Set<any>"], targetTypeTexts: ["Set<string>"] }); // true
isUnsafeReturn({ sourceTypeTexts: ["Promise<any>"], targetTypeTexts: ["Promise<string>"] });
classifyTypeText("string[]");

Utils groups the predicate family: isStringLikeTypeTexts, isNumberLikeTypeTexts, isPromiseLikeTypeTexts, splitTopLevelTypeText, splitTypeText, and friends. The native lint entry points (runNativeLintRule, nativeLintRuleMetas) are also exported here and are what corsa-oxlint builds on.

LSP and orchestration

  • The Rust crate corsa_lsp provides full LSP client support with virtual document overlays; the Node binding surfaces the document primitives shown above. See the Rust lsp_overlay example for the didOpen/didChange/ didClose flow.
  • CorsaDistributedOrchestrator was removed in 2.0 along with the rest of the Raft-backed replication layer. Worker pooling, project affinity, and caching live in ApiOrchestrator on the Rust side; the Node binding's orchestration entry point is @corsa-bind/napi/orchestrator. See the Architecture charter for why replication went away.

Development

Build and test the binding from the workspace:

bash
vp install
vp run -w build_wrapper
vp test run --config ./vite.config.ts src/bindings/nodejs/corsa_node/ts/**/*.test.ts

Benchmarks for the Node binding:

bash
vp run -w bench_ts

See also