import { withMappedErrors } from "./internal/error-mapping.js"; import { napi, type NapiAttachOptionsBuilder, type NapiExecOptionsBuilder, type NapiPullProgressCreate, type NapiPullProgressEvent, type NapiPullProgressStream, type NapiSandbox, type NapiSandboxBuilderSetters, type NapiSandboxConfig, } from "./internal/napi.js"; import { ExecHandle, ExecOutput } from "./exec.js"; import { SandboxFsOps } from "./fs.js"; import { LogEntry, LogStream, type LogReadOptions, type LogStreamOptions, logEntryFromNapi, logReadOptionsToNapi, logStreamOptionsToNapi, } from "./logs.js"; import { SandboxHandle, type SandboxStopResult } from "./sandbox-handle.js"; import type { SandboxMetrics } from "./metrics.js"; import { metricsFromNapi } from "./internal/metrics.js"; import { MetricsStream } from "./metrics-stream.js"; import { SandboxSshOps } from "./ssh.js"; /** * Fluent builder for a sandbox. Returned by `Sandbox.builder(name)`. * Sandbox names are limited to 128 UTF-8 bytes. * * The instance IS the napi-rs `SandboxBuilder` class — every setter is a * native call, no TS-side reimplementation. Only the terminal `create()` * method is wrapped here so it returns a TS * `Sandbox` (which adds `Symbol.asyncDispose`, error-mapping, and a few * sync getters on top of the native handle). */ // `interface ... extends NapiSandboxBuilderSetters` is the form that // preserves polymorphic `this` through chained calls — the napi // builder is split into a setters-only base + a terminals interface // (`internal/napi.ts`) precisely so we can extend the base here and // add the TS-flavored terminals (which return TS `Sandbox` / // `PullProgressCreate` instead of the napi shapes). An `Omit<...> & // {...}` type alias would lose the override on every chained `this` // return, leaving `b.image(...).create()` inferred as // `Promise`. export type SandboxConfig = NapiSandboxConfig; export interface SandboxBuilder extends NapiSandboxBuilderSetters { create(): Promise; createWithPullProgress(): Promise; } /** * Pair returned by `SandboxBuilder.createWithPullProgress()` — * the per-layer progress event stream plus a method to await the * final `Sandbox`. */ export class PullProgressCreate { /** @internal */ private readonly inner: NapiPullProgressCreate; /** @internal */ private readonly name: string; /** @internal */ private readonly attached: boolean; /** @internal */ constructor(inner: NapiPullProgressCreate, name: string, attached: boolean) { this.inner = inner; this.name = name; this.attached = attached; } /** * The progress event stream. Iterate with `for await...of` or poll * with `.recv()`. The stream closes once the pull completes. */ get progress(): NapiPullProgressStream { return this.inner.progress; } /** * Async iterator helper: equivalent to `for await (const ev of c.progress)`. * Lets you write `for await (const ev of c) { … }` directly. */ [Symbol.asyncIterator](): AsyncIterator { return this.inner.progress[Symbol.asyncIterator](); } /** Await the sandbox. Resolves once pull + boot finishes. */ async awaitSandbox(): Promise { const inner = await withMappedErrors(() => this.inner.awaitSandbox()); return new Sandbox(inner, this.name, this.attached); } } export class Sandbox implements AsyncDisposable { /** @internal */ readonly inner: NapiSandbox; /** Sandbox name. Names are limited to 128 UTF-8 bytes. */ readonly name: string; readonly ownsLifecycle: boolean; /** @internal use `Sandbox.builder(name).create()` */ constructor(inner: NapiSandbox, name: string, ownsLifecycle = true) { this.inner = inner; this.name = name; this.ownsLifecycle = ownsLifecycle; } // -- statics ------------------------------------------------------------ /** Begin building a new sandbox. Names are limited to 128 UTF-8 bytes. */ static builder(name: string): SandboxBuilder { const nb = new napi.SandboxBuilder(name); let detached = false; const origDetached = nb.detached.bind(nb); const origCreate = nb.create.bind(nb); const origCreateWithPP = nb.createWithPullProgress.bind(nb); const wrapped = nb as unknown as { detached: (enabled: boolean) => SandboxBuilder; }; wrapped.detached = (enabled: boolean) => { detached = enabled; origDetached(enabled); return nb as unknown as SandboxBuilder; }; // Override the terminals so they return a TS Sandbox. (nb as unknown as { create: () => Promise }).create = async () => { const inner = await withMappedErrors(() => origCreate()); return new Sandbox(inner, name, /*ownsLifecycle*/ !detached); }; ( nb as unknown as { createWithPullProgress: () => Promise; } ).createWithPullProgress = async () => { const raw = await withMappedErrors(() => origCreateWithPP()); return new PullProgressCreate(raw, name, /*attached*/ !detached); }; return nb as unknown as SandboxBuilder; } /** * Resume an existing stopped sandbox in attached mode. * Names are limited to 128 UTF-8 bytes. */ static async start(name: string): Promise { const inner = await withMappedErrors(() => napi.Sandbox.start(name)); return new Sandbox(inner, name, /*ownsLifecycle*/ true); } /** * Resume an existing stopped sandbox in detached mode. * Names are limited to 128 UTF-8 bytes. */ static async startDetached(name: string): Promise { const inner = await withMappedErrors(() => napi.Sandbox.startDetached(name), ); return new Sandbox(inner, name, /*ownsLifecycle*/ false); } /** * Look up a database handle for an existing sandbox. * Names are limited to 128 UTF-8 bytes. */ static async get(name: string): Promise { const h = await withMappedErrors(() => napi.Sandbox.get(name)); return new SandboxHandle(h); } /** List all known sandboxes. */ static async list(): Promise { const handles = await withMappedErrors(() => napi.Sandbox.list()); return handles.map((handle) => new SandboxHandle(handle)); } /** * List sandboxes filtered to those carrying all of the given labels * (AND-matched). */ static async listWith(filter: { labels?: Record; }): Promise { const handles = await withMappedErrors(() => napi.Sandbox.listWith(filter)); return handles.map((handle) => new SandboxHandle(handle)); } /** * Remove a stopped sandbox from the database. * Names are limited to 128 UTF-8 bytes. */ static async remove(name: string): Promise { await withMappedErrors(() => napi.Sandbox.remove(name)); } // -- exec --------------------------------------------------------------- async exec(cmd: string, args?: Iterable): Promise { const argv = args ? Array.from(args) : undefined; const raw = await withMappedErrors(() => this.inner.exec(cmd, argv)); return new ExecOutput(raw); } async execWith( cmd: string, configure: (b: NapiExecOptionsBuilder) => NapiExecOptionsBuilder, ): Promise { const builder = configure(new napi.ExecOptionsBuilder()); const raw = await withMappedErrors(() => this.inner.execWithBuilder(cmd, builder), ); return new ExecOutput(raw); } async execStream(cmd: string, args?: Iterable): Promise { const argv = args ? Array.from(args) : undefined; const raw = await withMappedErrors(() => this.inner.execStream(cmd, argv), ); return new ExecHandle(raw); } async execStreamWith( cmd: string, configure: (b: NapiExecOptionsBuilder) => NapiExecOptionsBuilder, ): Promise { const builder = configure(new napi.ExecOptionsBuilder()); const raw = await withMappedErrors(() => this.inner.execStreamWithBuilder(cmd, builder), ); return new ExecHandle(raw); } async shell(script: string): Promise { const raw = await withMappedErrors(() => this.inner.shell(script)); return new ExecOutput(raw); } async shellStream(script: string): Promise { const raw = await withMappedErrors(() => this.inner.shellStream(script)); return new ExecHandle(raw); } // -- attach ------------------------------------------------------------- async attach(cmd: string, args?: Iterable): Promise { const argv = args ? Array.from(args) : undefined; return await withMappedErrors(() => this.inner.attach(cmd, argv)); } async attachWith( cmd: string, configure: (b: NapiAttachOptionsBuilder) => NapiAttachOptionsBuilder, ): Promise { const builder = configure(new napi.AttachOptionsBuilder()); return await withMappedErrors(() => this.inner.attachWithBuilder(cmd, builder), ); } async attachShell(): Promise { return await withMappedErrors(() => this.inner.attachShell()); } // -- filesystem --------------------------------------------------------- fs(): SandboxFsOps { return new SandboxFsOps(this.inner.fs()); } // -- ssh ---------------------------------------------------------------- ssh(): SandboxSshOps { return new SandboxSshOps(this.inner); } // -- config ------------------------------------------------------------- /** * The full configuration this sandbox was created with — image, cpus, * memory, env, mounts, etc. The shape mirrors `SandboxBuilder.build()`. */ async config(): Promise { const json = await withMappedErrors(() => this.inner.configJson()); return remapKeysToCamel(JSON.parse(json)) as SandboxConfig; } // -- logs --------------------------------------------------------------- /** * Read captured output from this sandbox's `exec.log`. * * Backed by an on-disk JSON Lines file the runtime writes via the * relay tap. Works on running and stopped sandboxes alike — no * protocol traffic. Default sources are user output: `stdout`, * `stderr`, and pty-merged `output`. */ async logs(opts?: LogReadOptions): Promise { const napiOpts = logReadOptionsToNapi(opts); const raw = await withMappedErrors(() => this.inner.logs(napiOpts)); return raw.map(logEntryFromNapi); } /** * Stream captured output as it appears, with optional follow. * * Backed by the same on-disk `exec.log` as {@link logs}, but * yields entries lazily. Pass `{ follow: true }` to keep the * stream open past current EOF and pick up new entries as they * are written; otherwise the stream drains the current contents * and ends. Each yielded {@link LogEntry} carries an opaque * `cursor` that can be passed back via * {@link LogStreamOptions.fromCursor} to resume. */ async logStream(opts?: LogStreamOptions): Promise { const napiOpts = logStreamOptionsToNapi(opts); const raw = await withMappedErrors(() => this.inner.logStream(napiOpts)); return new LogStream(raw); } // -- metrics ------------------------------------------------------------ async metrics(): Promise { const raw = await withMappedErrors(() => this.inner.metrics()); return metricsFromNapi(raw); } /** Stream metrics snapshots at the given interval (in milliseconds). */ async metricsStream(intervalMs: number): Promise { const raw = await withMappedErrors(() => this.inner.metricsStream(intervalMs), ); return new MetricsStream(raw); } // -- lifecycle ---------------------------------------------------------- async stop(): Promise { await withMappedErrors(() => this.inner.stop()); } async requestStop(): Promise { await withMappedErrors(() => this.inner.requestStop()); } async stopWithTimeout(timeoutMs: number): Promise { await withMappedErrors(() => this.inner.stopWithTimeout(timeoutMs)); } async kill(): Promise { await withMappedErrors(() => this.inner.kill()); } async requestKill(): Promise { await withMappedErrors(() => this.inner.requestKill()); } async killWithTimeout(timeoutMs: number): Promise { await withMappedErrors(() => this.inner.killWithTimeout(timeoutMs)); } async requestDrain(): Promise { await withMappedErrors(() => this.inner.requestDrain()); } async waitUntilStopped(): Promise { return sandboxStopResultFromNapi( await withMappedErrors(() => this.inner.waitUntilStopped()), ); } async detach(): Promise { await withMappedErrors(() => this.inner.detach()); } async [Symbol.asyncDispose](): Promise { if (!this.ownsLifecycle) return; try { await this.inner.stop(); } catch { // best-effort dispose } } } function sandboxStopResultFromNapi(result: { name: string; status: string; exitCode?: number | null; signal?: number | null; observedAt: number; source?: string | null; }): SandboxStopResult { return { name: result.name, status: result.status as SandboxStopResult["status"], exitCode: result.exitCode ?? null, signal: result.signal ?? null, observedAt: new Date(result.observedAt), source: result.source ?? null, }; } const snakeToCamel = (k: string): string => k.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase()); // eslint-disable-next-line @typescript-eslint/no-explicit-any function remapKeysToCamel(v: any): any { if (Array.isArray(v)) return v.map(remapKeysToCamel); if (v && typeof v === "object") { const out: Record = {}; for (const [k, val] of Object.entries(v)) out[snakeToCamel(k)] = remapKeysToCamel(val); return out; } return v; }