Skip to main content
The Node.js application SDK is @varianz/node (install); it works from TypeScript or plain JavaScript. Test-side integrations (@varianz/vitest, @varianz/playwright) are covered under Testing. Requires Node.js 22+ and, for the decorator API, TC39 (stage-3) decorators — TypeScript ≥ 5.0 with experimentalDecorators: false.

Two registration styles

Both produce the same runtime handles and stage API.

Decorators

TC39 decorators register via context.addInitializer(...), so registration happens when the class is instantiated, not at import time. Construct the service before calling getVPointHandle(...).

createVPoints

Patch modes (opts.patch):
  • 'wrap' (default) — returns a proxy; the original object is untouched. Calls on the original object are not intercepted — use the returned instrumented, or:
  • 'mutate' — replaces methods on the original object in place (logs a one-time warning).
  • 'off' — registers without intercepting; dispatch manually via callVPoint().

Other registration paths

  • registerVPoint(vpdBytes, api) — from an offline-built descriptor; encodeVpd(spec) builds one from a DynamicSpec.
  • discover('src/services/**/*.service.ts') — loads matching files and instantiates exported classes so @vpoint initializers fire. Runs in-process, so .ts globs need a TS loader (tsx, ts-node, or --experimental-strip-types).

Schemas

Schemas come from four sources, in fixed precedence: explicit registration options → the @varianz/scanner build-time manifest → first successful invocation → auto-derived Unknown. Run the scanner and your TS types become the schema source — decorators like @struct/@field are then overrides for special cases (canonical names, Kind.Int32 instead of the Float64 default for number, field access modes). Values typed Unknown still support CEL field access at runtime; only CEL struct-literal construction needs a typed schema. Kinds cover the numeric range (Int8–Int128, UInt8–UInt128, Float32/64), Bool, Utf8, Bytes, Struct, and Unknown. Named struct kinds link a parameter to a @struct declaration:
type X = { … } produces no schema. The identical shape declared as an interface or a class resolves to a full struct with typed fields; a type alias resolves to Unknown, and the only signal is a generic “derived schema from runtime values” note. Declare schema-bearing shapes as interface or class.Also Unknown: Map<K,V>, Record<K,V>, Set<T>, Date, Uint8Array, inline nested object literals, union types (string | number, 'a' | 'b'), and the element type of Array<{…}>. string | undefined and string | null resolve correctly as optionals; bigint resolves to Int64.
Argument names differ between the scanned and unscanned paths. With the scanner’s manifest, ctx.args and CEL args.x use your real parameter names. Without it, arguments are named arg0, arg1, … and a CEL expression written against the named model fails to build: Field 'x' not found in struct '…$call-context'. It fails loudly, but it means a coordinator stage authored against a scanned deployment does not apply to an unscanned one.

Local stages

The Node SDK can attach stages in-process without a coordinator — JS/TS functions, CEL, or debug logging — at any anchor. Every attach function takes the same target: a VPoint handle, a VPointRef from createVPoints, or a lookup name.
The stage-function contract:
  • ctx.args holds the call’s arguments, ctx.self the receiver, ctx.vpLookupName the VPoint name. TypeScript erases parameter names, so the keys are arg0, arg1, … unless the scanner has run or the VPoint declares params explicitly.
  • Return CONTINUE to continue the pipeline. Return anything else — including undefined — to override the result.
  • Overrides are converted against the VPoint’s declared return kind; a mistyped one fails the call rather than handing the caller the wrong type.
  • Stage functions must be synchronous. Returning a Promise throws instead of being silently un-awaited.
  • Anchors are exactly Anchor.Validate | Pre | Mid | Post | Audit; the original function runs after the last one.
A stage that observes must still return CONTINUE. The return value is the override, and undefined counts — a stage that logs and returns nothing replaces the call’s result with undefined:
For pure logging prefer attachDebugStage, which cannot make this mistake.
Async VPoints and stages do not compose yet. Attaching any stage to a VPoint whose body returns a Promise makes the call return the stage’s value directly rather than a Promise: await still works, but .then(), .catch() and .finally() throw TypeError. Declaring a numeric returnKind on an async VPoint fails the call outright, with or without a stage. Leave async returns undeclared and await the result.
Handles are disposable on Node 23+; on Node 22 call stage.remove() explicitly:
Every lifecycle method throws once the stage or its VPoint is gone, rather than quietly no-opping. When the SDK is disabled, attaching returns an inert handle with the same methods.

Typed helpers

varianz-scan emits stages.gen.js and stages.gen.d.ts into its output directory (node_modules/.varianz by default), with one helper per scanned VPoint. The helper gives ctx.args the real parameter names and checks the override’s type:
Each helper is bound to its VPoint — passing a VPointRef for a different one throws at attach time instead of attaching a mistyped stage. If importing out of node_modules/.varianz is awkward, point the scanner’s outputDir at a source path or add a tsconfig paths alias.
Local stages apply to every call in the process, not only calls carrying a session ID. Use them for behavior owned by the process under test; use CEL stages through Vitest or Playwright when you need session isolation, cross-service delivery, or samples.

Sessions

The SDK holds the session in AsyncLocalStorage, exposed as VarianzContext:
A VPoint method that receives the session ID as a parameter can declare it with varianzId('sessionId') in its params, and the SDK enters the scope automatically. Framework-specific wiring (Fastify, Koa, Hono, NestJS, gRPC): Propagate sessions.

Coordinator connection

initCoordinator is process-global: a second call with the same configuration returns false and is a no-op; a second call with a different configuration throws coordinator already initialized with a different configuration. Call it once at startup (the test fixtures guard this for you). Omitting endpoint makes the SDK resolve one from VARIANZ_COORDINATOR_ENDPOINT, then varianz.toml, and raise if neither supplies one — it does not fall back to a local-only runtime. Supplying endpoint does not make it authoritative: VARIANZ_COORDINATOR_ENDPOINT outranks it. The test-side clients additionally accept the deprecated VARIANZ_TEST_ENDPOINT / VARIANZ_COORDINATOR_ADDR / VARIANZ_ENDPOINT spellings, with a warning. The environment tags determine which environment-scoped stages reach this instance. For diagnostics: initTracing('info') or initTracing('debug', { json: true }).

Troubleshooting

  • Decorators don’t register — decoration runs on instantiation. Construct the service before getVPointHandle(...).
  • initCoordinator throws “already initialized with a different configuration” — it’s process-global and the second call disagreed with the first. In Vitest, set fileParallelism: false. A second call with an identical configuration is harmless and returns false.
  • Calls aren’t intercepted — you’re calling the original object with the default wrap patch mode. Call through instrumented or use patch: 'mutate'.
  • varianz-scan finds no VPoints — the scanner pre-filters files containing vpoint/createVPoints call sites; importing types alone registers nothing.