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
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:
deno run --node-modules-dir=auto --allow-ffi --allow-read --allow-env --allow-run ./main.ts
Bun installs and imports the package directly:
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.
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):
try {
client.initialize();
// ... work ...
} finally {
client.close();
}
A complete checker roundtrip
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:
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— atsconfig.jsonto openfileChanges— on-disk file change notificationsoverlayChanges— 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:
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.
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.
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 documentCorsaVirtualDocument.inMemory(authority, path, languageId, text)— authority-scoped documentdocument.replace(text)— full-text replacedocument.applyChanges(changes)— incremental LSP-style editsdocument.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:
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_lspprovides full LSP client support with virtual document overlays; the Node binding surfaces the document primitives shown above. See the Rustlsp_overlayexample for thedidOpen/didChange/didCloseflow. CorsaDistributedOrchestratorwas removed in 2.0 along with the rest of the Raft-backed replication layer. Worker pooling, project affinity, and caching live inApiOrchestratoron 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:
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:
vp run -w bench_ts
See also
- Getting started — first program in Node and Rust
- Oxlint guide — type-aware rules on the same Rust core
- Content mappers — mapped files, span maps, and
runExternalCode - Performance — transport measurements and benchmark entry points
- Examples — runnable Node samples