Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ core:
@just gha::_step test-ct-runner
@just gha::_step test-bundle
@just gha::_step examples
@just gha::_step test-translate
@just gha::_step conformance
@just gha::_step sched-seeds
@just gha::_step test-ports
Expand Down
12 changes: 11 additions & 1 deletion contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ host-resource methods/statics (class-prototype authority), adds the
stage-3 decorator form, and makes interface members receive their
containing object as `this`; amendment A3 (2026-08-11) lets `instantiate`
accept untranslated artifacts (`{ componentBytes, translator }`) and run
the translation internally.** This document supersedes `descriptor-ir.md`'s interim
the translation internally; amendment A4 (2026-08-11) blesses the
translation envelope as the build-time artifact
(`artifactsFromEnvelope`).** This document supersedes `descriptor-ir.md`'s interim
"host value mapping" table as the destination for host-facing value shapes.
The runtime's *raw* boundary (`instance.exports`, `HostImports`) keeps the
`definitions.py` interpreter shapes as an **internal** surface; the
Expand Down Expand Up @@ -377,6 +379,14 @@ const instance = await instantiate(artifacts, {
worth sharing; warm translation is sub-millisecond).
`requiredImports` still takes a plan: translate explicitly to inspect
the import surface before instantiating.
- **Build-time translation** (A4): the translation ENVELOPE (the
single-file JSON from `Translator.translateRaw` / the `tools/translate`
CLI, carrying plan + FACT adapters) is the blessed deploy artifact —
production ships `component.wasm` + envelope + runtime, no translator.
`artifactsFromEnvelope(envelopeJson, componentBytes)` reconstitutes
`ComponentArtifacts`; the envelope's embedded component sha-256 is
verified at instantiation, so a mismatched deploy pair fails loudly.
Fetch-agnostic by design: the embedder acquires the two blobs.
- **Per-interface module authoring** (the consumers' file layout) is a
helper over the same record: a module's named export, camelCase of the
interface short-name, provides that interface
Expand Down
8 changes: 7 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ ci: (gha::core) (gha::browser)
# Includes the consumer smokes and exams CI cannot run (they need the
# polymorph checkouts and iroh-relay; docs/consumers.md).
# The full pre-commit pass (AGENTS.md "Gates"): everything.
gates: build test-rust test-runtime test-wasi-shims test-ct-runner test-bundle examples conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 iroh-exam
gates: build test-rust test-runtime test-wasi-shims test-ct-runner test-bundle examples test-translate conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 iroh-exam

# Fast sanity: builds + native tests + type-checks, no suites.
check: build test-rust
Expand Down Expand Up @@ -46,6 +46,12 @@ examples: shim
./examples/hello-world/run.sh
./examples/kitchen-sink/run.sh

# Build-time translation CLI (tools/translate, embedder-api A4): translate
# to an envelope, reconstitute artifacts without a translator, verify the
# mismatched-pair refusal.
test-translate: shim
deno test --allow-read --allow-write=/tmp --allow-run tools/translate/translate_test.ts

# Rehearsal finding: 20 runtime e2e tests self-skip when it is absent —
# generation must precede the runtime suite (318/0/3 with; 298/0/23 without).
# The conformance corpus (harness/generated/).
Expand Down
22 changes: 21 additions & 1 deletion runtime/src/embedder/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import type { WirePlan, WireExport } from "../plan/format.ts";
import type { LoadedPlan } from "../plan/loader.ts";
import { loadPlan, PlanError } from "../plan/loader.ts";
import { loadEnvelope, loadPlan, PlanError } from "../plan/loader.ts";
import type { FuncType, ResourceTypeInfo, ValType } from "../cabi/types.ts";
import type { ComponentValue } from "../cabi/types.ts";
import { Trap } from "../cabi/trap.ts";
Expand Down Expand Up @@ -89,6 +89,26 @@ export interface UntranslatedArtifacts {
/** Either artifacts shape accepted by `instantiate`. */
export type InstantiateSource = ComponentArtifacts | UntranslatedArtifacts;

/**
* Reconstitute `ComponentArtifacts` from a translation ENVELOPE — the
* single-file JSON emitted by build-time translation (`tools/translate`,
* or `Translator.translateRaw`), carrying the plan and the FACT adapter
* modules. The production deploy set is `component.wasm` + its envelope +
* the runtime: no translator ships (embedder-api.md amendment A4).
*
* Pure and fetch-agnostic: acquire the two blobs however the platform
* likes (HTTP, fs, bundler asset) and hand them over. The envelope embeds
* the component's sha-256, which `instantiate` verifies — a mismatched
* pair fails loudly at instantiation, never subtly at runtime.
*/
export function artifactsFromEnvelope(
envelopeJson: string,
componentBytes: Uint8Array,
): ComponentArtifacts {
const { wire, adapters } = loadEnvelope(envelopeJson);
return { plan: wire, componentBytes, adapters };
}

async function resolveArtifacts(
src: InstantiateSource,
): Promise<ComponentArtifacts> {
Expand Down
1 change: 1 addition & 0 deletions runtime/src/embedder/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// types that cast this facade; no generated code participates.

export {
artifactsFromEnvelope,
type ComponentArtifacts,
type EmbedderInstance,
type EmbedderOptions,
Expand Down
50 changes: 50 additions & 0 deletions tools/translate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# tools/translate — build-time translation

Translate a component **once, at build/deploy time**, so production never
ships the translator (~0.5 MB gzip of wasm). The deploy set becomes:

```
component.wasm # unchanged
component.plan.json # the translation envelope: plan + FACT adapters
your host + @deltic/runtime
```

## Translate

```sh
deno run --allow-read --allow-write tools/translate/main.ts \
app.component.wasm # writes app.component.plan.json
```

(`-o out.plan.json` to choose the destination, `--shim path` to point at a
translator build other than the repo's.)

## Deploy host

```ts
import { artifactsFromEnvelope, instantiate } from "@deltic/runtime/embedder";

const [envelope, componentBytes] = await Promise.all([
fetch("/app.component.plan.json").then((r) => r.text()),
fetch("/app.component.wasm").then((r) => r.arrayBuffer()),
]);
const component = await instantiate(
artifactsFromEnvelope(envelope, new Uint8Array(componentBytes)),
imports,
);
```

Acquisition is deliberately yours (HTTP above; `Deno.readFile`/`node:fs`
work the same) — `artifactsFromEnvelope` is pure. The envelope embeds the
component's sha-256 and length, which `instantiate` verifies: a mismatched
deploy pair (stale envelope, wrong component) **fails loudly at
instantiation**, pinned by `translate_test.ts`.

## When to prefer runtime translation instead

Components that arrive dynamically (plugin systems) can't pre-translate:
use `instantiate({ componentBytes, translator }, …)` (embedder-api A3)
with the translator asset, and let the runtime's artifact cache
(`@deltic/runtime/cache`) amortize repeat visits. The full delivery
decision tree is in the design note on
[#16](https://github.com/lann/deltic/issues/16).
69 changes: 69 additions & 0 deletions tools/translate/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Build-time translation CLI (issue #16, delivery design note item 2).
//
// Translates a component ONCE, at build/deploy time, so production never
// ships the ~0.5 MB (gzip) translator wasm — the deploy set becomes:
//
// component.wasm (unchanged)
// component.plan.json (this tool's output: the translation envelope
// — plan + FACT adapter modules, base64)
// your host code + @deltic/runtime
//
// and the host reconstitutes artifacts with `artifactsFromEnvelope` (see
// README.md). The envelope embeds the component's sha-256; `instantiate`
// verifies the pair, so a mismatched deploy fails loudly, not subtly.
//
// Usage:
// deno run --allow-read --allow-write tools/translate/main.ts \
// <component.wasm> [-o <out.plan.json>] [--shim <translator_shim.wasm>]
//
// Defaults: -o <component>.plan.json next to the input; --shim resolves to
// the repo's built translator (consumers of the published package will get
// a default translator from @deltic/translator once #16 packaging lands).

import { Translator } from "@deltic/runtime/shim";

function usage(): never {
console.error(
"usage: translate <component.wasm> [-o <out.plan.json>] [--shim <translator_shim.wasm>]",
);
Deno.exit(2);
}

let input: string | undefined;
let output: string | undefined;
let shimPath = new URL(
"../../target/wasm32-unknown-unknown/release/translator_shim.wasm",
import.meta.url,
).pathname;

const args = [...Deno.args];
while (args.length) {
const a = args.shift()!;
if (a === "-o") output = args.shift() ?? usage();
else if (a === "--shim") shimPath = args.shift() ?? usage();
else if (a.startsWith("-")) usage();
else if (input === undefined) input = a;
else usage();
}
if (input === undefined) usage();
output ??= input.replace(/\.wasm$/, "") + ".plan.json";

const componentBytes = await Deno.readFile(input);
const translator = await Translator.create(await Deno.readFile(shimPath));

const t0 = performance.now();
// `translateRaw` IS the artifact: the envelope JSON carries the plan and
// the FACT adapters (base64) in one deterministic file — the same format
// the runtime's artifact cache stores (runtime/src/cache).
const envelope = translator.translateRaw(componentBytes);
const ms = (performance.now() - t0).toFixed(1);

// Fail here, not at deploy time, if the translator rejected the component.
const { loadEnvelope } = await import("@deltic/runtime/plan");
const { wire, adapters } = loadEnvelope(envelope);

await Deno.writeTextFile(output, envelope);
console.log(
`${output}: ${envelope.length} bytes (${adapters.size} adapters, ` +
`${wire.imports.length} imports) in ${ms}ms`,
);
113 changes: 113 additions & 0 deletions tools/translate/translate_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// End-to-end pin for the build-time translation path (embedder-api.md A4):
// the CLI translates a fixture component to an envelope file, and a "deploy
// host" that never sees the translator reconstitutes artifacts from the
// envelope and runs the component. Also pins the loud-failure pairing check
// (envelope of component A + bytes of component B must refuse).

import { assertEq } from "../../runtime/tests/support/asserts.ts";
import {
artifactsFromEnvelope,
instantiate,
} from "../../runtime/src/embedder/mod.ts";

const here = new URL(".", import.meta.url);
const repo = new URL("../../", import.meta.url);

async function exists(url: URL): Promise<boolean> {
try {
await Deno.stat(url);
return true;
} catch {
return false;
}
}

const shim = new URL(
"target/wasm32-unknown-unknown/release/translator_shim.wasm",
repo,
);
const imports = new URL("crates/translator-shim/testdata/imports.wasm", repo);
const hello = new URL(
"crates/translator-shim/testdata/trivial.wasm",
repo,
);
const ready = (await exists(shim)) && (await exists(imports));

async function runCli(
args: string[],
): Promise<{ code: number; stdout: string; stderr: string }> {
const cmd = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-read",
"--allow-write=/tmp",
new URL("main.ts", here).pathname,
...args,
],
stdout: "piped",
stderr: "piped",
});
const out = await cmd.output();
const dec = new TextDecoder();
return {
code: out.code,
stdout: dec.decode(out.stdout),
stderr: dec.decode(out.stderr),
};
}

Deno.test({
name: "translate CLI: envelope out, deploy host instantiates without a translator",
ignore: !ready,
fn: async () => {
const out = `/tmp/deltic-translate-test-${crypto.randomUUID()}.plan.json`;
try {
const res = await runCli([imports.pathname, "-o", out]);
assertEq(res.code, 0, `cli failed: ${res.stderr}`);

// The deploy host: envelope + component bytes only.
const envelope = await Deno.readTextFile(out);
const componentBytes = await Deno.readFile(imports);
const logged: number[] = [];
const c = await instantiate(
artifactsFromEnvelope(envelope, componentBytes),
{
log: (x: number) => void logged.push(x),
"host:api/math": {
add: (a: number, b: number) => a + b,
greet: (who: string) => `hello ${who}`,
},
},
);
assertEq(await c.exports.run(2, 40), 42);
assertEq(logged, [42]);
} finally {
await Deno.remove(out).catch(() => {});
}
},
});

Deno.test({
name: "translate CLI: a mismatched envelope/component pair refuses at instantiation",
ignore: !ready || !(await exists(hello)),
fn: async () => {
const out = `/tmp/deltic-translate-test-${crypto.randomUUID()}.plan.json`;
try {
const res = await runCli([imports.pathname, "-o", out]);
assertEq(res.code, 0, `cli failed: ${res.stderr}`);
const envelope = await Deno.readTextFile(out);
// Wrong component bytes for this envelope: the embedded sha-256 (and
// length) must refuse the pair loudly.
const wrong = await Deno.readFile(hello);
let raised: unknown;
try {
await instantiate(artifactsFromEnvelope(envelope, wrong), {});
} catch (e) {
raised = e;
}
assertEq(raised !== undefined, true, "expected a pairing failure");
} finally {
await Deno.remove(out).catch(() => {});
}
},
});
Loading