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
132 changes: 78 additions & 54 deletions contracts/embedder-api.md

Large diffs are not rendered by default.

40 changes: 20 additions & 20 deletions crates/bindgen/src/codegen.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! TypeScript codegen implementing the C1 embedder-facing conventions
//! (`contracts/embedder-api.md`, normative — track C2-B). Emits **types
//! only**: `Imports`/`Exports` interfaces, value types per the mapping
//! table, resource classes, `WitError`-typed fallible signatures,
//! table, resource classes, `ComponentException`-typed fallible signatures,
//! `Stream<T>`/`Future<T>` references, plus the existing WORLD_DIGEST +
//! `verify()` digest handshake and a thin `bind()` cast. No runtime
//! behavior is emitted or assumed to exist yet (C2-A owns the runtime
Expand Down Expand Up @@ -65,7 +65,7 @@ pub fn generate(resolve: &Resolve, world: WorldId, expected_digest: &str) -> Res
src,
"import {{ verifyWorldDigest, type DigestMismatch }} from \"../../../src/digest/mod.ts\";"
)?;
// Stream<T>/Future<T>/ErrorContext/WitError/Trap plus the source-union
// Stream<T>/Future<T>/ErrorContext/ComponentException/Trap plus the source-union
// types used at parameter positions (StreamSource<T>/FutureSource<T>,
// contracts/embedder-api.md §"Streams and futures": "lowering accepts
// the natural JS producers") and `EmbedderInstance` (the `bind()` input
Expand All @@ -79,7 +79,7 @@ pub fn generate(resolve: &Resolve, world: WorldId, expected_digest: &str) -> Res
\x20 StreamSource,\n\
\x20 FutureSource,\n\
\x20 ErrorContext,\n\
\x20 WitError,\n\
\x20 ComponentException,\n\
\x20 Trap,\n\
\x20 EmbedderInstance,\n\
}} from \"../../../src/embedder/mod.ts\";\n"
Expand All @@ -89,7 +89,7 @@ pub fn generate(resolve: &Resolve, world: WorldId, expected_digest: &str) -> Res
// no fixture world raises a bare Trap type or uses error-context yet).
writeln!(
src,
"// deno-lint-ignore no-unused-vars\ntype _EnsureEmbedderTypesUsed = [Stream<unknown>, Future<unknown>, StreamSource<unknown>, FutureSource<unknown>, ErrorContext, WitError, Trap];\n"
"// deno-lint-ignore no-unused-vars\ntype _EnsureEmbedderTypesUsed = [Stream<unknown>, Future<unknown>, StreamSource<unknown>, FutureSource<unknown>, ErrorContext, ComponentException, Trap];\n"
)?;


Expand Down Expand Up @@ -527,12 +527,12 @@ fn param_list_skip_self(resolve: &Resolve, f: &Function) -> Result<String> {
/// - **Exports** are uniformly `Promise<T>` (contracts/embedder-api.md
/// §"Functions and async"). If the WIT result type is a top-level
/// `result<T, E>`, `T` is unwrapped (the `ok` payload; `void` if empty)
/// and a `@throws {WitError<E>}` doc line is attached — the err channel
/// and a `@throws {ComponentException<E>}` doc line is attached — the err channel
/// is a throw, never part of the resolved value (§"Error model").
/// - **Imports**: sync WIT funcs return `T` directly; async funcs return
/// `T | Promise<T>` (dispatch normative bullet #3). Fallible imports are
/// documented the same way (`@throws`), signaling via `throw new
/// WitError(payload)` per the host-import error-model paragraph.
/// ComponentException(payload)` per the host-import error-model paragraph.
///
/// Constructors have no explicit return type in TS (the class instance is
/// implicit); callers of this function skip constructors entirely.
Expand All @@ -555,7 +555,7 @@ fn func_return(resolve: &Resolve, f: &Function, is_export: bool) -> Result<(Stri
return Ok((format!("Future<{elem_ts}>"), None));
}
if let Some((ok, err)) = as_top_level_result(resolve, t) {
// Empty sides resolve `undefined` (`WitError.payload ===
// Empty sides resolve `undefined` (`ComponentException.payload ===
// undefined` on the err side) — C2 amendment,
// contracts/embedder-api.md value mapping table's
// function-result row.
Expand All @@ -567,7 +567,7 @@ fn func_return(resolve: &Resolve, f: &Function, is_export: bool) -> Result<(Stri
Some(t) => ts_value_type(resolve, t)?,
None => "undefined".to_string(),
};
let doc = format!("@throws {{WitError<{err_ts}>}}");
let doc = format!("@throws {{ComponentException<{err_ts}>}}");
let ret = if is_export {
format!("Promise<{ok_ts}>")
} else if is_async {
Expand Down Expand Up @@ -616,7 +616,7 @@ fn func_return(resolve: &Resolve, f: &Function, is_export: bool) -> Result<(Stri
/// `result<T, E>` used directly as a function's declared result type — the
/// "as a function result" row of the value mapping table, as opposed to a
/// `result` nested inside some other structural type (record/list/tuple/
/// variant payload), which stays the ordinary `{tag,val}` value shape.
/// variant payload), which stays the ordinary `{kind,value}` value shape.
fn as_top_level_result(resolve: &Resolve, ty: Type) -> Option<(Option<Type>, Option<Type>)> {
let Type::Id(id) = ty else { return None };
match &resolve.types[id].kind {
Expand Down Expand Up @@ -723,16 +723,16 @@ fn ts_typedef_value_type(resolve: &Resolve, id: TypeId) -> Result<String> {
// option uses the variant family instead. `ts_option_inner` renders
// the "some" payload, applying that boxing recursively.
TypeDefKind::Option(t) => format!("({} | undefined)", ts_option_inner(resolve, *t)?),
// result<T,E> nested as a value: `{tag,val}` family, `val` absent
// result<T,E> nested as a value: `{kind,value}` family, `value` absent
// for empty sides (same row as `variant`).
TypeDefKind::Result(r) => {
let ok_arm = match r.ok {
Some(t) => format!("{{ tag: \"ok\"; val: {} }}", ts_value_type(resolve, t)?),
None => "{ tag: \"ok\" }".to_string(),
Some(t) => format!("{{ kind: \"ok\"; value: {} }}", ts_value_type(resolve, t)?),
None => "{ kind: \"ok\" }".to_string(),
};
let err_arm = match r.err {
Some(t) => format!("{{ tag: \"err\"; val: {} }}", ts_value_type(resolve, t)?),
None => "{ tag: \"err\" }".to_string(),
Some(t) => format!("{{ kind: \"err\"; value: {} }}", ts_value_type(resolve, t)?),
None => "{ kind: \"err\" }".to_string(),
};
format!("({ok_arm} | {err_arm})")
}
Expand Down Expand Up @@ -760,14 +760,14 @@ fn ts_typedef_value_type(resolve: &Resolve, id: TypeId) -> Result<String> {

/// Render the "some" payload for an option, applying the nested-option
/// boxing rule recursively: if `t` is itself an `option<...>`, box it as
/// `{ tag: "some", val: <recurse> } | { tag: "none" }`; otherwise render it
/// `{ kind: "some", value: <recurse> } | { kind: "none" }`; otherwise render it
/// as a plain value type.
fn ts_option_inner(resolve: &Resolve, t: Type) -> Result<String> {
if let Type::Id(id) = t {
if let TypeDefKind::Option(inner) = &resolve.types[id].kind {
let boxed = ts_option_inner(resolve, *inner)?;
return Ok(format!(
"({{ tag: \"some\"; val: {boxed} }} | {{ tag: \"none\" }})"
"({{ kind: \"some\"; value: {boxed} }} | {{ kind: \"none\" }})"
));
}
}
Expand Down Expand Up @@ -903,7 +903,7 @@ fn emit_named_type(
TypeDefKind::Variant(v) => {
let Some(name) = &def.name else { return Ok(()) };
emitted.insert(id);
// `{ tag: "case" } | { tag: "case", val: T }` — `val` absent
// `{ kind: "case" } | { kind: "case", value: T }` — `value` absent
// (not `undefined`) for payloadless cases (value mapping table
// + the "why a discriminant property" rationale).
let arms: Result<Vec<String>> = v
Expand All @@ -912,11 +912,11 @@ fn emit_named_type(
.map(|c| {
Ok(match c.ty {
Some(t) => format!(
"{{ tag: {}; val: {} }}",
"{{ kind: {}; value: {} }}",
kebab_literal(&c.name),
ts_value_type(resolve, t)?
),
None => format!("{{ tag: {} }}", kebab_literal(&c.name)),
None => format!("{{ kind: {} }}", kebab_literal(&c.name)),
})
})
.collect();
Expand All @@ -931,7 +931,7 @@ fn emit_named_type(
let Some(name) = &def.name else { return Ok(()) };
emitted.insert(id);
// enum = string literal union of kebab-case case names (value
// mapping table) — data, not `{tag}` objects (unlike variant).
// mapping table) — data, not `{kind}` objects (unlike variant).
let arms: Vec<String> = e.cases.iter().map(|c| kebab_literal(&c.name)).collect();
writeln!(out, "export type {} =\n | {};\n", ts_ident(name), arms.join("\n | "))?;
}
Expand Down
16 changes: 8 additions & 8 deletions ct-runner/src/run-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
type ComponentArtifacts,
instantiate,
Trap,
WitError,
ComponentException,
} from "@deltic/runtime/embedder";
import { Context, testContextImportRecord } from "./context.ts";
import { analyzeImports, requireImportsResolved } from "./import-analysis.ts";
Expand Down Expand Up @@ -351,28 +351,28 @@ export async function runSuite(
}
} catch (e) {
const durationMs = Math.round(performance.now() - start);
if (e instanceof WitError) {
if (e instanceof ComponentException) {
const payload = e.payload as
| { tag: "failed"; val: string }
| { tag: "skipped"; val: string }
| { kind: "failed"; value: string }
| { kind: "skipped"; value: string }
| undefined;
if (payload?.tag === "failed") {
if (payload?.kind === "failed") {
counts.failed++;
event = {
case: name,
status: "fail",
provenance: "returned",
detail: payload.val,
detail: payload.value,
"duration-ms": durationMs,
"diagnostics-complete": true,
};
} else if (payload?.tag === "skipped") {
} else if (payload?.kind === "skipped") {
counts.skipped++;
event = {
case: name,
status: "skipped",
provenance: "returned",
detail: payload.val,
detail: payload.value,
"duration-ms": durationMs,
"diagnostics-complete": true,
};
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ Platform-neutral core (dependencies: `WebAssembly` JS API, `TextEncoder`/
Above the raw boundary sits the **embedder conventions layer**
(`runtime/src/embedder/`, governed by
[contracts/embedder-api.md](../contracts/embedder-api.md)): camelCase facades,
branded `WitError`s, resources as classes in both directions, `Stream`/
branded `ComponentException`s, resources as classes in both directions, `Stream`/
`Future` handles over web-native producers, and semver-canonical import
resolution matching the spec + wasmtime's `NameMap`.

Expand Down
6 changes: 3 additions & 3 deletions examples/kitchen-sink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ One world exercising the surfaces an embedder actually touches:
|---|---|---|---|
| enum / record / variant / flags | `types` interface | `describe`, `classify`, `scale`, `allowed` | §4 |
| outermost `option` → `undefined \| T` | `find` | | §5 |
| return-place `result` → resolve / throw `WitError` | `lookup` | | §5 |
| return-place `result` → resolve / throw `ComponentException` | `lookup` | | §5 |
| nested option/result as plain data + the boxing rule | `survey`, `maybe-maybe` | | §5 |
| host-implemented imports: sync, fallible, **suspending** | `notify` interface | `run-batch` | §2 |
| host-implemented resource (ctor / method / static / dispose) | `notify.channel` | `run-batch` | §3 |
Expand All @@ -31,11 +31,11 @@ What to notice:
(a continuation hop per call, illegal from `start` functions) — see
the §2c comment in [`host.ts`](host.ts).
- **Match errors on the brand, never the message.** `lookup`'s err side
arrives as a thrown `WitError` with `.payload`; any *unbranded* throw
arrives as a thrown `ComponentException` with `.payload`; any *unbranded* throw
from a host import is a host bug and traps the component.
- **The option rule is per-chain.** An option inside a `list` is still
the outermost of its own chain (`undefined | T`); boxing to
`{ tag: "some" | "none" }` happens only for option directly inside
`{ kind: "some" | "none" }` happens only for option directly inside
another option — `maybe-maybe` pins all three depths.
- **Resources are classes on both sides.** The host's `Channel` class is
handed over as-is (the runtime calls `[Symbol.dispose]` when the guest
Expand Down
2 changes: 1 addition & 1 deletion examples/kitchen-sink/guest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ impl Guest for Component {
notify::log(notify::Level::Info, "batch: start");

// Fallible import, both sides. The err side arrives as a plain
// Result::Err here — the host threw a branded WitError.
// Result::Err here — the host threw a branded ComponentException.
let id = notify::parse_id("42").map_err(|e| format!("parse-id(42): {e}"))?;
if notify::parse_id("not a number").is_ok() {
return Err("parse-id accepted garbage".into());
Expand Down
32 changes: 16 additions & 16 deletions examples/kitchen-sink/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// the contract ever disagree).
//
// §1 translate + instantiate, with a real imports record
// §2 host-implemented imports: sync, fallible (WitError), suspending
// §2 host-implemented imports: sync, fallible (ComponentException), suspending
// §3 a host-implemented resource class (@suspending method, static,
// dispose-on-guest-drop)
// §4 calling exports: enum/variant/record/flags spellings
Expand All @@ -20,7 +20,7 @@
import {
instantiate,
suspending,
WitError,
ComponentException,
} from "@deltic/runtime/embedder";
import { defaultTranslator } from "@deltic/translator";

Expand Down Expand Up @@ -88,12 +88,12 @@ const imports = {
},

// §2b — fallible import (result return-place): return the ok value;
// throw `new WitError(payload)` for the err side. Any OTHER throw is a
// throw `new ComponentException(payload)` for the err side. Any OTHER throw is a
// host bug and traps the component — the anti-footgun inversion.
parseId: (raw: string): number => {
const n = Number(raw);
if (!Number.isInteger(n) || n < 0) {
throw new WitError(`'${raw}' is not an id`);
throw new ComponentException(`'${raw}' is not an id`);
}
return n;
},
Expand Down Expand Up @@ -139,15 +139,15 @@ const api = component.exports["deltic:kitchen-sink/api"];

// --- §4: plainly-shaped values ----------------------------------------------

// variant → { tag } / { tag, val }; nested records are plain objects.
assertEq(await api.describe({ tag: "dot" }), "a dot", "describe dot");
// variant → { kind } / { kind, value }; nested records are plain objects.
assertEq(await api.describe({ kind: "dot" }), "a dot", "describe dot");
assertEq(
await api.describe({ tag: "circle", val: 3 }),
await api.describe({ kind: "circle", value: 3 }),
"a circle of radius 3",
"describe circle",
);
assertEq(
await api.describe({ tag: "rect", val: { x: 4, y: 5 } }),
await api.describe({ kind: "rect", value: { x: 4, y: 5 } }),
"a rectangle to (4, 5)",
"describe rect",
);
Expand All @@ -173,38 +173,38 @@ assertEq(await api.allowed({ exec: true }), false, "allowed exec-only");
assertEq(await api.find("origin"), { x: 0, y: 0 }, "find origin");
assertEq(await api.find("atlantis"), undefined, "find missing");

// RETURN-PLACE result: ok resolves; err arrives as a thrown WitError whose
// RETURN-PLACE result: ok resolves; err arrives as a thrown ComponentException whose
// `.payload` is the WIT err value. Match on the brand, never on message.
assertEq(await api.lookup("unit"), { x: 1, y: 1 }, "lookup ok");
try {
await api.lookup("atlantis");
throw new Error("lookup should have thrown");
} catch (e) {
if (!(e instanceof WitError)) throw e;
if (!(e instanceof ComponentException)) throw e;
assertEq(e.payload, "no point named 'atlantis'", "lookup err payload");
}

// NESTED option/result are plain data — but note WHICH rule applies where:
// the result-as-value is { tag: "ok" | "err", val }, while the option
// the result-as-value is { kind: "ok" | "err", value }, while the option
// wrapping it is still the outermost of ITS OWN chain (the list does not
// count), so the none slot is a genuine `undefined`, not a { tag: "none" }.
// count), so the none slot is a genuine `undefined`, not a { kind: "none" }.
assertEq(
await api.survey(),
[
undefined,
{ tag: "ok", val: { x: 2, y: 3 } },
{ tag: "err", val: "survey hole" },
{ kind: "ok", value: { x: 2, y: 3 } },
{ kind: "err", value: "survey hole" },
],
"survey nested shapes",
);

// Option-inside-option is the ONE place boxing appears, and it boxes
// exactly as deep as needed (the contract's worked example):
assertEq(await api.maybeMaybe(0), undefined, "maybe-maybe none");
assertEq(await api.maybeMaybe(1), { tag: "none" }, "maybe-maybe some(none)");
assertEq(await api.maybeMaybe(1), { kind: "none" }, "maybe-maybe some(none)");
assertEq(
await api.maybeMaybe(2),
{ tag: "some", val: 7 },
{ kind: "some", value: 7 },
"maybe-maybe some(some(7))",
);

Expand Down
18 changes: 9 additions & 9 deletions examples/kitchen-sink/wit/world.wit
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ interface types {
/// record → a plain JS object: { x: number, y: number }.
record point { x: s32, y: s32 }

/// variant → { tag } for payload-less cases, { tag, val } otherwise:
/// { tag: "dot" } | { tag: "circle", val: 3 } | { tag: "rect", val: {x,y} }
/// variant → { kind } for payload-less cases, { kind, value } otherwise:
/// { kind: "dot" } | { kind: "circle", value: 3 } | { kind: "rect", value: {x,y} }
variant shape {
dot,
circle(u32),
Expand All @@ -24,7 +24,7 @@ interface types {

/// Host-implemented imports: the embedder provides these in the imports
/// record. Covers a plain sync function, a fallible function (the branded
/// WitError throw), a suspending function (sync-typed, but the host parks
/// ComponentException throw), a suspending function (sync-typed, but the host parks
/// the guest's frame on a Promise), and a host-implemented resource with a
/// constructor, methods (one suspending), and a static.
interface notify {
Expand All @@ -33,7 +33,7 @@ interface notify {
/// Sync import; enum parameter arrives as a string.
log: func(lvl: level, msg: string);

/// Fallible import: the host implementation throws `new WitError(msg)`
/// Fallible import: the host implementation throws `new ComponentException(msg)`
/// for the err side — an unbranded throw would be a host bug and traps.
parse-id: func(raw: string) -> result<u32, string>;

Expand Down Expand Up @@ -75,23 +75,23 @@ interface api {
find: func(name: string) -> option<point>;

/// RETURN-PLACE result → the ok value resolves the Promise; the err
/// value arrives as a thrown `WitError` whose `.payload` is the string.
/// value arrives as a thrown `ComponentException` whose `.payload` is the string.
lookup: func(name: string) -> result<point, string>;

/// Option/result NESTED inside other types are plain data (never a
/// throw, never a wrapper-less undefined-vs-value pun):
/// - result-as-value → { tag: "ok" | "err", val }
/// - result-as-value → { kind: "ok" | "err", value }
/// - option is `undefined | T` at the OUTERMOST position of its own
/// chain — even inside this list — and boxes to
/// { tag: "some", val } | { tag: "none" } only when nested DIRECTLY
/// { kind: "some", value } | { kind: "none" } only when nested DIRECTLY
/// inside another option (see `maybe-maybe`).
survey: func() -> list<option<result<point, string>>>;

/// The option-boxing rule, exactly (contracts/embedder-api.md §"Value
/// mapping", the worked `option<option<u32>>` example):
/// none → undefined
/// some(none) → { tag: "none" }
/// some(some7) → { tag: "some", val: 7 }
/// some(none) → { kind: "none" }
/// some(some7) → { kind: "some", value: 7 }
maybe-maybe: func(depth: u32) -> option<option<u32>>;

/// Guest-implemented resource: the host constructs it (`new Counter(3)`
Expand Down
Loading
Loading