TypeScript SDK
The TypeScript SDK at sdks/typescript/ exposes three API surfaces from the same mvmforge package:
- Authoring — higher-order function calls and factories that register a workload to be emitted as canonical IR.
- Runtime — host-side
f.remote(...)andsession(...)for calling function-entrypoint workloads. Dev-only by design (per ADR-0010 §2). - Sandbox — typed lifecycle handles (
Sandbox,Process, …) over the local mvm sandbox primitives.
Uses keyword-style higher-order calls per ADR-0003 §3 — the TS analog of Python’s keyword-argument decorator.
Install
Available on npm: mvm-sdk.
npm install mvm-sdk# or: pnpm add mvm-sdk | yarn add mvm-sdkOptional peer deps (only as needed):
npm install zod zod-to-json-schema # for mv.zodSchema(...)npm install @msgpack/msgpack # for format: "msgpack"The SDK runs on Node 22.6+ via --experimental-strip-types — no compile step. Node 22.18+ enables type stripping by default. For local development against this repo (cloned), use cd sdks/typescript && pnpm install instead.
Authoring
Two workload shapes share the same workload({...}) + app({...}) registration. For function entrypoints, func({...}, fn) is the ergonomic shortcut.
Command entrypoint
import * as mv from "mvm-sdk";
mv.workload({ id: "my-service" });
mv.app({ name: "my-service", source: mv.localPath("src"), image: mv.nixPackages(["python312", "curl"]), entrypoint: mv.entrypoint({ command: ["python", "-m", "my_service"] }), resources: mv.resources({ cpuCores: 2, memoryMb: 512, rootfsSizeMb: 1024 }),});Function entrypoint (short form)
import * as mv from "mvm-sdk";
export const add = mv.func( { name: "adder", image: mv.nixPackages(["nodejs_22"]), resources: mv.resources({ cpuCores: 1, memoryMb: 256, rootfsSizeMb: 512 }), module: "adder", }, function add(a: number, b: number) { return a + b; },);
add(2, 3); // → 5 (local)await add.remote(2, 3); // → 5 (dispatched in the VM)mv.func({...}, fn) registers the workload + app + function entrypoint in one call and returns a RemoteFunction<F>. language defaults to "node"; override for cross-language manifest authoring.
Typed parameters and return types populate argsSchema / returnSchema automatically — no mv.zodSchema(...) needed for the common case (TypeScript number → JSON Schema number; use bigint for integer). See Payload schemas for the supported type table and when to reach for zod.
Multi-function apps (ADR-0014 Phase 2)
A workload can bundle several callable functions sharing one image,
one resources block, and one source tree. Repeat
mv.func({name: "X", ...})(...) against the same workload name;
subsequent calls contribute additional entrypoints rather than
registering a new app.
const add = mv.func({ name: "math-svc", module: "math", primary: true })( function add(a: number, b: number) { return a + b; },);
const mul = mv.func({ name: "math-svc", module: "math" })( function mul(a: number, b: number) { return a * b; },);The first call carries the app-level config (image, resources,
source, dependencies, network, dependsOn, mounts, env).
Subsequent calls against the same name: must omit those — the SDK
rejects them with a clear hint pointing at “app-level config goes
on the FIRST decoration.”
primary: true marks the workload’s default function — the one
mvmctl invoke math-svc (no --fn selector) dispatches to. The
SDK auto-marks the first call primary; you can flip it explicitly
via primary: true | false. The validator rejects multi-function
apps with no primary (E_NO_PRIMARY_ENTRYPOINT), multiple
primaries (E_MULTIPLE_PRIMARY_ENTRYPOINTS), or duplicate
(module, function) pairs (E_DUPLICATE_ENTRYPOINT_FUNCTION).
For long-form authoring, mv.app({name: ..., entrypoints: [...]})
takes a plural entrypoints: list directly.
Top-level functions
| Function | Purpose |
|---|---|
workload({ id }) | Declares the workload identity. Once per emit. |
app({...}) | Registers an app. Long-form companion to func(). |
func({...}, fn) | Workload + app + function entrypoint shortcut. Returns RemoteFunction<F>. |
localPath(path, opts?) | Source tree, relative to the manifest. |
nixPackages(packages) | Image declaration. |
entrypoint({...}) | Command-style entrypoint. |
entrypointFunction({ module, function, language?, format?, workingDir?, env?, argsSchema?, returnSchema?, extraImports? }) | Function-style entrypoint. language defaults to "node". |
resources({ cpuCores, memoryMb, rootfsSizeMb }) | Resource sizing. |
pythonDeps({ lockfile, tool? }) / nodeDeps({ lockfile, tool? }) / noDeps() | Dependency declarations. |
network({ mode?, ports?, egress?, peers?, dns? }) | Network posture. host mode rejected for function workloads. |
egress(allowlist) / hostPort(host, port) / dnsNone() / dnsSystem() / dnsResolver(host, port?) | Granular network grants. |
zodSchema(schema) | Convert a Zod schema to JSON Schema for argsSchema= / returnSchema= (requires zod + zod-to-json-schema). |
canonicalize(value) / emitJson() / reset() | Used by the emit subprocess; users normally don’t call these. |
language field
Per ADR-0010 §4, entrypointFunction({ language }) and func({ language }) default to "node" — the SDK records its own language. Override explicitly to author a workload in a different language. Current allowlist: python, node, wasm. The host validator rejects values outside the allowlist with E_UNSUPPORTED_LANGUAGE.
For language: "wasm": module is the relative path of a .wasm (WASI Preview 1) file inside the bundled source tree; function is captured for documentation but unused (wasmtime run invokes _start). Unlocks any compile-to-WASM toolchain (Rust + wasm32-wasi, TinyGo, Zig, AssemblyScript, etc.) without a per-language factory.
Runtime
mv.func({...}, fn) returns a RemoteFunction<F>:
export interface RemoteFunction<F extends (...args: any[]) => any> { (...args: Parameters<F>): ReturnType<F>; remote(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>>; readonly local: F; readonly workloadId: string; readonly format: SerializationFormat;}| Attribute | Notes |
|---|---|
add(2, 3) | Calls the local function. |
add.remote(2, 3) | Async. Encodes args, shells out to mvmctl invoke, decodes the return. |
add.local | The undecorated local function. |
add.workloadId / add.format | Workload id + wire format. |
session(workloadId, body)
Async function holding a single VM warm across calls inside body:
await mv.session("adder", async () => { await add.remote(2, 3); await add.remote(4, 5);});Session id propagates via Node AsyncLocalStorage. Torn down on exit even if body rejected.
Errors
| Class | When |
|---|---|
RemoteError | User code raised. .kind, .errorId (stable), .remoteMessage. |
MvmTransportError | Transport failure. |
MsgpackUnavailable | format: "msgpack" but @msgpack/msgpack not installed. |
SecretInArgError | Strict-mode counterpart to the secret-name heuristic. |
EmittingContextError | Layer-3 call fired during mvmforge emit (MVMFORGE_EMITTING=1 is set). Per ADR-0010 §2. |
Transport caps + timeouts
Same env vars as the Python SDK — see Environment variables.
Dev / production posture
The runtime SDK is dev-only by design. Production microVMs are observed via mvmctl logs and output streams; no host-side .remote() calls. Two safety gates: MVMFORGE_EMITTING=1 (build-time) and the wrapper’s runtime mode = "prod" | "dev" (in-VM).
Sandbox
Mirror of the Python sandbox surface (plan-0010 phase A):
import * as mv from "mvm-sdk";
await using sb = await mv.Sandbox.create({ name: "sandbox-1" });const info = await sb.info();const proc = await sb.spawn(["node", "-e", "console.log('hello')"]);await proc.awaitExit({ timeoutMs: 5000 });await sb.writeFile("/tmp/hello", Buffer.from("hi"));const entries = await sb.listDir("/tmp");Surfaces:
| Symbol | Purpose |
|---|---|
Sandbox | Lifecycle. |
Process / type ProcessInfo | Spawned process handles. |
type FileEntry / type FileStat | Filesystem types. |
type SandboxCreateOpts / type SandboxInfo / type SandboxMetrics / type SnapshotEntry / type UpOpts | Sandbox config + introspection types. |
SandboxError / SandboxNotFound | Failure classes. |
argvBuilders | Argv-corpus shared with Python for parity tests. |
Dev-only — same posture as the runtime SDK.
The emit subprocess contract
Per ADR-0002:
node --experimental-strip-types <ts-main> <entry.ts> with MVMFORGE_IR_OUT=<output-path> MVMFORGE_EMITTING=1<ts-main> defaults to sdks/typescript/src/main.ts; override with MVMFORGE_TS_MAIN. The emitter imports <entry.ts>, calls emitJson(), writes to MVMFORGE_IR_OUT. Exit codes 0 / 1 / 2.
MVMFORGE_IR_OUT=/tmp/ir.json node --experimental-strip-types \ sdks/typescript/src/main.ts /path/to/app.tsInternal layout
sdks/typescript/ package.json tsconfig.json src/ index.ts # public DSL (authoring + runtime + sandbox re-exports) main.ts # subprocess emitter _remote.ts # RemoteFunction + transport + EmittingContextError _session.ts # session() helper (AsyncLocalStorage) _sandbox.ts # Sandbox surface _subprocess.ts # capped + timed mvmctl wrapper ir/workload.ts # generated lower layer (json-schema-to-typescript) tests/ *.test.ts # node:test runner (no test framework dep)The exports of index.ts are stable. src/ir/workload.ts and the underscore modules are internal — pin exact mvmforge versions if you import from them. A future ESLint no-restricted-imports rule will block reaching past the public surface.
Regenerating the lower layer
just sdk-ts-gen # writes sdks/typescript/src/ir/workload.tsjust sdk-ts-check # asserts the committed file matches fresh regenerationjust sdk-ts-check is wired into just ci and fails on drift.
Tests
just sdk-ts-test94 tests use Node’s built-in node:test runner — no test framework dep. Coverage spans the DSL surface, the runtime transport (against a fake mvmctl), the emit-context guard, the secret-name heuristic, the sandbox surface, and the cross-language argv-corpus parity check shared with Python.
Cross-SDK conformance
The TypeScript SDK and Python SDK must produce byte-identical canonical IR for semantically equivalent source. The golden corpus at tests/corpus/ enforces it. just corpus-check runs every active SDK; when you change DSL behavior in one, change it in the other and add a corpus entry that exercises the change.