diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 5dc6fe8..be9a6ea 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -27,7 +27,17 @@ indistinguishable from end-of-stream by design; amendment A9 (2026-08-11) makes every cross-boundary brand a process-global registry symbol carried by the new dependency-free `@deltic/protocol` package — class identity is not part of the embedder API — and adds loud multi-copy diagnostics -(issue #83).** This document supersedes `descriptor-ir.md`'s interim +(issue #83); amendment A10 (2026-08-11) renames `WitError` to +`ComponentException` (`isWitError` → `isComponentException`) and the +variant-family discriminant properties `{ tag, val }` to `{ kind, value }`, +aligning with the draft web-embedding direction +(WebAssembly/component-model PR #686's canonical variant dictionary and +exception naming) while it is still cheap — semantics unchanged +(payloadless cases still omit `value`); the **wire vocabulary is +untouched**: the brand key stays `deltic.witError/1` (an opaque constant, +CEWD-style, so pre-A10 copies and hand-rolled brands keep interoperating) +and plan-format op discriminants (a different contract) keep `tag`.** +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 @@ -41,8 +51,11 @@ and docs/consumers.md. ## Principles 1. **Fresh design; jco compatibility is a non-goal** (docs/architecture.md §2). Where jco's - choice is also the right choice (camelCase, `{tag, val}` variants), we - converge by merit — deliberately, so consumer ports stay small. + choice is also the right choice (camelCase, enum strings), we + converge by merit — deliberately, so consumer ports stay small. Where + the emerging standard direction points elsewhere, we align upstream + instead (A10: `{kind, value}` variants per the PR #686 draft, a + deliberate divergence from jco's `{tag, val}`). 2. **Footguns are design defects.** Every convention here is judged against the defensive code the polymorph modules had to write under jco (bare-payload error throws, convention-only stream contracts, @@ -66,7 +79,7 @@ and docs/consumers.md. |---|---| | function, method, static, record field, flag name, function param (docs only — calls are positional) | camelCase (`get-resolution` → `getResolution`) | | resource name | PascalCase class (`tcp-socket` → `TcpSocket`) | -| enum value, variant/result tag | **kebab-case verbatim** as string literals (`connection-refused`) — they are data, not identifiers | +| enum value, variant/result case name (the `kind` value) | **kebab-case verbatim** as string literals (`connection-refused`) — they are data, not identifiers | | interface key in the imports/exports record | fully-qualified WIT id **verbatim, version included**: `wasi:clocks/monotonic-clock@0.3.0` | | world-level (bare) imports/exports | camelCase name at the record's top level | @@ -147,10 +160,10 @@ without interference). | `tuple` | `[A, B, …]` | real TS tuple | | `record` | plain object, camelCase fields | fields of option type are optional properties: lift emits **absent** (not `undefined`-valued) for none; lower accepts either spelling (C2 amendment) | | `enum` | string literal union of kebab-case case names | `"offer" \| "answer" \| …` | -| `variant` | `{ tag: "case" }` \| `{ tag: "case", val: T }` | `val` **absent** (not `undefined`) for payloadless cases | +| `variant` | `{ kind: "case" }` \| `{ kind: "case", value: T }` | `value` **absent** (not `undefined`) for payloadless cases | | `option` | `T \| undefined`; **nested** options box | see rule below | -| `result` **as a value** (nested in other types, or in parameter position) | `{ tag: "ok", val: T } \| { tag: "err", val: E }` | `val` absent for empty sides — same family as `variant` | -| `result` **as a function result** (return position only) | return `T` / throw `WitError` | empty sides: resolves `undefined` / `WitError.payload === undefined`; see "Error model" | +| `result` **as a value** (nested in other types, or in parameter position) | `{ kind: "ok", value: T } \| { kind: "err", value: E }` | `value` absent for empty sides — same family as `variant` | +| `result` **as a function result** (return position only) | return `T` / throw `ComponentException` | empty sides: resolves `undefined` / `ComponentException.payload === undefined`; see "Error model" | | `map` | its despecialization `list>` → `[K, V][]` | C2 amendment | | `flags` | object of camelCase booleans | lift: every flag present; lower: absent = `false` | | `own` / `borrow` | the resource class instance | see "Resources" | @@ -158,19 +171,20 @@ without interference). **Terminology note.** The spec calls variant alternatives **cases** (Explainer, definitions.py `case_label`); prose here follows that. The -discriminant *property* is nonetheless named `tag`, a deliberate -divergence: "tagged union" is the JS/TS-side term of art, `{ tag, val }` -is the established convention in this exact niche (jco, and the consumer -host modules already written against it), and `case` is a JS reserved -word — legal as a property, but `v.case` reads like syntax. The value of -`tag` is always the case name, kebab-case verbatim. +discriminant *property* is named `kind` with payload `value` (A10), +matching the canonical variant dictionary in the draft web embedding +(WebAssembly/component-model PR #686) — if that shape holds, native +support and this API agree for free. v0.2 named them `{ tag, val }` after +jco's convention; A10 supersedes that argument. `case` itself stays out: +it is a JS reserved word — legal as a property, but `v.case` reads like +syntax. The value of `kind` is always the case name, kebab-case verbatim. **Why a discriminant property rather than `{ [case]: value }`** (the single-key form the internal definitions.py-shaped boundary uses): -(1) exhaustiveness — `switch (v.tag)` + `assertNever` is compiler-checked +(1) exhaustiveness — `switch (v.kind)` + `assertNever` is compiler-checked case coverage; `in`-chains are not switchable and lose it; (2) payloadless -cases get one uniform shape (`val` absent) instead of a null/undefined -sentinel adjacent to `option` payloads; (3) generic code reads `v.tag` +cases get one uniform shape (`value` absent) instead of a null/undefined +sentinel adjacent to `option` payloads; (3) generic code reads `v.kind` typed and allocation-free where single-key needs an untypeable `Object.keys(v)[0]` cast, and per-case key shapes make every variant-touching site polymorphic for the engine; (4) case names stay @@ -181,15 +195,15 @@ as an optional nicety; the value shape is unaffected. **Option rule.** The *outermost* option in a chain maps to `T | undefined`; every option nested **directly inside another option** -uses the variant family: `{ tag: "some", val: … } | { tag: "none" }`. +uses the variant family: `{ kind: "some", value: … } | { kind: "none" }`. Only option maps to `undefined`, so this is the only ambiguity and the boxing is exactly as deep as needed. Example (`option>`, the values-fixture Some(None) edge): ```ts -undefined // none -{ tag: "none" } // some(none) -{ tag: "some", val: 7 } // some(some(7)) +undefined // none +{ kind: "none" } // some(none) +{ kind: "some", value: 7 } // some(some(7)) ``` **Worked example** (C0 finding #7 asked for exactly this shape) — @@ -197,15 +211,15 @@ undefined // none - as a **function result**: the call resolves to `[Counter, Counter]` (a real two-element tuple of class instances, ownership transferred to - the caller), or rejects/throws `WitError` whose `.payload` is the - `error` variant value, e.g. `{ tag: "timed-out" }`. + the caller), or rejects/throws `ComponentException` whose `.payload` is + the `error` variant value, e.g. `{ kind: "timed-out" }`. - **nested as a value** (say inside `list<…>`): - `{ tag: "ok", val: [Counter, Counter] } | { tag: "err", val: Error… }`. + `{ kind: "ok", value: [Counter, Counter] } | { kind: "err", value: Error… }`. ## Error model ```ts -class WitError extends Error { +class ComponentException extends Error { readonly payload: E; // the WIT err value, shaped per the table constructor(payload: E, message?: string); } @@ -217,17 +231,21 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins ``` - **Guest export with `result`**: the call resolves to `T` on ok and - rejects (throws, for sync paths) with `WitError` on err. `Trap` - rejections are always distinguishable by class. + rejects (throws, for sync paths) with `ComponentException` on err. + `Trap` rejections are always distinguishable by class. - **Host import with `result`**: the host function returns `T` for - ok and `throw`s `new WitError(payload)` for err — the ergonomic - throw-for-error pattern, **branded**. + ok and `throw`s `new ComponentException(payload)` for err — the ergonomic + throw-for-error pattern, **branded**. (The name matches PR #686's draft + `ComponentException`; ours does not derive from `DOMException` — absent + in the bare `sm`/`jsc` shell lanes — and carries the structured + `payload` instead. Revisit inheritance if the draft's shape stabilizes.) - **An unbranded throw from a host import is a host bug and becomes a trap** (with a message naming the import), never a guest-visible err — the inversion of jco's convention, where any stray `TypeError` was fed to the lift and the polymorph modules had to wrap every platform call defensively (`platformCall` in webcrypto.js). Here the defensive wrapper - is unnecessary by construction: only `WitError` crosses as an err value. + is unnecessary by construction: only `ComponentException` crosses as an + err value. - Host code must never catch-and-swallow `Trap` (re-throw if observed); traps poison the instance per docs/architecture.md §7 regardless. - **`Trap.message` is diagnostic text, not API.** Match on the `Trap` @@ -242,13 +260,13 @@ class PeerTrappedError extends Error { // A7: a stream/future op whose peer ins errors) have stable wording chosen by this project, but the same rule applies: text is for humans and logs. - Results nested inside values never throw anywhere — they are plain - `{ tag, val }` data (table above). + `{ kind, value }` data (table above). - **Recognition is by brand, not class** (amendment A9): every class above carries a process-global brand symbol, and the runtime's checks read the brand. Same-copy `instanceof` still works and stays the documented spelling in single-copy graphs; `@deltic/protocol` exports predicates - (`isWitError`, `isTrap`, `isPeerTrappedError`, …) as the multi-copy-robust - form. See §"Module identity and @deltic/protocol". + (`isComponentException`, `isTrap`, `isPeerTrappedError`, …) as the + multi-copy-robust form. See §"Module identity and @deltic/protocol". ## Functions and async @@ -513,7 +531,7 @@ contract entirely. **The protocol package.** `@deltic/protocol` is a dependency-free workspace package carrying the embedder contract's *vocabulary*: the -brand symbols, the canonical error classes (`WitError`, `Trap`, +brand symbols, the canonical error classes (`ComponentException`, `Trap`, `DroppedError`, `PeerTrappedError`, `InvalidHandleError`, `StreamProducerError`), `suspending()`/`isSuspending`, the recognition predicates, the copy registry, and `PROTOCOL_GENERATION`. @@ -532,7 +550,7 @@ equivalent of a semver major: | brand key | carried by | marks | |---|---|---| -| `deltic.witError/1` | `WitError.prototype` | err-result values | +| `deltic.witError/1` | `ComponentException.prototype` | err-result values (key keeps the pre-A10 name: opaque wire constant, CEWD-style — renaming it would break pre-A10 copies and hand-rolled brands) | | `deltic.trap/1` | `Trap.prototype` | component-fatal errors | | `deltic.dropped/1` | `DroppedError.prototype` | dropped-future rejections | | `deltic.peerTrapped/1` | `PeerTrappedError.prototype` | peer-fault rejections (A7) | @@ -550,13 +568,14 @@ equivalent of a semver major: **Brands are contract markers, not a security boundary.** A hand-rolled object carrying the right brand is a legal value: an Error with `[Symbol.for("deltic.witError/1")]: true` and a `payload` property IS a -WitError to every copy; a function with `[Symbol.for("deltic.suspending/1")]: -true` IS suspending-marked. This is what makes zero-import host modules -possible. The canonical classes are conveniences, not gatekeepers. +ComponentException to every copy; a function with +`[Symbol.for("deltic.suspending/1")]: true` IS suspending-marked. This is +what makes zero-import host modules possible. The canonical classes are +conveniences, not gatekeepers. **Stateless vs stateful.** For the error classes and the suspending mark, -brand agreement is the whole story — a copy-B `WitError` crossing a -copy-A boundary is fully honored. Stateful values (stream/future handles, +brand agreement is the whole story — a copy-B `ComponentException` +crossing a copy-A boundary is fully honored. Stateful values (stream/future handles, resource wrappers, error-contexts) are different: their machinery lives in the copy that minted them, so cross-copy use is impossible in principle. For those, the brand converts "misclassified" into @@ -584,7 +603,7 @@ not carry `@deltic/*` mappings in any config consumers resolve through ## Bindgen obligations (summary of what the above requires) Per world: `Imports`/`Exports` types; resource classes (both directions); -`WitError` payload types per fallible function; value types per the +`ComponentException` payload types per fallible function; value types per the mapping table; the mangled-name assembly (`[method]r.f` ↔ `class` methods) in both directions; stream/future adapters incl. pumping; the digest handshake. The generated layer is an adapter over the runtime's raw @@ -644,7 +663,7 @@ thin class over host-supplied readiness: class Pollable { ready(): boolean; block(): void /* tier (c) only */ } // wasi:io/poll@0.2.x poll: (in: Pollable[]) => Uint32Array indices — Promise.race under the hood class InputStream { - read(len: bigint): Uint8Array; // throws WitError + read(len: bigint): Uint8Array; // throws ComponentException blockingRead(len: bigint): Uint8Array; // tier (b)/(c) subscribe(): Pollable; [Symbol.dispose](): void; @@ -656,7 +675,7 @@ leaves): resources with async methods and stream-shaped I/O map directly: ```ts class TcpSocket { - static create(af: "ipv4" | "ipv6"): TcpSocket; // result → throw WitError + static create(af: "ipv4" | "ipv6"): TcpSocket; // result → throw ComponentException bind(addr: IpSocketAddress): void; connect(addr: IpSocketAddress): Promise; // async func send(data: Stream): Promise; // called once; stream drives the connection @@ -671,19 +690,22 @@ with no impedance: // export handle: async func(request: request) -> result exports["wasi:http/handler@0.3.0"].handle(req: Request): Promise // Request/Response are resource classes; .body(): Stream (Uint8Array -// chunks); trailers as Future; err → WitError. +// chunks); trailers as Future; err → ComponentException. ``` **polymorph:webrtc-datachannels `data-channel`** (the consumer reference): ```ts class DataChannel { - send(msg: Message): Promise; // throws WitError + send(msg: Message): Promise; // throws ComponentException receive(): Promise; receiveViaStream(): Stream; // record { kind, length, data: Stream } [Symbol.dispose](): void; } -// Message = { tag: "binary", val: Uint8Array } | { tag: "string", val: string } +// Message = { kind: "binary", value: Uint8Array } | { kind: "string", value: string } +// (StreamMessage's own record field named `kind` is untouched by the A10 +// discriminant naming — record fields are plain properties, and a variant +// never merges its payload's fields into the discriminant object.) ``` Verdict of the examination: nothing in p2/p3 requires a convention not @@ -692,13 +714,15 @@ idiom, addressed by the three-tier strategy and made visible in types. ## Migration notes for the polymorph modules (jco → this API) -Small by design: camelCase, `{tag, val}` variants, enum strings, flags -objects, and resource-classes-per-interface all carry over unchanged. The -real deltas: (1) err results are `throw new WitError(payload)` instead of -throwing the bare payload — and the defensive `platformCall`-style -wrappers can be deleted rather than ported; (2) jco `Stream` objects -(`read({count})`) become `Stream`/`ReadableStream`; (3) nested results -read `{ tag: "ok" | "err", val }`; (4) transpile-time flags +Small by design: camelCase, enum strings, flags objects, and +resource-classes-per-interface all carry over unchanged. The +real deltas: (1) err results are `throw new ComponentException(payload)` +instead of throwing the bare payload — and the defensive +`platformCall`-style wrappers can be deleted rather than ported; (2) jco +`Stream` objects (`read({count})`) become `Stream`/`ReadableStream`; +(3) variant-family discriminants are `{ kind, value }`, not jco's +`{ tag, val }` (A10; mechanical rename), and nested results read +`{ kind: "ok" | "err", value }`; (4) transpile-time flags (`--async-exports`/`--async-imports`, `check-flags.mjs`) have no equivalent — asyncness comes from the binary; (5) `--map` wildcards become the module-mapping helper, with version handling per "Version @@ -720,8 +744,8 @@ canonicalization" (semver-track resolution, matching wasmtime's linker pumping with auto-close on end/DROPPED, `cancelRead`/`cancelWrite`, `DroppedError`, double-wrap and cross-store asserts (R-fix review notes 1–4). -5. `WitError`/`Trap` branding at every host-import boundary; unbranded - throw → trap naming the import. +5. `ComponentException`/`Trap` branding at every host-import boundary; + unbranded throw → trap naming the import. 6. Bindgen: `Imports`/`Exports` world types, resource classes both directions, mangled-key assembly, value types per the table. 7. WASI shim package (separate deliverable) implementing the p2 baseline diff --git a/crates/bindgen/src/codegen.rs b/crates/bindgen/src/codegen.rs index 9b3345e..571b6f6 100644 --- a/crates/bindgen/src/codegen.rs +++ b/crates/bindgen/src/codegen.rs @@ -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`/`Future` 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 @@ -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/Future/ErrorContext/WitError/Trap plus the source-union + // Stream/Future/ErrorContext/ComponentException/Trap plus the source-union // types used at parameter positions (StreamSource/FutureSource, // contracts/embedder-api.md §"Streams and futures": "lowering accepts // the natural JS producers") and `EmbedderInstance` (the `bind()` input @@ -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" @@ -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, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap];\n" + "// deno-lint-ignore no-unused-vars\ntype _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap];\n" )?; @@ -527,12 +527,12 @@ fn param_list_skip_self(resolve: &Resolve, f: &Function) -> Result { /// - **Exports** are uniformly `Promise` (contracts/embedder-api.md /// §"Functions and async"). If the WIT result type is a top-level /// `result`, `T` is unwrapped (the `ok` payload; `void` if empty) -/// and a `@throws {WitError}` doc line is attached — the err channel +/// and a `@throws {ComponentException}` 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` (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. @@ -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. @@ -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 { @@ -616,7 +616,7 @@ fn func_return(resolve: &Resolve, f: &Function, is_export: bool) -> Result<(Stri /// `result` 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, Option)> { let Type::Id(id) = ty else { return None }; match &resolve.types[id].kind { @@ -723,16 +723,16 @@ fn ts_typedef_value_type(resolve: &Resolve, id: TypeId) -> Result { // 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 nested as a value: `{tag,val}` family, `val` absent + // result 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})") } @@ -760,14 +760,14 @@ fn ts_typedef_value_type(resolve: &Resolve, id: TypeId) -> Result { /// 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: } | { tag: "none" }`; otherwise render it +/// `{ kind: "some", value: } | { kind: "none" }`; otherwise render it /// as a plain value type. fn ts_option_inner(resolve: &Resolve, t: Type) -> Result { 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\" }})" )); } } @@ -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> = v @@ -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(); @@ -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 = e.cases.iter().map(|c| kebab_literal(&c.name)).collect(); writeln!(out, "export type {} =\n | {};\n", ts_ident(name), arms.join("\n | "))?; } diff --git a/ct-runner/src/run-suite.ts b/ct-runner/src/run-suite.ts index 0cffbfc..17dc0ca 100644 --- a/ct-runner/src/run-suite.ts +++ b/ct-runner/src/run-suite.ts @@ -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"; @@ -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, }; diff --git a/docs/architecture.md b/docs/architecture.md index 5cb2441..e3f5f70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`. diff --git a/examples/kitchen-sink/README.md b/examples/kitchen-sink/README.md index b30dcd3..a41390a 100644 --- a/examples/kitchen-sink/README.md +++ b/examples/kitchen-sink/README.md @@ -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 | @@ -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 diff --git a/examples/kitchen-sink/guest/src/lib.rs b/examples/kitchen-sink/guest/src/lib.rs index 543054d..2b51290 100644 --- a/examples/kitchen-sink/guest/src/lib.rs +++ b/examples/kitchen-sink/guest/src/lib.rs @@ -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()); diff --git a/examples/kitchen-sink/host.ts b/examples/kitchen-sink/host.ts index af28663..e98c79e 100644 --- a/examples/kitchen-sink/host.ts +++ b/examples/kitchen-sink/host.ts @@ -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 @@ -20,7 +20,7 @@ import { instantiate, suspending, - WitError, + ComponentException, } from "@deltic/runtime/embedder"; import { defaultTranslator } from "@deltic/translator"; @@ -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; }, @@ -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", ); @@ -173,27 +173,27 @@ 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", ); @@ -201,10 +201,10 @@ assertEq( // 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))", ); diff --git a/examples/kitchen-sink/wit/world.wit b/examples/kitchen-sink/wit/world.wit index 71ef948..902cbbf 100644 --- a/examples/kitchen-sink/wit/world.wit +++ b/examples/kitchen-sink/wit/world.wit @@ -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), @@ -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 { @@ -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; @@ -75,23 +75,23 @@ interface api { find: func(name: string) -> option; /// 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; /// 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>>; /// The option-boxing rule, exactly (contracts/embedder-api.md §"Value /// mapping", the worked `option>` 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>; /// Guest-implemented resource: the host constructs it (`new Counter(3)` diff --git a/ports/webcrypto/src/errors.ts b/ports/webcrypto/src/errors.ts index 3b24caa..7b94862 100644 --- a/ports/webcrypto/src/errors.ts +++ b/ports/webcrypto/src/errors.ts @@ -2,7 +2,7 @@ // // Governing docs: // - contracts/embedder-api.md §"Error model" — host imports report a WIT -// `result<_, error>` err case by throwing `new WitError(payload)`; an +// `result<_, error>` err case by throwing `new ComponentException(payload)`; an // UNBRANDED throw becomes a host-fatal trap. That is a deliberate // inversion of jco's convention (any stray `TypeError` was fed to the // lift), which is why the polymorph reference wraps every platform call @@ -14,46 +14,46 @@ // // `error` is a WIT variant; per the value-mapping table // (contracts/embedder-api.md §"Value mapping", `variant` row) its payload -// shape is `{ tag, val? }` with `val` absent for payloadless cases. +// shape is `{ kind, value? }` with `value` absent for payloadless cases. -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; /** The `types.error` payload shape (the value-mapping table's variant row). */ export type WcErrorPayload = - | { tag: "invalid-key"; val: string } - | { tag: "invalid-nonce"; val: string } - | { tag: "authentication-failed" } - | { tag: "not-extractable" } - | { tag: "unsupported"; val: string } - | { tag: "not-permitted"; val: string } - | { tag: "other"; val: string } - | { tag: "extension"; val: { origin: string; name: string; message: string } }; + | { kind: "invalid-key"; value: string } + | { kind: "invalid-nonce"; value: string } + | { kind: "authentication-failed" } + | { kind: "not-extractable" } + | { kind: "unsupported"; value: string } + | { kind: "not-permitted"; value: string } + | { kind: "other"; value: string } + | { kind: "extension"; value: { origin: string; name: string; message: string } }; /** Throw the branded `result<_, error>` err value for a WIT-declared case. */ export function witError(payload: WcErrorPayload): never { - throw new WitError(payload); + throw new ComponentException(payload); } export function errInvalidKey(detail: string): never { - return witError({ tag: "invalid-key", val: detail }); + return witError({ kind: "invalid-key", value: detail }); } export function errInvalidNonce(detail: string): never { - return witError({ tag: "invalid-nonce", val: detail }); + return witError({ kind: "invalid-nonce", value: detail }); } export function errAuthenticationFailed(): never { - return witError({ tag: "authentication-failed" }); + return witError({ kind: "authentication-failed" }); } export function errNotExtractable(): never { - return witError({ tag: "not-extractable" }); + return witError({ kind: "not-extractable" }); } export function errUnsupported(detail: string): never { - return witError({ tag: "unsupported", val: detail }); + return witError({ kind: "unsupported", value: detail }); } export function errNotPermitted(detail: string): never { - return witError({ tag: "not-permitted", val: detail }); + return witError({ kind: "not-permitted", value: detail }); } export function errOther(detail: string): never { - return witError({ tag: "other", val: detail }); + return witError({ kind: "other", value: detail }); } /** The refusal an operation renders on a usage-denied key (reference parity: js/jco/webcrypto.js:162-164). */ @@ -75,8 +75,8 @@ function asPlatformError(err: unknown): { name: string | undefined; detail: stri * js/jco/webcrypto.js:251-262). `NotSupportedError` is the WIT's * "well-formed request this implementation does not serve" * (`error.unsupported`); everything else platform-thrown is operational - * (`error.other`). Anything already a `WitError` passes through unchanged. - * An exception that is neither a `WitError` nor DOMException-shaped is a + * (`error.other`). Anything already a `ComponentException` passes through unchanged. + * An exception that is neither a `ComponentException` nor DOMException-shaped is a * host bug, not a taxonomy case: it is rethrown as-is and becomes a trap * per contracts/embedder-api.md's error model, not smuggled into `other`. */ @@ -84,7 +84,7 @@ export async function platformCall(what: string, run: () => Promise): Prom try { return await run(); } catch (err) { - if (err instanceof WitError) throw err; + if (err instanceof ComponentException) throw err; const { name, detail } = asPlatformError(err); if (err instanceof DOMException) { if (name === "NotSupportedError") { diff --git a/ports/webcrypto/src/platform.ts b/ports/webcrypto/src/platform.ts index 1487c79..f8167cd 100644 --- a/ports/webcrypto/src/platform.ts +++ b/ports/webcrypto/src/platform.ts @@ -10,7 +10,7 @@ // authority for which verdict each is. import { errInvalidKey, errNotExtractable, errUnsupported, platformCall } from "./errors.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; import { asBufferSource } from "./util.ts"; const subtle = globalThis.crypto.subtle; @@ -64,7 +64,7 @@ export async function importPlatformKey( try { return await subtle.importKey(format, asBufferSource(bytes), algorithm, extractable, usages); } catch (err) { - if (err instanceof WitError) throw err; + if (err instanceof ComponentException) throw err; invalidKey(err, what); } } @@ -81,7 +81,7 @@ export async function importPlatformKeyJwk( try { return await subtle.importKey("jwk", jwk as JsonWebKey, algorithm, extractable, usages); } catch (err) { - if (err instanceof WitError) throw err; + if (err instanceof ComponentException) throw err; invalidKey(err, what); } } @@ -206,7 +206,7 @@ export async function redactingInvalidKey(what: string, run: () => Promise try { return await run(); } catch (err) { - if (err instanceof WitError && (err.payload as { tag?: string })?.tag === "invalid-key") { + if (err instanceof ComponentException && (err.payload as { kind?: string })?.kind === "invalid-key") { errInvalidKey(`invalid ${what}`); } throw err; diff --git a/ports/webcrypto/src/publicEncryption.ts b/ports/webcrypto/src/publicEncryption.ts index 98e5849..9bf3ee0 100644 --- a/ports/webcrypto/src/publicEncryption.ts +++ b/ports/webcrypto/src/publicEncryption.ts @@ -70,8 +70,8 @@ function oaepAlgorithm(entry: { hash: string; digestBytes: number }, modulusLeng /** The named plaintext-bound condition: the signal to switch to hybrid wrapping (reference: webcrypto.js:5461). */ function errMessageTooLong(what: string, length: number, algorithm: OaepAlgorithm): never { witError({ - tag: "extension", - val: { + kind: "extension", + value: { origin: "polymorph:webcrypto", name: "message-too-long", message: `${what} is ${length} bytes; this key's RSA-OAEP bound is ${algorithm.plaintextBound}`, diff --git a/ports/webcrypto/tests/aead_test.ts b/ports/webcrypto/tests/aead_test.ts index 9bd96d9..256c6b3 100644 --- a/ports/webcrypto/tests/aead_test.ts +++ b/ports/webcrypto/tests/aead_test.ts @@ -6,7 +6,7 @@ import { assertEq, assertRejects } from "./asserts.ts"; import { AeadKeyOptions, aesGcm } from "../src/mod.ts"; import { arrayStream } from "./testStream.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; @@ -62,8 +62,8 @@ Deno.test("aes-gcm: open with a tampered ciphertext fails error.authentication-f sealed[0] ^= 0xff; const err = await assertRejects( () => key.open(nonce, new Uint8Array(0), undefined, arrayStream(sealed)), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "authentication-failed"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "authentication-failed"); }); Deno.test("aes-gcm: seal on an open-only key fails error.not-permitted", async () => { @@ -72,21 +72,21 @@ Deno.test("aes-gcm: seal on an open-only key fails error.not-permitted", async ( const key = await aesGcm.generateKey("aes256", opts); const err = await assertRejects( () => key.seal(new Uint8Array(12), new Uint8Array(0), undefined, arrayStream(new Uint8Array(4))), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "not-permitted"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "not-permitted"); }); Deno.test("aes-gcm: seal with an out-of-window nonce fails error.invalid-nonce", async () => { const key = await aesGcm.generateKey("aes256", fullOptions()); const err = await assertRejects( () => key.seal(new Uint8Array(4), new Uint8Array(0), undefined, arrayStream(new Uint8Array(4))), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "invalid-nonce"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "invalid-nonce"); }); Deno.test("aes-gcm: aes192 is declined with error.unsupported (WIT portability ruling, not a Deno-specific gap)", async () => { const err = await assertRejects( () => aesGcm.generateKey("aes192", fullOptions()), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "unsupported"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "unsupported"); }); diff --git a/ports/webcrypto/tests/digest_test.ts b/ports/webcrypto/tests/digest_test.ts index ffbf8a3..a169e8e 100644 --- a/ports/webcrypto/tests/digest_test.ts +++ b/ports/webcrypto/tests/digest_test.ts @@ -8,7 +8,7 @@ import { assertEq, assertRejects, assertThrows } from "./asserts.ts"; import { sha2 } from "../src/mod.ts"; import { arrayStream } from "./testStream.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; function hex(bytes: Uint8Array): string { return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); @@ -34,8 +34,8 @@ Deno.test("sha2: makeDigest(sha384/sha512) compute over empty stream", async () Deno.test("sha2: sha224/sha512-224/sha512-256 decline with error.unsupported (WIT-mandated, not a Deno gap)", () => { for (const variant of ["sha224", "sha512-224", "sha512-256"]) { - const err = assertThrows(() => sha2.makeDigest(variant)) as WitError; - assertEq((err.payload as { tag: string }).tag, "unsupported"); + const err = assertThrows(() => sha2.makeDigest(variant)) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "unsupported"); } }); diff --git a/ports/webcrypto/tests/families_test.ts b/ports/webcrypto/tests/families_test.ts index 2231dfa..ebef83a 100644 --- a/ports/webcrypto/tests/families_test.ts +++ b/ports/webcrypto/tests/families_test.ts @@ -32,7 +32,7 @@ import { AgreementKeyOptions, } from "../src/mod.ts"; import { arrayStream } from "./testStream.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; @@ -59,8 +59,8 @@ function tc(doc: any, tcId: number, group = 0): any { if (found === undefined) throw new Error(`tcId ${tcId} not in group ${group}`); return found; } -function tag(err: unknown): string { - return ((err as WitError).payload as { tag: string }).tag; +function errKind(err: unknown): string { + return ((err as ComponentException).payload as { kind: string }).kind; } function cipherOptions(): CipherKeyOptions { const o = new CipherKeyOptions(); @@ -91,7 +91,7 @@ Deno.test("aes-cbc: a tampered ciphertext fails the WIT's uniform error.other (n const ct = hexToBytes(t.ct); ct[ct.length - 1] ^= 0xff; // corrupt the final block: bad padding on decrypt const err = await assertRejects(() => key.decrypt(hexToBytes(t.iv), undefined, arrayStream(ct))); - assertEq(tag(err), "other"); + assertEq(errKind(err), "other"); }); Deno.test("aes-ctr: a counter length is required, and AES-CBC refuses one (error.invalid-nonce both ways)", async () => { @@ -99,10 +99,10 @@ Deno.test("aes-ctr: a counter length is required, and AES-CBC refuses one (error const missing = await assertRejects(() => ctr.encrypt(new Uint8Array(16), undefined, arrayStream(new Uint8Array(4))) ); - assertEq(tag(missing), "invalid-nonce"); + assertEq(errKind(missing), "invalid-nonce"); const cbc = await aesCbc.generateKey("aes256", cipherOptions()); const extra = await assertRejects(() => cbc.encrypt(new Uint8Array(16), 64, arrayStream(new Uint8Array(4)))); - assertEq(tag(extra), "invalid-nonce"); + assertEq(errKind(extra), "invalid-nonce"); }); Deno.test("aes-ctr: encrypt/decrypt round-trip at a 128-bit counter", async () => { @@ -146,7 +146,7 @@ Deno.test("aes-kw: a tampered wrapped blob fails error.authentication-failed (in const wrapped = hexToBytes(t.ct); wrapped[0] ^= 0xff; const err = await assertRejects(() => key.unwrap(wrapped)); - assertEq(tag(err), "authentication-failed"); + assertEq(errKind(err), "authentication-failed"); }); Deno.test("pbkdf2-sha2: KAT against pbkdf2_hmacsha256_test.json tcId 1 (RFC 7914)", async () => { @@ -166,7 +166,7 @@ Deno.test("pbkdf2-sha2: a zero iteration count fails error.other at prepare, bef o.canDeriveBits(true); const password = await pbkdf2.importPassword(new Uint8Array([1, 2, 3]), o); const err = await assertRejects(() => pbkdf2Sha2.prepare("sha256", password, new Uint8Array(8), 0)); - assertEq(tag(err), "other"); + assertEq(errKind(err), "other"); }); Deno.test("ecdh: KAT against ecdh_secp256r1_webcrypto_test.json tcId 1 (agreed secret matches the vector)", async () => { @@ -188,7 +188,7 @@ Deno.test("ecdh: an off-curve peer point is refused (error.invalid-key; ecdh_sec const t = tc(doc, 332); assertEq(t.result, "invalid"); const err = await assertRejects(() => ecdh.importPublicKeyRaw("p256", hexToBytes(t.public))); - assertEq(tag(err), "invalid-key"); + assertEq(errKind(err), "invalid-key"); }); Deno.test("ecdsa-verify: KAT against ecdsa_secp256r1_sha256_p1363_test.json tcId 2 (valid P1363 signature)", async () => { @@ -208,7 +208,7 @@ Deno.test("ecdsa-verify: an upstream-invalid signature fails error.authenticatio const t = g.tests.find((x: { result: string }) => x.result === "invalid"); const key = await ecdsaVerify.importVerifyingKeyJwk("p256-sha256", JSON.stringify(g.publicKeyJwk)); const err = await assertRejects(() => key.verify(arrayStream(hexToBytes(t.msg)), hexToBytes(t.sig))); - assertEq(tag(err), "authentication-failed"); + assertEq(errKind(err), "authentication-failed"); }); Deno.test("ecdsa-sign: generate -> sign -> verify round-trip (P-384/SHA-384)", async () => { @@ -230,7 +230,7 @@ Deno.test("rsassa-pkcs1-v15-verify: KAT against rsa_signature_2048_sha256_test.j await key.verify(arrayStream(hexToBytes(ok.msg)), hexToBytes(ok.sig)); const bad = g.tests.find((x: { result: string }) => x.result === "invalid"); const err = await assertRejects(() => key.verify(arrayStream(hexToBytes(bad.msg)), hexToBytes(bad.sig))); - assertEq(tag(err), "authentication-failed"); + assertEq(errKind(err), "authentication-failed"); }); Deno.test("rsa-pss-verify: KAT against rsa_pss_2048_sha256_mgf1_32_test.json (salt length bound at mint)", async () => { @@ -244,7 +244,7 @@ Deno.test("rsa-pss-verify: KAT against rsa_pss_2048_sha256_mgf1_32_test.json (sa // key's salt length is mint-bound (`import-verifying-key-jwk`'s contract). const other = await rsaPssVerify.importVerifyingKeyJwk("sha256", 0, JSON.stringify(g.publicKeyJwk)); const err = await assertRejects(() => other.verify(arrayStream(hexToBytes(ok.msg)), hexToBytes(ok.sig))); - assertEq(tag(err), "authentication-failed"); + assertEq(errKind(err), "authentication-failed"); }); Deno.test("rsa-oaep: KAT against rsa_oaep_2048_sha256_mgf1sha256_test.json tcId 1 (valid) and tcId 32 (truncated ciphertext)", async () => { @@ -265,7 +265,7 @@ Deno.test("rsa-oaep: KAT against rsa_oaep_2048_sha256_mgf1sha256_test.json tcId key.decrypt(bad.label.length > 0 ? hexToBytes(bad.label) : undefined, hexToBytes(bad.ct)) ); // RFC 8017's single verdict: every decryption failure is detail-free. - assertEq(tag(err), "authentication-failed"); + assertEq(errKind(err), "authentication-failed"); }); Deno.test("sha1-checked: both postures decline with error.unsupported (no platform carries sha1dc)", () => { @@ -276,6 +276,6 @@ Deno.test("sha1-checked: both postures decline with error.unsupported (no platfo } catch (e) { caught = e; } - assertEq(tag(caught), "unsupported"); + assertEq(errKind(caught), "unsupported"); } }); diff --git a/ports/webcrypto/tests/hkdf_test.ts b/ports/webcrypto/tests/hkdf_test.ts index d2315be..b12b3d4 100644 --- a/ports/webcrypto/tests/hkdf_test.ts +++ b/ports/webcrypto/tests/hkdf_test.ts @@ -5,7 +5,7 @@ import { assertEq, assertRejects } from "./asserts.ts"; import { DeriveOptions, hkdf, hkdfSha2 } from "../src/mod.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; @@ -51,13 +51,13 @@ Deno.test("hkdf: import-ikm accepts empty material (RFC 5869 permits it)", async Deno.test("hkdf: import-ikm with a grantless options resource fails error.not-permitted", async () => { const err = await assertRejects( () => hkdf.importIkm(new Uint8Array(16), new DeriveOptions()), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "not-permitted"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "not-permitted"); }); Deno.test("hkdf-sha2: derive-bits(none) on a KDF input fails error.other (no natural output length)", async () => { const ikm = await hkdf.importIkm(new Uint8Array(16).fill(7), deriveOptions()); const input = await hkdfSha2.prepare("sha256", ikm, new Uint8Array(0), new Uint8Array(0)); - const err = await assertRejects(() => input.deriveBits(undefined)) as WitError; - assertEq((err.payload as { tag: string }).tag, "other"); + const err = await assertRejects(() => input.deriveBits(undefined)) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "other"); }); diff --git a/ports/webcrypto/tests/keyAgreement_test.ts b/ports/webcrypto/tests/keyAgreement_test.ts index d3c18ed..f704ff6 100644 --- a/ports/webcrypto/tests/keyAgreement_test.ts +++ b/ports/webcrypto/tests/keyAgreement_test.ts @@ -5,7 +5,7 @@ import { assertEq, assertRejects } from "./asserts.ts"; import { AgreementKeyOptions, x25519 } from "../src/mod.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; function agreeOptions(): AgreementKeyOptions { const o = new AgreementKeyOptions(); @@ -47,11 +47,11 @@ Deno.test("key-agreement: derive-bits without can-derive-bits fails error.not-pe const [sk] = await x25519.generateKey(opts); const [, pub2] = await x25519.generateKey(agreeOptions()); const input = await sk.agree(pub2); - const err = await assertRejects(() => input.deriveBits(undefined)) as WitError; - assertEq((err.payload as { tag: string }).tag, "not-permitted"); + const err = await assertRejects(() => input.deriveBits(undefined)) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "not-permitted"); }); Deno.test("x25519: import-public-key-raw with wrong length fails error.invalid-key", async () => { - const err = await assertRejects(() => x25519.importPublicKeyRaw(new Uint8Array(10))) as WitError; - assertEq((err.payload as { tag: string }).tag, "invalid-key"); + const err = await assertRejects(() => x25519.importPublicKeyRaw(new Uint8Array(10))) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "invalid-key"); }); diff --git a/ports/webcrypto/tests/mac_test.ts b/ports/webcrypto/tests/mac_test.ts index 70f669b..8e57d12 100644 --- a/ports/webcrypto/tests/mac_test.ts +++ b/ports/webcrypto/tests/mac_test.ts @@ -7,7 +7,7 @@ import { assertEq, assertRejects } from "./asserts.ts"; import { hmacSha1, hmacSha2, MacKeyOptions } from "../src/mod.ts"; import { arrayStream } from "./testStream.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; @@ -60,8 +60,8 @@ Deno.test("mac: sign on a verify-only key fails error.not-permitted (the usage-g const opts = new MacKeyOptions(); opts.canVerify(true); // sign NOT granted const key = await hmacSha2.importKeyRaw("sha256", new Uint8Array(32).fill(0x33), opts); - const err = await assertRejects(() => key.sign(arrayStream(new Uint8Array(0)))) as WitError; - assertEq((err.payload as { tag: string; val: string }).tag, "not-permitted"); + const err = await assertRejects(() => key.sign(arrayStream(new Uint8Array(0)))) as ComponentException; + assertEq((err.payload as { kind: string; value: string }).kind, "not-permitted"); }); Deno.test("mac: verify with a wrong tag fails error.authentication-failed", async () => { @@ -69,15 +69,15 @@ Deno.test("mac: verify with a wrong tag fails error.authentication-failed", asyn const wrongTag = new Uint8Array(32); // all-zero: not the real tag const err = await assertRejects( () => key.verify(arrayStream(new TextEncoder().encode("data")), wrongTag), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "authentication-failed"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "authentication-failed"); }); Deno.test("mac: importKeyRaw with empty material fails error.invalid-key", async () => { const err = await assertRejects( () => hmacSha2.importKeyRaw("sha256", new Uint8Array(0), signOptions()), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "invalid-key"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "invalid-key"); }); Deno.test("mac: export-key-raw on a non-extractable key fails error.not-extractable", async () => { @@ -85,6 +85,6 @@ Deno.test("mac: export-key-raw on a non-extractable key fails error.not-extracta opts.canSign(true); // extractable NOT granted (default false, per the package-wide options contract) const key = await hmacSha2.importKeyRaw("sha256", new Uint8Array(32).fill(0x55), opts); - const err = await assertRejects(() => key.exportKeyRaw()) as WitError; - assertEq((err.payload as { tag: string }).tag, "not-extractable"); + const err = await assertRejects(() => key.exportKeyRaw()) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "not-extractable"); }); diff --git a/ports/webcrypto/tests/signature_test.ts b/ports/webcrypto/tests/signature_test.ts index bbc7226..4048883 100644 --- a/ports/webcrypto/tests/signature_test.ts +++ b/ports/webcrypto/tests/signature_test.ts @@ -7,7 +7,7 @@ import { assertEq, assertRejects } from "./asserts.ts"; import { ed25519Sign, ed25519Verify, SigningKeyOptions } from "../src/mod.ts"; import { arrayStream } from "./testStream.ts"; -import { WitError } from "../../../runtime/src/embedder/errors.ts"; +import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; @@ -51,19 +51,19 @@ Deno.test("ed25519: verify with a tampered signature fails error.authentication- sig[0] ^= 0xff; // deliberately-corrupted signature (synthetic, not real key material) const err = await assertRejects( () => vk.verify(arrayStream(new TextEncoder().encode("payload")), sig), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "authentication-failed"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "authentication-failed"); }); Deno.test("ed25519: import-verifying-key-raw with wrong length fails error.invalid-key", async () => { const err = await assertRejects( () => ed25519Verify.importVerifyingKeyRaw(new Uint8Array(16)), - ) as WitError; - assertEq((err.payload as { tag: string }).tag, "invalid-key"); + ) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "invalid-key"); }); Deno.test("ed25519: sign without can-sign fails error.not-permitted (a untouched options resource cannot mint)", async () => { const opts = new SigningKeyOptions(); // canSign never called - const err = await assertRejects(() => ed25519Sign.generateKey(opts)) as WitError; - assertEq((err.payload as { tag: string }).tag, "not-permitted"); + const err = await assertRejects(() => ed25519Sign.generateKey(opts)) as ComponentException; + assertEq((err.payload as { kind: string }).kind, "not-permitted"); }); diff --git a/ports/webrtc/deno.json b/ports/webrtc/deno.json index 07a9474..6223913 100644 --- a/ports/webrtc/deno.json +++ b/ports/webrtc/deno.json @@ -1,6 +1,6 @@ { "nodeModulesDir": "auto", - "//": "This package imports the runtime by relative path (like ports/websocket). The one alias below is what ports/websocket itself uses internally; it resolves to the same file URL as the relative imports here, so there is exactly one `WitError`/`Stream` module instance and `instanceof` holds across the boundary (contracts/embedder-api.md's error-model brand hazard).", + "//": "This package imports the runtime by relative path (like ports/websocket). The one alias below is what ports/websocket itself uses internally; it resolves to the same file URL as the relative imports here, so there is exactly one `ComponentException`/`Stream` module instance and `instanceof` holds across the boundary (contracts/embedder-api.md's error-model brand hazard).", "imports": { "@deltic/runtime/embedder": "../../runtime/src/embedder/mod.ts" }, diff --git a/ports/webrtc/src/types.ts b/ports/webrtc/src/types.ts index 2cd2251..5c18f22 100644 --- a/ports/webrtc/src/types.ts +++ b/ports/webrtc/src/types.ts @@ -3,32 +3,32 @@ // // Authority: wit/webrtc.wit `interface types` (polymorph-webrtc-datachannels, // read-only reference). Enums are kebab-case string literal unions; variants -// are `{ tag, val? }`; records are plain camelCase objects. +// are `{ kind, value? }`; records are plain camelCase objects. import type { Stream, StreamSource } from "@deltic/runtime/embedder"; // --- error ----------------------------------------------------------------- export type WebrtcError = - | { tag: "closed" } - | { tag: "timed-out" } - | { tag: "invalid-signaling"; val: string } - | { tag: "receiving-via-stream" } - | { tag: "receive-buffer-overflow" } - | { tag: "other"; val: string }; + | { kind: "closed" } + | { kind: "timed-out" } + | { kind: "invalid-signaling"; value: string } + | { kind: "receiving-via-stream" } + | { kind: "receive-buffer-overflow" } + | { kind: "other"; value: string }; // --- message ----------------------------------------------------------------- export type Message = - | { tag: "binary"; val: Uint8Array } - | { tag: "string"; val: string }; + | { kind: "binary"; value: Uint8Array } + | { kind: "string"; value: string }; export const Message = { binary(bytes: Uint8Array): Message { - return { tag: "binary", val: bytes }; + return { kind: "binary", value: bytes }; }, string(text: string): Message { - return { tag: "string", val: text }; + return { kind: "string", value: text }; }, }; @@ -85,8 +85,8 @@ export interface IceCandidate { // --- config-error ------------------------------------------------------------------ export type ConfigError = - | { tag: "not-supported" } - | { tag: "invalid"; val: string }; + | { kind: "not-supported" } + | { kind: "invalid"; value: string }; // --- ice-server -------------------------------------------------------------------- diff --git a/ports/webrtc/src/webrtc.ts b/ports/webrtc/src/webrtc.ts index 26a153f..6e799c2 100644 --- a/ports/webrtc/src/webrtc.ts +++ b/ports/webrtc/src/webrtc.ts @@ -9,9 +9,9 @@ // authority) written against the standard W3C `RTCPeerConnection` / // `RTCDataChannel` API. Behavior is preserved; only the boundary conventions // change: -// - thrown bare `{ tag, val }` payloads become `throw new WitError(payload)` +// - thrown bare `{ tag, val }` payloads become `throw new ComponentException(payload)` // (contracts/embedder-api.md §"Error model" — "Host import with -// result": throw new WitError(payload) for err). +// result": throw new ComponentException(payload) for err). // - jco `Stream`/`ReadableStream` params/results become the runtime's real // `Stream` (consumed, e.g. `send-via-stream`'s guest-provided // messages) / `ReadableStream` (produced, e.g. `receive-via-stream`'s @@ -19,10 +19,10 @@ // a `stream` is expected). Imported from // `@deltic/runtime/embedder` (aliased in this package's // `deno.json` to the exact same file the runtime and every other port -// use), NOT reimplemented locally: `WitError` is a plain branded class +// use), NOT reimplemented locally: `ComponentException` is a plain branded class // with no `Store` involvement, so a local clone would produce a second // class identity and every `throw` from this port would fail -// `instanceof WitError` at a real component boundary — silently +// `instanceof ComponentException` at a real component boundary — silently // becoming an unbranded-throw trap instead of a guest-visible err. This // is exactly the cross-package brand hazard `ports/websocket/deno.json` // documents and solves the same way. @@ -34,7 +34,7 @@ // interpretation the reference authors made; the WIT gives no accessor // for it, so there is no guest-facing shape to convert. -import { Stream, type StreamSource, WitError } from "@deltic/runtime/embedder"; +import { Stream, type StreamSource, ComponentException } from "@deltic/runtime/embedder"; import type { ConfigError, ConnectionState, @@ -217,13 +217,13 @@ export class PeerConnectionConfig { setIceServers(servers: IceServer[]): void { for (const server of servers) { if (!server.urls.length) { - throw new WitError({ tag: "invalid", val: "ice-server has no urls" }); + throw new ComponentException({ kind: "invalid", value: "ice-server has no urls" }); } for (const url of server.urls) { if (!/^(stun|stuns|turn|turns):/.test(url)) { - throw new WitError({ - tag: "invalid", - val: `ice-server url ${JSON.stringify(url)} has no stun:/stuns:/turn:/turns: scheme`, + throw new ComponentException({ + kind: "invalid", + value: `ice-server url ${JSON.stringify(url)} has no stun:/stuns:/turn:/turns: scheme`, }); } } @@ -297,13 +297,13 @@ function incomingQueue(channel: { return; } const message: Message = typeof data === "string" - ? { tag: "string", val: data } - : { tag: "binary", val: new Uint8Array(data) }; + ? { kind: "string", value: data } + : { kind: "binary", value: new Uint8Array(data) }; push(message, size); }); const endError = (): WebrtcError => - overflowed ? { tag: "receive-buffer-overflow" } : { tag: "closed" }; + overflowed ? { kind: "receive-buffer-overflow" } : { kind: "closed" }; const end = () => { if (closed) return; closed = true; @@ -319,12 +319,12 @@ function incomingQueue(channel: { buffered -= size; return Promise.resolve(message); } - if (overflowed) return Promise.reject(new WitError({ tag: "receive-buffer-overflow" })); - if (closed) return Promise.reject(new WitError({ tag: "closed" })); + if (overflowed) return Promise.reject(new ComponentException({ kind: "receive-buffer-overflow" })); + if (closed) return Promise.reject(new ComponentException({ kind: "closed" })); return new Promise((resolve, reject) => { waiters.push({ resolve, - reject: (e) => reject(new WitError(e)), + reject: (e) => reject(new ComponentException(e)), }); }); }, @@ -337,7 +337,7 @@ function incomingQueue(channel: { messages.length = 0; buffered = 0; closed = true; - while (waiters.length) waiters.shift()!.reject({ tag: "closed" }); + while (waiters.length) waiters.shift()!.reject({ kind: "closed" }); }, }; } @@ -376,20 +376,20 @@ export class DataChannel { // `"open"`), unlike a synchronous local latch. Gate on the local flag // first so this port's `close()` is observed synchronously regardless of // backend timing. - if (this.#localClosed) throw new WitError({ tag: "closed" }); + if (this.#localClosed) throw new ComponentException({ kind: "closed" }); await this.#waitOpen(); await this.#waitForDrain(); try { - this.#channel.send(message.val); + this.#channel.send(message.value); } catch { - throw new WitError({ tag: "closed" }); + throw new ComponentException({ kind: "closed" }); } } async receive(): Promise { - if (this.#localClosed) throw new WitError({ tag: "closed" }); + if (this.#localClosed) throw new ComponentException({ kind: "closed" }); if (this.#streamClaimed) { - throw new WitError({ tag: "receiving-via-stream" }); + throw new ComponentException({ kind: "receiving-via-stream" }); } return this.#incoming.next(); } @@ -412,21 +412,21 @@ export class DataChannel { const bytes = await collectByteStream(item.data); if (bytes.length !== item.length) { throw { - tag: "other", - val: `stream-message payload was ${bytes.length} bytes but length declared ${item.length}`, + kind: "other", + value: `stream-message payload was ${bytes.length} bytes but length declared ${item.length}`, } satisfies WebrtcError; } const message: Message = item.kind === "string" - ? { tag: "string", val: new TextDecoder().decode(bytes) } - : { tag: "binary", val: bytes }; + ? { kind: "string", value: new TextDecoder().decode(bytes) } + : { kind: "binary", value: bytes }; await this.send(message); sent += 1n; } } catch (error) { - const payload: WebrtcError = error instanceof WitError + const payload: WebrtcError = error instanceof ComponentException ? (error.payload as WebrtcError) - : (isWebrtcError(error) ? error : { tag: "closed" }); - throw new WitError({ error: payload, sent }); + : (isWebrtcError(error) ? error : { kind: "closed" }); + throw new ComponentException({ error: payload, sent }); } } @@ -438,13 +438,13 @@ export class DataChannel { * expected — the runtime lowers it; this port never drives a `Store`. */ receiveViaStream(): ReadableStream { - if (this.#localClosed) throw new WitError({ tag: "closed" }); + if (this.#localClosed) throw new ComponentException({ kind: "closed" }); if (this.#streamClaimed) { - throw new WitError({ tag: "receiving-via-stream" }); + throw new ComponentException({ kind: "receiving-via-stream" }); } this.#streamClaimed = true; const incoming = this.#incoming; - incoming.rejectWaiters({ tag: "receiving-via-stream" }); + incoming.rejectWaiters({ kind: "receiving-via-stream" }); return new ReadableStream({ async pull(controller) { let message: Message; @@ -456,11 +456,11 @@ export class DataChannel { controller.close(); return; } - const bytes = message.tag === "string" - ? new TextEncoder().encode(message.val) - : message.val; + const bytes = message.kind === "string" + ? new TextEncoder().encode(message.value) + : message.value; controller.enqueue({ - kind: message.tag, + kind: message.kind, length: bytes.length, data: bytesToReadable(bytes) as StreamSource, }); @@ -473,18 +473,18 @@ export class DataChannel { const channel = this.#channel; if (channel.readyState === "open") return Promise.resolve(); if (channel.readyState === "closing" || channel.readyState === "closed") { - return Promise.reject(new WitError({ tag: "closed" })); + return Promise.reject(new ComponentException({ kind: "closed" })); } return new Promise((resolve, reject) => { channel.addEventListener("open", () => resolve(), { once: true }); channel.addEventListener( "close", - () => reject(new WitError({ tag: "closed" })), + () => reject(new ComponentException({ kind: "closed" })), { once: true }, ); channel.addEventListener( "error", - () => reject(new WitError({ tag: "closed" })), + () => reject(new ComponentException({ kind: "closed" })), { once: true }, ); }); @@ -547,7 +547,7 @@ export class DataChannel { } function isWebrtcError(v: unknown): v is WebrtcError { - return typeof v === "object" && v !== null && typeof (v as { tag?: unknown }).tag === "string"; + return typeof v === "object" && v !== null && typeof (v as { kind?: unknown }).kind === "string"; } // --- peer-connection ------------------------------------------------------------ @@ -684,7 +684,7 @@ export class PeerConnection { this.#closed || this.#failed || this.#isFailedNow() || this.#pc.connectionState === "closed" ) { - throw new WitError({ tag: "closed" }); + throw new ComponentException({ kind: "closed" }); } } @@ -709,7 +709,7 @@ export class PeerConnection { this.#ownedWrappers.add(wrapper); return wrapper; } catch (err) { - throw new WitError({ tag: "other", val: String(err) }); + throw new ComponentException({ kind: "other", value: String(err) }); } } @@ -734,7 +734,7 @@ export class PeerConnection { const offer = await this.#pc.createOffer(); return { kind: "offer", sdp: offer.sdp }; } catch (err) { - throw new WitError({ tag: "other", val: String(err) }); + throw new ComponentException({ kind: "other", value: String(err) }); } } @@ -744,7 +744,7 @@ export class PeerConnection { const answer = await this.#pc.createAnswer(); return { kind: "answer", sdp: answer.sdp }; } catch (err) { - throw new WitError({ tag: "other", val: String(err) }); + throw new ComponentException({ kind: "other", value: String(err) }); } } @@ -753,7 +753,7 @@ export class PeerConnection { try { await this.#pc.setLocalDescription({ type: description.kind, sdp: description.sdp }); } catch (err) { - throw new WitError({ tag: "invalid-signaling", val: String(err) }); + throw new ComponentException({ kind: "invalid-signaling", value: String(err) }); } } @@ -762,7 +762,7 @@ export class PeerConnection { try { await this.#pc.setRemoteDescription({ type: description.kind, sdp: description.sdp }); } catch (err) { - throw new WitError({ tag: "invalid-signaling", val: String(err) }); + throw new ComponentException({ kind: "invalid-signaling", value: String(err) }); } } @@ -787,7 +787,7 @@ export class PeerConnection { sdpMLineIndex: candidate.sdpMlineIndex ?? null, }); } catch (err) { - throw new WitError({ tag: "invalid-signaling", val: String(err) }); + throw new ComponentException({ kind: "invalid-signaling", value: String(err) }); } } @@ -821,11 +821,11 @@ export class PeerConnection { if (this.#isConnectedNow()) this.#everConnected = true; if (this.#everConnected) return; - if (this.#closed || isFailed()) throw new WitError({ tag: "closed" }); + if (this.#closed || isFailed()) throw new ComponentException({ kind: "closed" }); await new Promise((resolve, reject) => { const timer = setTimeout(() => { cleanup(); - reject(new WitError({ tag: "timed-out" })); + reject(new ComponentException({ kind: "timed-out" })); }, CONNECT_TIMEOUT_MS); const check = () => { if (this.#isConnectedNow()) { @@ -834,12 +834,12 @@ export class PeerConnection { resolve(); } else if (isFailed()) { cleanup(); - reject(new WitError({ tag: "closed" })); + reject(new ComponentException({ kind: "closed" })); } }; const onClose = () => { cleanup(); - reject(new WitError({ tag: "closed" })); + reject(new ComponentException({ kind: "closed" })); }; const cleanup = () => { clearTimeout(timer); diff --git a/ports/webrtc/webrtc.test.ts b/ports/webrtc/webrtc.test.ts index 414fcc6..570a731 100644 --- a/ports/webrtc/webrtc.test.ts +++ b/ports/webrtc/webrtc.test.ts @@ -23,7 +23,7 @@ import { resetMaxInboundBufferBytes, setMaxInboundBufferBytes, } from "./src/webrtc.ts"; -import { WitError } from "@deltic/runtime/embedder"; +import { ComponentException } from "@deltic/runtime/embedder"; import type { IceCandidate, Message, WebrtcError } from "./src/types.ts"; const NO_SANITIZE = { sanitizeResources: false, sanitizeOps: false }; @@ -98,13 +98,13 @@ Deno.test("loopback: text echo both directions", NO_SANITIZE, async () => { const chA = a.createDataChannel(options); const chB = await firstIncoming(b); - await chA.send({ tag: "string", val: "hello from a" }); + await chA.send({ kind: "string", value: "hello from a" }); const gotAtB = await chB.receive(); - assertEquals(gotAtB, { tag: "string", val: "hello from a" }); + assertEquals(gotAtB, { kind: "string", value: "hello from a" }); - await chB.send({ tag: "string", val: "hello from b" }); + await chB.send({ kind: "string", value: "hello from b" }); const gotAtA = await chA.receive(); - assertEquals(gotAtA, { tag: "string", val: "hello from b" }); + assertEquals(gotAtA, { kind: "string", value: "hello from b" }); } finally { a.close(); b.close(); @@ -119,13 +119,13 @@ Deno.test("loopback: binary echo + message-boundary preservation", NO_SANITIZE, const msg1 = new Uint8Array([1, 2, 3]); const msg2 = new Uint8Array([4, 5]); - await chA.send({ tag: "binary", val: msg1 }); - await chA.send({ tag: "binary", val: msg2 }); + await chA.send({ kind: "binary", value: msg1 }); + await chA.send({ kind: "binary", value: msg2 }); const got1 = await chB.receive(); const got2 = await chB.receive(); - assertEquals(got1, { tag: "binary", val: msg1 }); - assertEquals(got2, { tag: "binary", val: msg2 }); + assertEquals(got1, { kind: "binary", value: msg1 }); + assertEquals(got2, { kind: "binary", value: msg2 }); } finally { a.close(); b.close(); @@ -143,8 +143,8 @@ Deno.test("loopback: unordered/maxRetransmits options accepted", NO_SANITIZE, as const chA = a.createDataChannel(options); const chB = await firstIncoming(b); - await chA.send({ tag: "string", val: "unordered ok" }); - assertEquals(await chB.receive(), { tag: "string", val: "unordered ok" }); + await chA.send({ kind: "string", value: "unordered ok" }); + assertEquals(await chB.receive(), { kind: "string", value: "unordered ok" }); } finally { a.close(); b.close(); @@ -164,21 +164,21 @@ Deno.test("loopback: receive-via-stream consumes a burst", NO_SANITIZE, async () const bytes = await collectU8(sm.data as unknown as AsyncIterable); received.push( sm.kind === "string" - ? { tag: "string", val: new TextDecoder().decode(bytes) } - : { tag: "binary", val: bytes }, + ? { kind: "string", value: new TextDecoder().decode(bytes) } + : { kind: "binary", value: bytes }, ); if (received.length === 3) return; } })(); for (let i = 0; i < 3; i++) { - await chA.send({ tag: "string", val: `msg-${i}` }); + await chA.send({ kind: "string", value: `msg-${i}` }); } await streamDone; assertEquals(received, [ - { tag: "string", val: "msg-0" }, - { tag: "string", val: "msg-1" }, - { tag: "string", val: "msg-2" }, + { kind: "string", value: "msg-0" }, + { kind: "string", value: "msg-1" }, + { kind: "string", value: "msg-2" }, ]); } finally { a.close(); @@ -210,8 +210,8 @@ Deno.test("loopback: single-use violation -> receiving-via-stream error", NO_SAN void chA; // keep `a`'s channel referenced for symmetry/documentation chB.receiveViaStream(); - const err = await assertRejects(() => chB.receive(), WitError); - assertEquals((err as WitError).payload, { tag: "receiving-via-stream" }); + const err = await assertRejects(() => chB.receive(), ComponentException); + assertEquals((err as ComponentException).payload, { kind: "receiving-via-stream" }); // A second `receiveViaStream` call after the first also violates the // once-only rule (thrown synchronously, per the WIT contract). @@ -221,8 +221,8 @@ Deno.test("loopback: single-use violation -> receiving-via-stream error", NO_SAN } catch (e) { threw = e; } - assert(threw instanceof WitError); - assertEquals((threw as WitError).payload, { tag: "receiving-via-stream" }); + assert(threw instanceof ComponentException); + assertEquals((threw as ComponentException).payload, { kind: "receiving-via-stream" }); } finally { a.close(); b.close(); @@ -242,7 +242,7 @@ Deno.test("loopback: inbound-buffer overflow -> overflow-close semantics", NO_SA // and the overflow-close fires on the sender or receiver's channel. for (let i = 0; i < 20; i++) { try { - await chA.send({ tag: "string", val: `0123456789-${i}` }); + await chA.send({ kind: "string", value: `0123456789-${i}` }); } catch { break; // sender side observed the close once b's channel closed. } @@ -258,9 +258,9 @@ Deno.test("loopback: inbound-buffer overflow -> overflow-close semantics", NO_SA try { await chB.receive(); } catch (e) { - assert(e instanceof WitError); - assertEquals((e as WitError).payload, { - tag: "receive-buffer-overflow", + assert(e instanceof ComponentException); + assertEquals((e as ComponentException).payload, { + kind: "receive-buffer-overflow", }); overflowed = true; break; @@ -282,8 +282,8 @@ Deno.test("loopback: close propagation + post-close error cases", NO_SANITIZE, a const chB = await firstIncoming(b); chA.close(); - const err = await assertRejects(() => chA.send({ tag: "string", val: "x" }), WitError); - assertEquals((err as WitError).payload, { tag: "closed" }); + const err = await assertRejects(() => chA.send({ kind: "string", value: "x" }), ComponentException); + assertEquals((err as ComponentException).payload, { kind: "closed" }); // The peer observes the remote close too (eventually `receive` fails). let sawClosed = false; @@ -291,7 +291,7 @@ Deno.test("loopback: close propagation + post-close error cases", NO_SANITIZE, a try { await chB.receive(); } catch (e) { - assert(e instanceof WitError); + assert(e instanceof ComponentException); sawClosed = true; break; } @@ -300,8 +300,8 @@ Deno.test("loopback: close propagation + post-close error cases", NO_SANITIZE, a a.close(); b.close(); - const connErr = await assertRejects(() => a.createOffer(), WitError); - assertEquals((connErr as WitError).payload, { tag: "closed" }); + const connErr = await assertRejects(() => a.createOffer(), ComponentException); + assertEquals((connErr as ComponentException).payload, { kind: "closed" }); }); Deno.test( @@ -319,17 +319,17 @@ Deno.test( // the backend transitions the native readyState. a.close(); const errA = await assertRejects( - () => chA.send({ tag: "string", val: "after-close" }), - WitError, + () => chA.send({ kind: "string", value: "after-close" }), + ComponentException, ); - assertEquals((errA as WitError).payload, { tag: "closed" }); + assertEquals((errA as ComponentException).payload, { kind: "closed" }); b.close(); const errB = await assertRejects( - () => chB.send({ tag: "string", val: "after-close" }), - WitError, + () => chB.send({ kind: "string", value: "after-close" }), + ComponentException, ); - assertEquals((errB as WitError).payload, { tag: "closed" }); + assertEquals((errB as ComponentException).payload, { kind: "closed" }); }, ); diff --git a/ports/websocket/README.md b/ports/websocket/README.md index 6ad3816..42f9737 100644 --- a/ports/websocket/README.md +++ b/ports/websocket/README.md @@ -25,7 +25,7 @@ Deno prints is benign. browser-first host the consumer's suite asserts. `src/websocket.ts` preserves its logic line-for-line (cited as `websocket.js:LINE`) and translates only the conventions: - bare-payload throws → `WitError`, jco `Stream` → `Stream` / + bare-payload throws → `ComponentException`, jco `Stream` → `Stream` / `ReadableStream`, `--map` module wiring → `websocketImports()`, module-level setters → `configure()` **plus** the compatible `setMaxInboundBufferBytes` / `setConnectTimeoutMs` / diff --git a/ports/websocket/deno.json b/ports/websocket/deno.json index 5aba3d7..8c71022 100644 --- a/ports/websocket/deno.json +++ b/ports/websocket/deno.json @@ -2,7 +2,7 @@ "name": "@deltic/port-websocket", "version": "0.0.0", "exports": "./src/websocket.ts", - "//": "This package is deliberately NOT a member of the root workspace (like tools/smoke-c0): it imports the runtime, wasi-shims and ct-runner by relative path. The one alias below is what wasi-shims itself uses internally; it resolves to the same file URL as the relative imports here, so there is exactly one module instance and `instanceof WitError` holds across the boundary.", + "//": "This package is deliberately NOT a member of the root workspace (like tools/smoke-c0): it imports the runtime, wasi-shims and ct-runner by relative path. The one alias below is what wasi-shims itself uses internally; it resolves to the same file URL as the relative imports here, so there is exactly one module instance and `instanceof ComponentException` holds across the boundary.", "imports": { "@deltic/runtime/embedder": "../../runtime/src/embedder/mod.ts" }, diff --git a/ports/websocket/src/websocket.ts b/ports/websocket/src/websocket.ts index c7484ed..dadff56 100644 --- a/ports/websocket/src/websocket.ts +++ b/ports/websocket/src/websocket.ts @@ -12,7 +12,7 @@ // // jco | this port // -------------------------------------+------------------------------------ -// `throw { tag, val }` (bare payload) | `throw new WitError({ tag, val })` +// `throw { tag, val }` (bare payload) | `throw new ComponentException({ kind, value })` // jco `Stream` (`read({count})`) | `Stream` / `ReadableStream` // module-namespace `--map` wiring | `websocketImports()` record fragment // module-level setters | `configure()` + compatible setters @@ -27,20 +27,20 @@ import { Stream, type StreamSource, - WitError, + ComponentException, } from "../../../runtime/src/embedder/mod.ts"; // ----- WIT value types (contracts/embedder-api.md §"Value mapping") --------- -/** `types.error` — a variant; `val` is absent for payloadless cases. */ +/** `types.error` — a variant; `value` is absent for payloadless cases. */ export type WebsocketError = - | { tag: "invalid-url"; val: string } - | { tag: "connect-failed"; val: string } - | { tag: "closed" } - | { tag: "receiving-via-stream" } - | { tag: "receive-buffer-overflow" } - | { tag: "invalid-argument"; val: string } - | { tag: "other"; val: string }; + | { kind: "invalid-url"; value: string } + | { kind: "connect-failed"; value: string } + | { kind: "closed" } + | { kind: "receiving-via-stream" } + | { kind: "receive-buffer-overflow" } + | { kind: "invalid-argument"; value: string } + | { kind: "other"; value: string }; /** * `types.message` — `variant { binary(list), %string(string) }`. @@ -49,8 +49,8 @@ export type WebsocketError = * of the name, so the conventions' "kebab-case verbatim" tag is `"string"`. */ export type Message = - | { tag: "binary"; val: Uint8Array } - | { tag: "string"; val: string }; + | { kind: "binary"; value: Uint8Array } + | { kind: "string"; value: string }; /** `types.message-kind` — an enum, so a string-literal union. */ export type MessageKind = "binary" | "string"; @@ -85,10 +85,10 @@ export interface CloseInfo { export type WebsocketState = "open" | "closing" | "closed"; /** Throw a WIT `error` the branded way (contracts/embedder-api.md §"Error model"). */ -function witError(payload: WebsocketError): WitError { - return new WitError( +function componentException(payload: WebsocketError): ComponentException { + return new ComponentException( payload, - payload.tag + ("val" in payload ? `: ${payload.val}` : ""), + payload.kind + ("value" in payload ? `: ${payload.value}` : ""), ); } @@ -219,30 +219,30 @@ function isValidProtocolToken(token: string): boolean { /** Validate a connect URL per the WIT contract; throws `invalid-url`. */ function validateUrl(url: string): void { if (url.includes("#")) { - throw witError({ tag: "invalid-url", val: "URL must not have a fragment" }); + throw componentException({ kind: "invalid-url", value: "URL must not have a fragment" }); } let parsed: URL; try { parsed = new URL(url); } catch (err) { - throw witError({ - tag: "invalid-url", - val: `URL does not parse: ${(err as Error)?.message ?? err}`, + throw componentException({ + kind: "invalid-url", + value: `URL does not parse: ${(err as Error)?.message ?? err}`, }); } if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { - throw witError({ - tag: "invalid-url", - val: `URL scheme must be ws or wss, not ${JSON.stringify(parsed.protocol)}`, + throw componentException({ + kind: "invalid-url", + value: `URL scheme must be ws or wss, not ${JSON.stringify(parsed.protocol)}`, }); } if (!parsed.hostname) { - throw witError({ tag: "invalid-url", val: "URL must have a host" }); + throw componentException({ kind: "invalid-url", value: "URL must have a host" }); } // The WHATWG WebSocket constructor rejects credentials in the URL; the // eager taxonomy matches that floor uniformly. websocket.js:127-131. if (parsed.username || parsed.password) { - throw witError({ tag: "invalid-url", val: "URL must not have userinfo" }); + throw componentException({ kind: "invalid-url", value: "URL must not have userinfo" }); } } @@ -251,15 +251,15 @@ function validateProtocols(protocols: string[]): void { for (let i = 0; i < protocols.length; i += 1) { const protocol = protocols[i]; if (!isValidProtocolToken(protocol)) { - throw witError({ - tag: "invalid-argument", - val: `subprotocol ${JSON.stringify(protocol)} is not a valid token`, + throw componentException({ + kind: "invalid-argument", + value: `subprotocol ${JSON.stringify(protocol)} is not a valid token`, }); } if (protocols.indexOf(protocol) !== i) { - throw witError({ - tag: "invalid-argument", - val: `subprotocol ${JSON.stringify(protocol)} is offered twice`, + throw componentException({ + kind: "invalid-argument", + value: `subprotocol ${JSON.stringify(protocol)} is offered twice`, }); } } @@ -273,22 +273,22 @@ function validateProtocols(protocols: string[]): void { function validateCloseArgs(code: number | undefined, reason: string): void { if (code !== undefined && code !== null) { if (code !== 1000 && !(code >= 3000 && code <= 4999)) { - throw witError({ - tag: "invalid-argument", - val: `close code must be 1000 or in 3000-4999, not ${code}`, + throw componentException({ + kind: "invalid-argument", + value: `close code must be 1000 or in 3000-4999, not ${code}`, }); } } else if (reason.length) { - throw witError({ - tag: "invalid-argument", - val: "a close reason requires a close code", + throw componentException({ + kind: "invalid-argument", + value: "a close reason requires a close code", }); } const bytes = utf8ByteLength(reason); if (bytes > 123) { - throw witError({ - tag: "invalid-argument", - val: `close reason must be at most 123 bytes, got ${bytes}`, + throw componentException({ + kind: "invalid-argument", + value: `close reason must be at most 123 bytes, got ${bytes}`, }); } } @@ -331,7 +331,7 @@ export class Websocket { /** * `connect: static async func(url, protocols) -> result`. * Resolves with a `Websocket` once the handshake completes; throws - * `WitError` on failure. websocket.js:203. + * `ComponentException` on failure. websocket.js:203. */ static async connect(url: string, protocols: string[]): Promise { validateUrl(url); @@ -343,9 +343,9 @@ export class Websocket { } catch (err) { // Eager validation covered the SyntaxError cases; anything left is a // platform policy refusing the connection. - throw witError({ - tag: "connect-failed", - val: String((err as Error)?.message ?? err), + throw componentException({ + kind: "connect-failed", + value: String((err as Error)?.message ?? err), }); } ws.binaryType = "arraybuffer"; @@ -366,9 +366,9 @@ export class Websocket { const ce = event as CloseEvent; settle( reject, - witError({ - tag: "connect-failed", - val: ce.reason || `connection failed (code ${ce.code})`, + componentException({ + kind: "connect-failed", + value: ce.reason || `connection failed (code ${ce.code})`, }), ); }; @@ -382,9 +382,9 @@ export class Websocket { timer = setTimeout(() => { settle( reject, - witError({ - tag: "connect-failed", - val: `handshake timed out after ${connectTimeoutMs}ms`, + componentException({ + kind: "connect-failed", + value: `handshake timed out after ${connectTimeoutMs}ms`, }), ); try { @@ -402,9 +402,9 @@ export class Websocket { try { ws.close(); } catch { /* already closing */ } - throw witError({ - tag: "connect-failed", - val: ws.protocol + throw componentException({ + kind: "connect-failed", + value: ws.protocol ? `server selected subprotocol ${JSON.stringify(ws.protocol)} which was not offered` : "server selected no subprotocol although one was offered", }); @@ -413,9 +413,9 @@ export class Websocket { try { ws.close(); } catch { /* already closing */ } - throw witError({ - tag: "connect-failed", - val: `server selected subprotocol ${ + throw componentException({ + kind: "connect-failed", + value: `server selected subprotocol ${ JSON.stringify(ws.protocol) } although none was offered`, }); @@ -450,16 +450,16 @@ export class Websocket { async send(message: Message): Promise { for (;;) { if (this.#localClosed || this.#ws.readyState !== WebSocket.OPEN) { - throw witError({ tag: "closed" }); + throw componentException({ kind: "closed" }); } if (this.#ws.bufferedAmount <= MAX_BUFFERED_AMOUNT) break; // No `bufferedamountlow` on WebSocket: poll the drain. await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS)); } try { - this.#ws.send(message.val as string | Uint8Array); + this.#ws.send(message.value as string | Uint8Array); } catch (err) { - throw witError({ tag: "other", val: String((err as Error)?.message ?? err) }); + throw componentException({ kind: "other", value: String((err as Error)?.message ?? err) }); } } @@ -468,16 +468,16 @@ export class Websocket { * `error` once the connection closes. websocket.js:325. */ receive(): Promise { - if (this.#localClosed) return Promise.reject(witError({ tag: "closed" })); + if (this.#localClosed) return Promise.reject(componentException({ kind: "closed" })); if (this.#streamClaimed) { - return Promise.reject(witError({ tag: "receiving-via-stream" })); + return Promise.reject(componentException({ kind: "receiving-via-stream" })); } return this.#incoming.next(); } /** * `send-via-stream: async func(stream) -> result<_, send-via-stream-error>`. - * Throws `WitError`. websocket.js:336. + * Throws `ComponentException`. websocket.js:336. */ async sendViaStream(messages: Stream): Promise { let sent = 0n; @@ -488,9 +488,9 @@ export class Websocket { // memory without bound. websocket.js:341-349. const { bytes, excess } = await collectByteStream(item.data, item.length); if (excess > 0 || bytes.length !== item.length) { - throw witError({ - tag: "other", - val: `stream-message payload was ${ + throw componentException({ + kind: "other", + value: `stream-message payload was ${ bytes.length + excess } bytes but length declared ${item.length}`, }); @@ -503,14 +503,14 @@ export class Websocket { try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { - throw witError({ - tag: "other", - val: "string stream-message payload is not valid UTF-8", + throw componentException({ + kind: "other", + value: "string stream-message payload is not valid UTF-8", }); } - message = { tag: "string", val: text }; + message = { kind: "string", value: text }; } else { - message = { tag: "binary", val: bytes }; + message = { kind: "binary", value: bytes }; } await this.send(message); sent += 1n; @@ -518,10 +518,10 @@ export class Websocket { } catch (error) { // A WIT error variant passes through; anything else is a host-side // failure and must not masquerade as a normal close. websocket.js:370-378. - const payload: WebsocketError = error instanceof WitError + const payload: WebsocketError = error instanceof ComponentException ? error.payload as WebsocketError - : { tag: "other", val: String(error) }; - throw new WitError( + : { kind: "other", value: String(error) }; + throw new ComponentException( { error: payload, sent }, `send-via-stream failed after ${sent} message(s)`, ); @@ -539,11 +539,11 @@ export class Websocket { * (contracts/embedder-api.md §"Streams and futures"). */ receiveViaStream(): ReadableStream { - if (this.#localClosed) throw witError({ tag: "closed" }); - if (this.#streamClaimed) throw witError({ tag: "receiving-via-stream" }); + if (this.#localClosed) throw componentException({ kind: "closed" }); + if (this.#streamClaimed) throw componentException({ kind: "receiving-via-stream" }); this.#streamClaimed = true; const incoming = this.#incoming; - incoming.rejectWaiters({ tag: "receiving-via-stream" }); + incoming.rejectWaiters({ kind: "receiving-via-stream" }); return new ReadableStream({ async pull(controller) { let message: Message; @@ -555,11 +555,11 @@ export class Websocket { controller.close(); return; } - const bytes = message.tag === "string" - ? new TextEncoder().encode(message.val) - : message.val; + const bytes = message.kind === "string" + ? new TextEncoder().encode(message.value) + : message.value; controller.enqueue({ - kind: message.tag, + kind: message.kind, length: bytes.length, data: bytesToStream(bytes), }); @@ -751,18 +751,18 @@ function incomingQueue(ws: WebSocket, onOverflowClose: () => void): IncomingQueu return; } const message: Message = typeof data === "string" - ? { tag: "string", val: data } - : { tag: "binary", val: new Uint8Array(data as ArrayBuffer) }; + ? { kind: "string", value: data } + : { kind: "binary", value: new Uint8Array(data as ArrayBuffer) }; push(message, size); }); const endError = (): WebsocketError => - overflowed ? { tag: "receive-buffer-overflow" } : { tag: "closed" }; + overflowed ? { kind: "receive-buffer-overflow" } : { kind: "closed" }; const end = () => { if (closed) return; closed = true; while (waiters.length) { - waiters.shift()!.reject(witError(endError())); + waiters.shift()!.reject(componentException(endError())); } }; ws.addEventListener("close", end); @@ -776,14 +776,14 @@ function incomingQueue(ws: WebSocket, onOverflowClose: () => void): IncomingQueu return Promise.resolve(message); } if (overflowed) { - return Promise.reject(witError({ tag: "receive-buffer-overflow" })); + return Promise.reject(componentException({ kind: "receive-buffer-overflow" })); } - if (closed) return Promise.reject(witError({ tag: "closed" })); + if (closed) return Promise.reject(componentException({ kind: "closed" })); return new Promise((resolve, reject) => waiters.push({ resolve, reject })); }, rejectWaiters(error: WebsocketError) { while (waiters.length) { - waiters.shift()!.reject(witError(error)); + waiters.shift()!.reject(componentException(error)); } }, end, @@ -794,7 +794,7 @@ function incomingQueue(ws: WebSocket, onOverflowClose: () => void): IncomingQueu buffered = 0; closed = true; while (waiters.length) { - waiters.shift()!.reject(witError({ tag: "closed" })); + waiters.shift()!.reject(componentException({ kind: "closed" })); } }, }; diff --git a/ports/websocket/tests/websocket_test.ts b/ports/websocket/tests/websocket_test.ts index d3d5ab9..71ba29f 100644 --- a/ports/websocket/tests/websocket_test.ts +++ b/ports/websocket/tests/websocket_test.ts @@ -9,7 +9,7 @@ // consumer's own conformance suite, executed by conformance/run.ts. import { assert, assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@^1.0.0"; -import { WitError } from "../../../runtime/src/embedder/mod.ts"; +import { ComponentException } from "../../../runtime/src/embedder/mod.ts"; import { currentConfig, resetConfig, @@ -21,19 +21,19 @@ import { } from "../src/websocket.ts"; import { burstPayload, startEchoServer, type TestServer } from "./echo_server.ts"; -/** Assert `fn` throws a branded `WitError` whose payload tag is `tag`. */ -function assertWitTag(fn: () => unknown, tag: WebsocketError["tag"]): WebsocketError { - const e = assertThrows(fn, WitError) as WitError; - assertEquals(e.payload.tag, tag); +/** Assert `fn` throws a branded `ComponentException` whose payload kind is `kind`. */ +function assertComponentExceptionKind(fn: () => unknown, kind: WebsocketError["kind"]): WebsocketError { + const e = assertThrows(fn, ComponentException) as ComponentException; + assertEquals(e.payload.kind, kind); return e.payload; } -async function assertRejectsWitTag( +async function assertRejectsComponentExceptionKind( fn: () => Promise, - tag: WebsocketError["tag"], + kind: WebsocketError["kind"], ): Promise { - const e = await assertRejects(fn, WitError) as WitError; - assertEquals(e.payload.tag, tag); + const e = await assertRejects(fn, ComponentException) as ComponentException; + assertEquals(e.payload.kind, kind); return e.payload; } @@ -54,15 +54,15 @@ Deno.test("connect + echo: text and binary round-trip, kinds preserved", async ( assertEquals(ws.protocol(), ""); assertEquals(ws.state(), "open"); - await ws.send({ tag: "string", val: "héllo — 你好 🦀" }); + await ws.send({ kind: "string", value: "héllo — 你好 🦀" }); const text = await ws.receive(); - assertEquals(text, { tag: "string", val: "héllo — 你好 🦀" }); + assertEquals(text, { kind: "string", value: "héllo — 你好 🦀" }); const payload = new Uint8Array([0, 1, 2, 253, 254, 255]); - await ws.send({ tag: "binary", val: payload }); + await ws.send({ kind: "binary", value: payload }); const bin = await ws.receive(); - assertEquals(bin.tag, "binary"); - assertEquals(bin.val as Uint8Array, payload); + assertEquals(bin.kind, "binary"); + assertEquals(bin.value as Uint8Array, payload); ws.close(1000, "bye"); const info = await ws.waitClosed(); @@ -87,7 +87,7 @@ Deno.test("subprotocol: offered but none selected fails connect-failed", async ( await withServer(async (s) => { // The WIT binds the server: "the connection fails if the server ... // selects none at all" (wit/websocket.wit:181-185). - await assertRejectsWitTag( + await assertRejectsComponentExceptionKind( () => Websocket.connect(`${s.base}/echo`, ["alpha"]), "connect-failed", ); @@ -97,7 +97,7 @@ Deno.test("subprotocol: offered but none selected fails connect-failed", async ( Deno.test("subprotocol: a malformed offer fails invalid-argument, eagerly", async () => { await withServer(async (s) => { for (const protocols of [["dup", "dup"], ["has space"], [""], ["bad,comma"]]) { - await assertRejectsWitTag( + await assertRejectsComponentExceptionKind( () => Websocket.connect(`${s.base}/echo`, protocols), "invalid-argument", ); @@ -117,7 +117,7 @@ Deno.test("connect: invalid URLs fail invalid-url, eagerly", async () => { "/echo", ] ) { - await assertRejectsWitTag(() => Websocket.connect(url, []), "invalid-url"); + await assertRejectsComponentExceptionKind(() => Websocket.connect(url, []), "invalid-url"); } }); }); @@ -126,19 +126,19 @@ Deno.test("close: argument validation is eager and leaves the connection usable" await withServer(async (s) => { const ws = await Websocket.connect(`${s.base}/echo`, []); for (const code of [0, 999, 1001, 1005, 1006, 1015, 2999, 5000, 65535]) { - assertWitTag(() => ws.close(code, ""), "invalid-argument"); + assertComponentExceptionKind(() => ws.close(code, ""), "invalid-argument"); } // A reason needs a code; 124 bytes is one too many; 123 is exact. - assertWitTag(() => ws.close(undefined, "reason"), "invalid-argument"); - assertWitTag(() => ws.close(1000, "r".repeat(124)), "invalid-argument"); + assertComponentExceptionKind(() => ws.close(undefined, "reason"), "invalid-argument"); + assertComponentExceptionKind(() => ws.close(1000, "r".repeat(124)), "invalid-argument"); // The bound counts UTF-8 bytes, not code units: 42 three-byte chars // overflow, 41 fit exactly. - assertWitTag(() => ws.close(4000, "€".repeat(42)), "invalid-argument"); + assertComponentExceptionKind(() => ws.close(4000, "€".repeat(42)), "invalid-argument"); // A rejected close left the connection usable. - await ws.send({ tag: "binary", val: new Uint8Array([7, 7, 7]) }); + await ws.send({ kind: "binary", value: new Uint8Array([7, 7, 7]) }); const echoed = await ws.receive(); - assertEquals(echoed.val as Uint8Array, new Uint8Array([7, 7, 7])); + assertEquals(echoed.value as Uint8Array, new Uint8Array([7, 7, 7])); assertEquals(ws.state(), "open"); ws.close(4999, "€".repeat(41)); @@ -150,13 +150,13 @@ Deno.test("close: argument validation is eager and leaves the connection usable" Deno.test("close: local close discards the backlog and latches", async () => { await withServer(async (s) => { const ws = await Websocket.connect(`${s.base}/echo`, []); - await ws.send({ tag: "binary", val: new Uint8Array([1, 2, 3]) }); + await ws.send({ kind: "binary", value: new Uint8Array([1, 2, 3]) }); ws.close(1000, ""); // Idempotent: a second close is a no-op, not an error. ws.close(4000, "second"); - await assertRejectsWitTag(() => ws.receive(), "closed"); - await assertRejectsWitTag( - () => ws.send({ tag: "binary", val: new Uint8Array([1]) }), + await assertRejectsComponentExceptionKind(() => ws.receive(), "closed"); + await assertRejectsComponentExceptionKind( + () => ws.send({ kind: "binary", value: new Uint8Array([1]) }), "closed", ); await ws.waitClosed(); @@ -168,8 +168,8 @@ Deno.test("receive-via-stream: happy path delivers one stream-message per messag await withServer(async (s) => { const ws = await Websocket.connect(`${s.base}/echo`, []); const sent = [ - { tag: "binary", val: new Uint8Array([9, 8, 7, 6]) } as const, - { tag: "string", val: "streamed téxt ✓" } as const, + { kind: "binary", value: new Uint8Array([9, 8, 7, 6]) } as const, + { kind: "string", value: "streamed téxt ✓" } as const, ]; for (const m of sent) await ws.send(m); @@ -203,9 +203,9 @@ Deno.test("receive-via-stream: single-use; pending receive is rejected", async ( // with `receiving-via-stream` (wit/websocket.wit:239-243). const pending = ws.receive(); const stream = ws.receiveViaStream(); - await assertRejectsWitTag(() => pending, "receiving-via-stream"); - assertWitTag(() => ws.receiveViaStream(), "receiving-via-stream"); - await assertRejectsWitTag(() => ws.receive(), "receiving-via-stream"); + await assertRejectsComponentExceptionKind(() => pending, "receiving-via-stream"); + assertComponentExceptionKind(() => ws.receiveViaStream(), "receiving-via-stream"); + await assertRejectsComponentExceptionKind(() => ws.receive(), "receiving-via-stream"); await stream.cancel(); ws.close(1000, ""); await ws.waitClosed(); @@ -230,10 +230,10 @@ Deno.test("flow control: overflow closes, backlog stays receivable, then overflo try { message = await ws.receive(); } catch (e) { - assertEquals((e as WitError).payload.tag, "receive-buffer-overflow"); + assertEquals((e as ComponentException).payload.kind, "receive-buffer-overflow"); break; } - assertEquals(message.val as Uint8Array, burstPayload(drained, 1024)); + assertEquals(message.value as Uint8Array, burstPayload(drained, 1024)); drained += 1; assert(drained <= floodCount, "received more messages than were sent"); } @@ -248,7 +248,7 @@ Deno.test("flow control: a message larger than the whole bound overflows immedia const ws = await Websocket.connect(`${s.base}/burst?count=1&size=8192`, []); // Nothing precedes it in the backlog: the very first receive observes // the overflow (wit/websocket.wit:165-169). - await assertRejectsWitTag(() => ws.receive(), "receive-buffer-overflow"); + await assertRejectsComponentExceptionKind(() => ws.receive(), "receive-buffer-overflow"); }); }); @@ -256,13 +256,13 @@ Deno.test("connect: the handshake bound fires as connect-failed", async () => { await withServer(async (s) => { setConnectTimeoutMs(250); const started = performance.now(); - const payload = await assertRejectsWitTag( + const payload = await assertRejectsComponentExceptionKind( () => Websocket.connect(`${s.base}/stall`, []), "connect-failed", ); const elapsed = performance.now() - started; assert(elapsed < 5_000, `connect bound did not fire promptly (${elapsed}ms)`); - assert("val" in payload && typeof payload.val === "string"); + assert("value" in payload && typeof payload.value === "string"); }); }); diff --git a/protocol/src/brands.ts b/protocol/src/brands.ts index 5118dad..334cb60 100644 --- a/protocol/src/brands.ts +++ b/protocol/src/brands.ts @@ -17,8 +17,16 @@ // carrying the right symbol is a legal value (this is what makes zero-import // host modules possible). The canonical classes are conveniences. -/** `WitError` — a WIT `result` err value. */ -export const WIT_ERROR: unique symbol = Symbol.for( +/** + * `ComponentException` — a WIT `result` err value. + * + * The key string keeps its pre-A10 name (`witError`) deliberately: it is an + * opaque wire constant, CEWD-style (same precedent as bindgen's CEWD name), + * so pre-A10 copies and hand-rolled brands keep interoperating. Only the + * exported TS identifier renamed with the class (contracts/embedder-api.md + * amendment A10). + */ +export const COMPONENT_EXCEPTION: unique symbol = Symbol.for( "deltic.witError/1", ); /** `Trap` — component-fatal, never a value. */ diff --git a/protocol/src/errors.ts b/protocol/src/errors.ts index 04d386b..f53175b 100644 --- a/protocol/src/errors.ts +++ b/protocol/src/errors.ts @@ -9,7 +9,7 @@ // // Three classes, three meanings, no overlap: // -// * `WitError` — a WIT `result` err **value**. The only thing that +// * `ComponentException` — a WIT `result` err **value**. The only thing that // crosses the boundary as an err. Branding is the point: under jco any // stray `TypeError` from a host import was fed to the lift, so every // consumer wrapped every platform call defensively (webcrypto.js's @@ -24,7 +24,7 @@ // The predicates below are brand-based and NOT `instanceof`. They are also // deliberately NOT installed as `Symbol.hasInstance` on the classes: a // consumer subclass would inherit that `hasInstance` and then match ANY -// branded value (`x instanceof MyWitError` true for a plain `WitError`), +// branded value (`x instanceof MyComponentException` true for a plain `ComponentException`), // which is a worse footgun than the one A9 removes. import { @@ -35,20 +35,20 @@ import { PEER_TRAPPED, STREAM_PRODUCER, TRAP, - WIT_ERROR, + COMPONENT_EXCEPTION, } from "./brands.ts"; /** A WIT `result` err value, branded. `payload` is shaped per the value table. */ -export class WitError extends Error { +export class ComponentException extends Error { readonly payload: E; constructor(payload: E, message?: string) { - super(message ?? `WIT error: ${describePayload(payload)}`); - this.name = "WitError"; + super(message ?? `component error: ${describePayload(payload)}`); + this.name = "ComponentException"; this.payload = payload; } } -defineBrand(WitError.prototype, WIT_ERROR); +defineBrand(ComponentException.prototype, COMPONENT_EXCEPTION); /** * A Component Model trap — a deterministic guest-visible fault @@ -140,8 +140,8 @@ export class StreamProducerError extends Error { defineBrand(StreamProducerError.prototype, STREAM_PRODUCER); /** Brand check: is this a WIT `result` err value? (A9; any copy, or hand-rolled.) */ -export function isWitError(v: unknown): v is WitError { - return hasBrand(v, WIT_ERROR); +export function isComponentException(v: unknown): v is ComponentException { + return hasBrand(v, COMPONENT_EXCEPTION); } /** Brand check: is this a component-fatal trap? (A9.) */ @@ -171,8 +171,8 @@ export function isStreamProducerError(v: unknown): v is StreamProducerError { function describePayload(p: unknown): string { if (p === null || p === undefined) return String(p); - if (typeof p === "object" && "tag" in (p as Record)) { - return String((p as { tag: unknown }).tag); + if (typeof p === "object" && "kind" in (p as Record)) { + return String((p as { kind: unknown }).kind); } if (typeof p === "object") return JSON.stringify(p); return String(p); diff --git a/protocol/src/mod.ts b/protocol/src/mod.ts index 7c2f07b..5b52426 100644 --- a/protocol/src/mod.ts +++ b/protocol/src/mod.ts @@ -30,7 +30,7 @@ export { SUSPENDING, TRAP, WASI_EXIT, - WIT_ERROR, + COMPONENT_EXCEPTION, } from "./brands.ts"; export { @@ -41,11 +41,11 @@ export { isPeerTrappedError, isStreamProducerError, isTrap, - isWitError, + isComponentException, PeerTrappedError, StreamProducerError, Trap, - WitError, + ComponentException, } from "./errors.ts"; export { anySuspendingImport, isSuspending, suspending } from "./suspending.ts"; diff --git a/protocol/tests/brands_test.ts b/protocol/tests/brands_test.ts index 15a9507..78d9896 100644 --- a/protocol/tests/brands_test.ts +++ b/protocol/tests/brands_test.ts @@ -8,10 +8,10 @@ import { assertEquals } from "./assert.ts"; import * as brands from "../src/brands.ts"; -import { PROTOCOL_GENERATION, WitError } from "../src/mod.ts"; +import { PROTOCOL_GENERATION, ComponentException } from "../src/mod.ts"; const EXPECTED: Record = { - "deltic.witError/1": brands.WIT_ERROR, + "deltic.witError/1": brands.COMPONENT_EXCEPTION, "deltic.trap/1": brands.TRAP, "deltic.dropped/1": brands.DROPPED, "deltic.peerTrapped/1": brands.PEER_TRAPPED, @@ -51,16 +51,16 @@ Deno.test("A9: the protocol generation matches the key suffix", () => { Deno.test("A9: brands are non-enumerable and non-writable on prototypes", () => { const d = Object.getOwnPropertyDescriptor( - WitError.prototype, - brands.WIT_ERROR, + ComponentException.prototype, + brands.COMPONENT_EXCEPTION, ); assertEquals(d?.value, true); assertEquals(d?.enumerable, false); assertEquals(d?.writable, false); // Not inherited by plain objects, and invisible to value walks. - assertEquals(Object.keys(new WitError(1)).includes("payload"), true); + assertEquals(Object.keys(new ComponentException(1)).includes("payload"), true); assertEquals( - Object.getOwnPropertySymbols(new WitError(1)).length, + Object.getOwnPropertySymbols(new ComponentException(1)).length, 0, "the brand lives on the prototype, never on instances", ); diff --git a/protocol/tests/errors_test.ts b/protocol/tests/errors_test.ts index f4f30df..a1bf24a 100644 --- a/protocol/tests/errors_test.ts +++ b/protocol/tests/errors_test.ts @@ -15,15 +15,15 @@ import { isPeerTrappedError, isStreamProducerError, isTrap, - isWitError, + isComponentException, PeerTrappedError, StreamProducerError, Trap, - WitError, + ComponentException, } from "../src/mod.ts"; Deno.test("A9: canonical classes are recognized by their own predicate", () => { - assert(isWitError(new WitError({ tag: "nope" }))); + assert(isComponentException(new ComponentException({ kind: "nope" }))); assert(isTrap(new Trap("x"))); assert(isDroppedError(new DroppedError())); assert(isPeerTrappedError(new PeerTrappedError("where", new Error("e")))); @@ -32,8 +32,8 @@ Deno.test("A9: canonical classes are recognized by their own predicate", () => { }); Deno.test("A9: the brands do not cross-talk", () => { - assertFalse(isTrap(new WitError(1))); - assertFalse(isWitError(new Trap())); + assertFalse(isTrap(new ComponentException(1))); + assertFalse(isComponentException(new Trap())); assertFalse(isDroppedError(new PeerTrappedError("w", "c"))); assertFalse(isPeerTrappedError(new DroppedError())); }); @@ -41,13 +41,13 @@ Deno.test("A9: the brands do not cross-talk", () => { Deno.test("A9: a hand-rolled brand IS the value (zero-import host module)", () => { // Precisely the shape contracts/embedder-api.md blesses: "an Error with // [Symbol.for('deltic.witError/1')]: true and a payload property IS a - // WitError to every copy". + // ComponentException to every copy". const e = Object.assign(new Error("boom"), { [Symbol.for("deltic.witError/1")]: true, - payload: { tag: "denied" }, + payload: { kind: "denied" }, }); - assert(isWitError(e)); - assertEquals(e.payload, { tag: "denied" }); + assert(isComponentException(e)); + assertEquals(e.payload, { kind: "denied" }); // Not even an Error: brands are markers, not a class hierarchy. assert(isTrap({ [Symbol.for("deltic.trap/1")]: true })); @@ -57,24 +57,24 @@ Deno.test("A9: a hand-rolled brand IS the value (zero-import host module)", () = }); Deno.test("A9: unbranded look-alikes are refused", () => { - class NotAWitError extends Error { + class NotAComponentException extends Error { payload = 1; } - assertFalse(isWitError(new NotAWitError())); - assertFalse(isWitError(new Error("plain"))); - assertFalse(isWitError({ payload: 1 })); - assertFalse(isWitError(null)); - assertFalse(isWitError(undefined)); - assertFalse(isWitError("deltic.witError/1")); - assertFalse(isWitError(42)); + assertFalse(isComponentException(new NotAComponentException())); + assertFalse(isComponentException(new Error("plain"))); + assertFalse(isComponentException({ payload: 1 })); + assertFalse(isComponentException(null)); + assertFalse(isComponentException(undefined)); + assertFalse(isComponentException("deltic.witError/1")); + assertFalse(isComponentException(42)); // Present but not exactly `true`: refused (no truthiness coercion). - assertFalse(isWitError({ [Symbol.for("deltic.witError/1")]: 1 })); + assertFalse(isComponentException({ [Symbol.for("deltic.witError/1")]: 1 })); }); Deno.test("A9: predicates are NOT instanceof — a foreign prototype passes", () => { // A different copy's class: same brand key (registry symbol), different // constructor identity. This is the #83 failure mode, made to pass. - class ForeignWitError extends Error { + class ForeignComponentException extends Error { payload: unknown; constructor(payload: unknown) { super("foreign"); @@ -82,20 +82,20 @@ Deno.test("A9: predicates are NOT instanceof — a foreign prototype passes", () } } Object.defineProperty( - ForeignWitError.prototype, + ForeignComponentException.prototype, Symbol.for("deltic.witError/1"), { value: true }, ); - const e = new ForeignWitError({ tag: "x" }); - assertFalse(e instanceof WitError, "premise: class identity differs"); - assert(isWitError(e), "brand identity holds"); + const e = new ForeignComponentException({ kind: "x" }); + assertFalse(e instanceof ComponentException, "premise: class identity differs"); + assert(isComponentException(e), "brand identity holds"); }); Deno.test("A9: Symbol.hasInstance is deliberately NOT overridden", () => { // Overriding it would be inherited by consumer subclasses, so // `x instanceof MySubclass` would match ANY branded value — a worse footgun // than the one A9 removes. instanceof keeps its plain nominal meaning. - class Sub extends WitError {} - assertFalse(new WitError(1) instanceof Sub); - assert(new Sub(1) instanceof WitError); + class Sub extends ComponentException {} + assertFalse(new ComponentException(1) instanceof Sub); + assert(new Sub(1) instanceof ComponentException); }); diff --git a/runtime/src/embedder/errors.ts b/runtime/src/embedder/errors.ts index b29f5d0..12dce07 100644 --- a/runtime/src/embedder/errors.ts +++ b/runtime/src/embedder/errors.ts @@ -19,10 +19,10 @@ export { isPeerTrappedError, isStreamProducerError, isTrap, - isWitError, + isComponentException, PeerTrappedError, Trap, - WitError, + ComponentException, } from "@deltic/protocol"; /** diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index f0a8a0f..8f055b9 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -26,8 +26,8 @@ import { import { camelCase, parseLeafName, pascalCase } from "./casing.ts"; import { isSuspending, suspending } from "../jspi/suspending.ts"; import { Translator } from "../shim/mod.ts"; -import { copyCensus, isTrap, isWitError } from "@deltic/protocol"; -import { NameCollisionError, WitError } from "./errors.ts"; +import { copyCensus, isTrap, isComponentException } from "@deltic/protocol"; +import { NameCollisionError, ComponentException } from "./errors.ts"; import { type ImportLeaf, requiredImports } from "./imports.ts"; import { buildGuestResourceClass, @@ -666,7 +666,7 @@ class Facade { * * Error model (contract §"Error model"), the inversion of jco's convention: * * a returned value is the ok side; - * * `throw new WitError(payload)` is the err side of a `result`; + * * `throw new ComponentException(payload)` is the err side of a `result`; * * a `Trap` passes through unchanged; * * **any other throw is a host bug and becomes a trap naming the import** * — never a guest-visible err. This is what makes the consumers' @@ -691,10 +691,10 @@ class Facade { return fromHost(v, resultType, o); }; const fail = (e: unknown, args: unknown[]): ComponentValue => { - // Brand, not class (amendment A9): a `WitError` thrown by a host module + // Brand, not class (amendment A9): a `ComponentException` thrown by a host module // that resolved a DIFFERENT runtime copy — or hand-rolled with the // registry symbol — is the same value here (issue #83). - if (isWitError(e) && isResult) { + if (isComponentException(e) && isResult) { const rt = resultType as ValType & { kind: "result" }; return { error: rt.error === null ? null : fromHost(e.payload, rt.error, o), @@ -711,21 +711,21 @@ class Facade { // normal outcome whose implementation may retain the handles. releaseAsyncArgs(args); if (isTrap(e)) throw e; - if (isWitError(e)) { + if (isComponentException(e)) { throw new Trap( - `${where} threw a WitError, but its WIT type has no err side; ` + + `${where} threw a ComponentException, but its WIT type has no err side; ` + `only a fallible import may signal an error value`, ); } // The #83 signature: in a graph with several copies, an UNBRANDED throw - // is usually a pre-A9 copy's `WitError` (its brand rode class identity, + // is usually a pre-A9 copy's `ComponentException` (its brand rode class identity, // which does not survive the copy boundary). Say so rather than leaving // the latent puzzle that motivated amendment A9. const census = copyCensus(); throw new Trap( `${where} threw ${describeThrow(e)}. An unbranded throw from a host ` + `import is a host bug and becomes a trap: signal a WIT error with ` + - `\`throw new WitError(payload)\`.` + + `\`throw new ComponentException(payload)\`.` + (census === "" ? "" : ` (${census} — an error carrying no deltic brand in a ` + @@ -981,7 +981,7 @@ class Facade { * Uniformly Promise-shaped (contract §"Functions and async"): a sync * completion resolves immediately, so there is one calling convention. * A `result` in *function-result* position resolves `T` or rejects - * `WitError`; a result nested inside a value is plain `{tag, val}` data + * `ComponentException`; a result nested inside a value is plain `{kind, value}` data * and never throws. */ #wrapExportFn( @@ -1039,7 +1039,7 @@ class Facade { if (resultType.kind === "result") { const v = raw as Record; if ("error" in v) { - throw new WitError( + throw new ComponentException( resultType.error === null ? undefined : toHost(v["error"], resultType.error, o), diff --git a/runtime/src/embedder/mod.ts b/runtime/src/embedder/mod.ts index 87cb76c..f166133 100644 --- a/runtime/src/embedder/mod.ts +++ b/runtime/src/embedder/mod.ts @@ -42,7 +42,7 @@ export { isStreamProducerError, isSuspending, isTrap, - isWitError, + isComponentException, PEER_TRAPPED, PROTOCOL_GENERATION, registerRuntimeCopy, @@ -53,7 +53,7 @@ export { STREAM_PRODUCER, SUSPENDING, TRAP, - WIT_ERROR, + COMPONENT_EXCEPTION, } from "@deltic/protocol"; export { @@ -75,7 +75,7 @@ export { NameCollisionError, PeerTrappedError, Trap, - WitError, + ComponentException, } from "./errors.ts"; export { diff --git a/runtime/src/embedder/values.ts b/runtime/src/embedder/values.ts index 518f60b..9320009 100644 --- a/runtime/src/embedder/values.ts +++ b/runtime/src/embedder/values.ts @@ -7,7 +7,7 @@ // (note: the internal despecialization labels result's err case // `"error"`, not `"err"` — cabi/types.ts `despecialize`), kebab-case // record keys, resource handles as bare reps. -// TO: the conventions: `{ tag, val? }` for variants and nested results, +// TO: the conventions: `{ kind, value? }` for variants and nested results, // outermost-option-as-`undefined` with nested boxing, real tuples, // camelCase record fields, flags objects, enum strings verbatim, // `Uint8Array` for `list`, class instances for resources, @@ -125,7 +125,7 @@ export interface AdapterOptions { * * `inOption` implements the contract's option rule: the *outermost* option in * a chain maps to `T | undefined`; an option nested **directly inside another - * option** boxes as `{ tag: "some", val } | { tag: "none" }`. Only option maps + * option** boxes as `{ kind: "some", value } | { kind: "none" }`. Only option maps * to `undefined`, so this is the only ambiguity, and the flag is set only when * descending through an option's payload — every other constructor resets it. */ @@ -198,8 +198,8 @@ export function toHost( throw new TypeError(`${o.where}: unknown variant case '${label}'`); } return c.type === null - ? { tag: label } - : { tag: label, val: toHost(payload, c.type, o, scope) }; + ? { kind: label } + : { kind: label, value: toHost(payload, c.type, o, scope) }; } case "enum": { // Enum values are data: kebab-case verbatim, never camelCased. @@ -210,8 +210,8 @@ export function toHost( const [label, payload] = single(v, o); if (inOption) { return label === "none" - ? { tag: "none" } - : { tag: "some", val: toHost(payload, t.type, o, scope, true) }; + ? { kind: "none" } + : { kind: "some", value: toHost(payload, t.type, o, scope, true) }; } return label === "none" ? undefined @@ -220,10 +220,10 @@ export function toHost( case "result": { const [label, payload] = single(v, o); // Internal despecialization names the err case "error"; the contract's - // tag is "err" (cabi/types.ts `despecialize`). - const tag = label === "error" ? "err" : "ok"; + // kind is "err" (cabi/types.ts `despecialize`). + const kind = label === "error" ? "err" : "ok"; const ct = label === "error" ? t.error : t.ok; - return ct === null ? { tag } : { tag, val: toHost(payload, ct, o, scope) }; + return ct === null ? { kind } : { kind, value: toHost(payload, ct, o, scope) }; } case "flags": { checkNoCollisions(t, t.labels, `${o.where}: flags`); @@ -390,16 +390,16 @@ export function fromHost( return out; } case "variant": { - const { tag, val, has } = tagged(v, o); - const c = t.cases.find((c) => c.label === tag); + const { kind, value, has } = tagged(v, o); + const c = t.cases.find((c) => c.label === kind); if (c === undefined) { - throw new TypeError(`${o.where}: unknown variant case '${tag}'`); + throw new TypeError(`${o.where}: unknown variant case '${kind}'`); } - if (c.type === null) return { [tag]: null }; + if (c.type === null) return { [kind]: null }; if (!has) { - throw new TypeError(`${o.where}: variant case '${tag}' needs a 'val'`); + throw new TypeError(`${o.where}: variant case '${kind}' needs a 'value'`); } - return { [tag]: fromHost(val, c.type, o) }; + return { [kind]: fromHost(value, c.type, o) }; } case "enum": { if (typeof v !== "string" || !t.labels.includes(v)) { @@ -412,38 +412,38 @@ export function fromHost( } case "option": { if (inOption) { - const { tag, val, has } = tagged(v, o); - if (tag === "none") return { none: null }; - if (tag !== "some") { + const { kind, value, has } = tagged(v, o); + if (kind === "none") return { none: null }; + if (kind !== "some") { throw new TypeError( - `${o.where}: a nested option must be { tag: "some" | "none" }`, + `${o.where}: a nested option must be { kind: "some" | "none" }`, ); } - return { some: has ? fromHost(val, t.type, o, true) : null }; + return { some: has ? fromHost(value, t.type, o, true) : null }; } return v === undefined ? { none: null } : { some: fromHost(v, t.type, o, true) }; } case "result": { - const { tag, val, has } = tagged(v, o); - if (tag !== "ok" && tag !== "err") { + const { kind, value, has } = tagged(v, o); + if (kind !== "ok" && kind !== "err") { throw new TypeError( - `${o.where}: a result value must be { tag: "ok" | "err" }`, + `${o.where}: a result value must be { kind: "ok" | "err" }`, ); } - const label = tag === "err" ? "error" : "ok"; - const ct = tag === "err" ? t.error : t.ok; + const label = kind === "err" ? "error" : "ok"; + const ct = kind === "err" ? t.error : t.ok; if (ct === null) return { [label]: null }; // Symmetric with the variant path above: a case that carries a payload // must be given one. Silently lowering `null` would put a zero where the // guest expects data. if (!has) { throw new TypeError( - `${o.where}: result case '${tag}' carries a payload and needs a 'val'`, + `${o.where}: result case '${kind}' carries a payload and needs a 'value'`, ); } - return { [label]: fromHost(val, ct, o) }; + return { [label]: fromHost(value, ct, o) }; } case "flags": { if (v === null || typeof v !== "object") { @@ -474,17 +474,17 @@ export function fromHost( function tagged( v: unknown, o: AdapterOptions, -): { tag: string; val: unknown; has: boolean } { - if (v === null || typeof v !== "object" || !("tag" in v)) { +): { kind: string; value: unknown; has: boolean } { + if (v === null || typeof v !== "object" || !("kind" in v)) { throw new TypeError( - `${o.where}: expected a { tag, val? } value, got ${describe(v)}`, + `${o.where}: expected a { kind, value? } value, got ${describe(v)}`, ); } - const rec = v as { tag: unknown; val?: unknown }; - if (typeof rec.tag !== "string") { - throw new TypeError(`${o.where}: 'tag' must be a string`); + const rec = v as { kind: unknown; value?: unknown }; + if (typeof rec.kind !== "string") { + throw new TypeError(`${o.where}: 'kind' must be a string`); } - return { tag: rec.tag, val: rec.val, has: "val" in rec }; + return { kind: rec.kind, value: rec.value, has: "value" in rec }; } function int( diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 55e5bfc..fb86d0d 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -1739,7 +1739,7 @@ export function createLoweredImport(input: { // (contracts/intrinsics.md v0.2 §2: release is owed only on exits // that do NOT poison the caller). Every rejection that reaches // this park is a poisoning trap in the CALLER's own frame: - // branded `WitError`s on fallible imports were already resolved + // branded `ComponentException`s on fallible imports were already resolved // into err-shaped VALUES by the conventions layer // (embedder/instantiate.ts `#wrapImportFn`'s `fail` — they take // the success arm above), every other conventions-layer throw is @@ -1767,7 +1767,7 @@ export function createLoweredImport(input: { // reaches the guest as a rejection of the import's Promise, // which the engine turns back into a wasm trap (empirical // fact (e); `SuspensionPoint` routes a produce-throw through - // exactly that path). Branded `WitError`s never reach the raw + // exactly that path). Branded `ComponentException`s never reach the raw // boundary — the conventions layer resolves them into // err-shaped values one layer up (see the settle-path // enumeration above). diff --git a/runtime/tests/bindgen/generated/async-probe.ts b/runtime/tests/bindgen/generated/async-probe.ts index 9d518ad..ac3058b 100644 --- a/runtime/tests/bindgen/generated/async-probe.ts +++ b/runtime/tests/bindgen/generated/async-probe.ts @@ -10,13 +10,13 @@ import type { StreamSource, FutureSource, ErrorContext, - WitError, + ComponentException, Trap, EmbedderInstance, } from "../../../src/embedder/mod.ts"; // deno-lint-ignore no-unused-vars -type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap]; +type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap]; /** Canonical structural digest (docs/architecture.md §9). */ export const WORLD_DIGEST = "sha256:3d7b4e1c6a8e82f5cda2650c07eed5eb1e8d139d818afcf670a2acd942bf85e1"; diff --git a/runtime/tests/bindgen/generated/future-user.ts b/runtime/tests/bindgen/generated/future-user.ts index ab97673..84c3d65 100644 --- a/runtime/tests/bindgen/generated/future-user.ts +++ b/runtime/tests/bindgen/generated/future-user.ts @@ -10,13 +10,13 @@ import type { StreamSource, FutureSource, ErrorContext, - WitError, + ComponentException, Trap, EmbedderInstance, } from "../../../src/embedder/mod.ts"; // deno-lint-ignore no-unused-vars -type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap]; +type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap]; /** Canonical structural digest (docs/architecture.md §9). */ export const WORLD_DIGEST = "sha256:35ccba94425aa7b8b0093f8c7629908955b5efa06bfd843fc0a6233345d8e4a3"; diff --git a/runtime/tests/bindgen/generated/hello.ts b/runtime/tests/bindgen/generated/hello.ts index 0681487..648e64b 100644 --- a/runtime/tests/bindgen/generated/hello.ts +++ b/runtime/tests/bindgen/generated/hello.ts @@ -10,13 +10,13 @@ import type { StreamSource, FutureSource, ErrorContext, - WitError, + ComponentException, Trap, EmbedderInstance, } from "../../../src/embedder/mod.ts"; // deno-lint-ignore no-unused-vars -type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap]; +type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap]; /** Canonical structural digest (docs/architecture.md §9). */ export const WORLD_DIGEST = "sha256:04ae5eb2633ff22f5af8c5e9234c18d089e80a99e04b0946929f0a2e3f5ad7c9"; diff --git a/runtime/tests/bindgen/generated/resources.ts b/runtime/tests/bindgen/generated/resources.ts index 8d85ce7..e80a0c7 100644 --- a/runtime/tests/bindgen/generated/resources.ts +++ b/runtime/tests/bindgen/generated/resources.ts @@ -10,13 +10,13 @@ import type { StreamSource, FutureSource, ErrorContext, - WitError, + ComponentException, Trap, EmbedderInstance, } from "../../../src/embedder/mod.ts"; // deno-lint-ignore no-unused-vars -type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap]; +type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap]; /** Canonical structural digest (docs/architecture.md §9). */ export const WORLD_DIGEST = "sha256:c2444a79c689cb3d11a78206e1dfa9df58d06723535738941a65c3aaa67c994d"; diff --git a/runtime/tests/bindgen/generated/stream-echo.ts b/runtime/tests/bindgen/generated/stream-echo.ts index ebc04ae..9d490fb 100644 --- a/runtime/tests/bindgen/generated/stream-echo.ts +++ b/runtime/tests/bindgen/generated/stream-echo.ts @@ -10,13 +10,13 @@ import type { StreamSource, FutureSource, ErrorContext, - WitError, + ComponentException, Trap, EmbedderInstance, } from "../../../src/embedder/mod.ts"; // deno-lint-ignore no-unused-vars -type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap]; +type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap]; /** Canonical structural digest (docs/architecture.md §9). */ export const WORLD_DIGEST = "sha256:3e92c7647b980356b1d27da9f75fa212a60da6976f99941ffe1ecf3bd9e400bd"; diff --git a/runtime/tests/bindgen/generated/values.ts b/runtime/tests/bindgen/generated/values.ts index bd5bac7..69fb9af 100644 --- a/runtime/tests/bindgen/generated/values.ts +++ b/runtime/tests/bindgen/generated/values.ts @@ -10,13 +10,13 @@ import type { StreamSource, FutureSource, ErrorContext, - WitError, + ComponentException, Trap, EmbedderInstance, } from "../../../src/embedder/mod.ts"; // deno-lint-ignore no-unused-vars -type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, WitError, Trap]; +type _EnsureEmbedderTypesUsed = [Stream, Future, StreamSource, FutureSource, ErrorContext, ComponentException, Trap]; /** Canonical structural digest (docs/architecture.md §9). */ export const WORLD_DIGEST = "sha256:e0791536cb4b9731057b82831150611eed64f22d665130a02f247d3227e2e4a7"; @@ -41,10 +41,10 @@ export interface Size { } export type Shape= -| { tag: "point" } -| { tag: "circle"; val: number } -| { tag: "label"; val: string } -| { tag: "rect"; val: Size }; +| { kind: "point" } +| { kind: "circle"; value: number } +| { kind: "label"; value: string } +| { kind: "rect"; value: Size }; export type Color= | "red" @@ -71,9 +71,9 @@ export interface ValuesExports { echoEnum(v: Color): Promise; echoFlags(v: Perms): Promise; echoOption(v: (string | undefined)): Promise<(string | undefined)>; - echoOptionNested(v: (({ tag: "some"; val: number } | { tag: "none" }) | undefined)): Promise<(({ tag: "some"; val: number } | { tag: "none" }) | undefined)>; - /** @throws {WitError} */ - echoResult(v: ({ tag: "ok"; val: number } | { tag: "err"; val: string })): Promise; + echoOptionNested(v: (({ kind: "some"; value: number } | { kind: "none" }) | undefined)): Promise<(({ kind: "some"; value: number } | { kind: "none" }) | undefined)>; + /** @throws {ComponentException} */ + echoResult(v: ({ kind: "ok"; value: number } | { kind: "err"; value: string })): Promise; echoListU8(v: Uint8Array): Promise; echoListString(v: (string)[]): Promise<(string)[]>; echoTuple(v: [number, string, number]): Promise<[number, string, number]>; diff --git a/runtime/tests/bindgen/usage/type_assert.ts b/runtime/tests/bindgen/usage/type_assert.ts index dbd012c..c25b8c9 100644 --- a/runtime/tests/bindgen/usage/type_assert.ts +++ b/runtime/tests/bindgen/usage/type_assert.ts @@ -1,7 +1,7 @@ // Tiny type-level assertion helpers shared by the bindgen usage samples. // These have no runtime behavior — they exist purely to pin generated-type // shapes at `deno check` time (track C2-B gate: "type-level assertions ... -// pinning tag/val union member shapes incl. absent-val, nested option +// pinning kind/value union member shapes incl. absent-value, nested option // boxing, tuple-as-tuple, camelCase fields, bigint positions, Uint8Array, // Promise-shaped exports, ..."). diff --git a/runtime/tests/bindgen/usage/values_usage.ts b/runtime/tests/bindgen/usage/values_usage.ts index 2d28130..327d984 100644 --- a/runtime/tests/bindgen/usage/values_usage.ts +++ b/runtime/tests/bindgen/usage/values_usage.ts @@ -12,7 +12,7 @@ import type { Shape, ValuesExports, } from "../generated/values.ts"; -import type { EmbedderInstance, WitError } from "../../../src/embedder/mod.ts"; +import type { EmbedderInstance, ComponentException } from "../../../src/embedder/mod.ts"; import type { Equal, Expect } from "./type_assert.ts"; // --- record: camelCase fields ------------------------------------------- @@ -20,30 +20,30 @@ type _MixedFields = Expect< Equal >; -// --- variant: `{tag}` | `{tag,val}`, val ABSENT (not undefined) for the +// --- variant: `{kind}` | `{kind,value}`, value ABSENT (not undefined) for the // payloadless case --------------------------------------------------------- -type _ShapeIsTagVal = Expect< +type _ShapeIsKindValue = Expect< Equal< Shape, - | { tag: "point" } - | { tag: "circle"; val: number } - | { tag: "label"; val: string } - | { tag: "rect"; val: Size_ } + | { kind: "point" } + | { kind: "circle"; value: number } + | { kind: "label"; value: string } + | { kind: "rect"; value: Size_ } > >; interface Size_ { w: number; h: number; } -// Payloadless case really has no `val` key at all (not `val: undefined`): -// a value assignable to the point case must not require providing `val`, -// and `val` must not be a legal key on it. -const point: Shape = { tag: "point" }; -// @ts-expect-error val is not a valid property on the payloadless case -const _pointWithVal: Shape = { tag: "point", val: 1 }; +// Payloadless case really has no `value` key at all (not `value: undefined`): +// a value assignable to the point case must not require providing `value`, +// and `value` must not be a legal key on it. +const point: Shape = { kind: "point" }; +// @ts-expect-error value is not a valid property on the payloadless case +const _pointWithValue: Shape = { kind: "point", value: 1 }; void point; -// --- enum: kebab string literal union, NOT `{tag}` objects -------------- +// --- enum: kebab string literal union, NOT `{kind}` objects -------------- type _ColorIsStringUnion = Expect>; const _colorLiteral: Color = "red"; @@ -94,34 +94,34 @@ export function useValues(instance: EmbedderInstance) { >; // option>: outer option -> T | undefined; the option nested - // directly inside another option boxes into the {tag:"some"|"none"} + // directly inside another option boxes into the {kind:"some"|"none"} // variant family instead of a second `undefined` — this is the // values-fixture Some(None) edge the contract calls out by name. type _EchoOptionNested = Expect< Equal< ValuesExports["echoOptionNested"], ( - v: { tag: "some"; val: number } | { tag: "none" } | undefined, - ) => Promise<{ tag: "some"; val: number } | { tag: "none" } | undefined> + v: { kind: "some"; value: number } | { kind: "none" } | undefined, + ) => Promise<{ kind: "some"; value: number } | { kind: "none" } | undefined> > >; const none: ReturnType extends Promise ? T : never = undefined; // none(): bare undefined - const someNone: { tag: "none" } = { tag: "none" }; // some(none) - const someSome: { tag: "some"; val: number } = { tag: "some", val: 7 }; // some(some(7)) + const someNone: { kind: "none" } = { kind: "none" }; // some(none) + const someSome: { kind: "some"; value: number } = { kind: "some", value: 7 }; // some(some(7)) void none; void someNone; void someSome; // result AS A FUNCTION RESULT: resolves to the ok payload, - // `@throws {WitError}` documents the err channel (never part of + // `@throws {ComponentException}` documents the err channel (never part of // the resolved value) — contracts/embedder-api.md §"Error model". As a // *parameter*, `result` is not in return position, so it keeps the plain - // `{tag,val}` value shape (same family as `variant`). - type _EchoResultParamIsTagVal = Expect< + // `{kind,value}` value shape (same family as `variant`). + type _EchoResultParamIsKindValue = Expect< Equal< Parameters[0], - { tag: "ok"; val: number } | { tag: "err"; val: string } + { kind: "ok"; value: number } | { kind: "err"; value: string } > >; type _EchoResultReturnsOkOnly = Expect< @@ -130,11 +130,11 @@ export function useValues(instance: EmbedderInstance) { async function callEchoResult(): Promise { try { - return await exports.echoResult({ tag: "ok", val: 1 }); + return await exports.echoResult({ kind: "ok", value: 1 }); } catch (e) { - // Branded per the error model: an err value crosses only as WitError. - const witErr = e as WitError; - return witErr.payload.length; + // Branded per the error model: an err value crosses only as ComponentException. + const componentErr = e as ComponentException; + return componentErr.payload.length; } } void callEchoResult; diff --git a/runtime/tests/embedder/host-result-payload.wat b/runtime/tests/embedder/host-result-payload.wat index 8f75f9b..8fa2ecb 100644 --- a/runtime/tests/embedder/host-result-payload.wat +++ b/runtime/tests/embedder/host-result-payload.wat @@ -2,7 +2,7 @@ ;; ;; The sibling `host-result.wat` pins the *branding* with `result` (both sides ;; empty), whose flat lowering is a single i32 and needs no memory. This one -;; pins the **payload lowering** — `WitError.payload` -> the guest's err case — +;; pins the **payload lowering** — `ComponentException.payload` -> the guest's err case — ;; with `result`: three flat values, so the lowered import spills ;; through a return pointer, and the string rides the guest's realloc. ;; diff --git a/runtime/tests/embedder/host-result.wat b/runtime/tests/embedder/host-result.wat index 4d725dd..eaf3413 100644 --- a/runtime/tests/embedder/host-result.wat +++ b/runtime/tests/embedder/host-result.wat @@ -2,7 +2,7 @@ ;; (contracts/embedder-api.md §"Error model", C2 checklist item 5). ;; ;; The corpus has no component that imports a *fallible* host function, so the -;; branded-throw round trip — `throw new WitError(payload)` becoming the guest's +;; branded-throw round trip — `throw new ComponentException(payload)` becoming the guest's ;; `err` case, and an unbranded throw becoming a trap — has nothing to run ;; against. This is the smallest component that does. ;; diff --git a/runtime/tests/embedder/host_imports_test.ts b/runtime/tests/embedder/host_imports_test.ts index 3870203..70162c1 100644 --- a/runtime/tests/embedder/host_imports_test.ts +++ b/runtime/tests/embedder/host_imports_test.ts @@ -9,7 +9,7 @@ import { assertEq } from "../support/asserts.ts"; import { caught, haveFixture, instantiateFixture, testdata } from "./support.ts"; -import { Trap, WitError } from "../../src/embedder/mod.ts"; +import { Trap, ComponentException } from "../../src/embedder/mod.ts"; import { INTERNAL_HOST_REGISTRIES } from "../../src/embedder/instantiate.ts"; const ready = await haveFixture(testdata("imports")); @@ -167,12 +167,12 @@ Deno.test({ }); Deno.test({ - name: "error model: throw new WitError(payload) is the err side", + name: "error model: throw new ComponentException(payload) is the err side", ignore: !errReady, fn: async () => { // The branded throw — and the ONLY thing that crosses as an err value. const c = await hostResult(() => { - throw new WitError(undefined); + throw new ComponentException(undefined); }); assertEq(await c.exports.run(), 1, "1 == the guest observed err"); }, @@ -196,7 +196,7 @@ Deno.test({ `the trap must name the import leaf: ${e}`, ); assertEq(String(e).includes("TypeError"), true, `${e}`); - assertEq(String(e).includes("WitError"), true, "…and say how to signal err"); + assertEq(String(e).includes("ComponentException"), true, "…and say how to signal err"); }, }); @@ -228,7 +228,7 @@ Deno.test({ // is NOT: it must not resolve as `ok`, and it must not be mis-branded as // an unbranded-throw host bug, because the host did neither. const c = await hostResult(() => - Promise.reject(new WitError(undefined)) as unknown as void + Promise.reject(new ComponentException(undefined)) as unknown as void ); const e = await caught(() => c.exports.run()); assertEq(e !== undefined, true, "it must not resolve as ok"); @@ -279,15 +279,15 @@ Deno.test({ }); Deno.test({ - name: "error model: WitError's PAYLOAD reaches the guest's err case", + name: "error model: ComponentException's PAYLOAD reaches the guest's err case", ignore: !payloadReady, fn: async () => { - // The whole branded-throw path end to end: `throw new WitError("boom")` + // The whole branded-throw path end to end: `throw new ComponentException("boom")` // -> `{error: "boom"}` -> the err side of `result` -> // the string lowered into the guest through ITS realloc. `1004` is // `1000 + "boom".length`, so it pins the case AND the payload. const c = await payloadFixture(() => { - throw new WitError("boom"); + throw new ComponentException("boom"); }); assertEq(await c.exports.run(), 1004, "err case + 4-byte payload"); }, @@ -299,7 +299,7 @@ Deno.test({ fn: async () => { const msg = "connection refused by the host"; const c = await payloadFixture(() => { - throw new WitError(msg); + throw new ComponentException(msg); }); assertEq(await c.exports.run(), 1000 + msg.length); }, diff --git a/runtime/tests/embedder/suspending_imports_test.ts b/runtime/tests/embedder/suspending_imports_test.ts index e6b53b2..c114015 100644 --- a/runtime/tests/embedder/suspending_imports_test.ts +++ b/runtime/tests/embedder/suspending_imports_test.ts @@ -149,7 +149,7 @@ Deno.test({ fn: async () => { // A rejection at resume time routes through the suspension point's fail // path: the engine unwinds the parked frame (empirical fact (e): a - // post-resume trap is an ordinary rejection). Branded WitErrors never + // post-resume trap is an ordinary rejection). Branded ComponentExceptions never // reach this layer raw — this is the unbranded-failure path. const c = await instantiateFixture(testdata("imports"), { log: () => {}, @@ -176,23 +176,23 @@ const fallibleReady = isSupported(); Deno.test({ - name: "suspending(): a WitError rejection over a park becomes the guest's err case, not a trap", + name: "suspending(): a ComponentException rejection over a park becomes the guest's err case, not a trap", ignore: !fallibleReady, fn: async () => { // The branded-throw contract survives the suspension: #wrapImportFn // chains the marked import's Promise through its ok/fail adapters, so a - // WitError REJECTION settles the boundary promise with the err-shaped + // ComponentException REJECTION settles the boundary promise with the err-shaped // value — the parked frame resumes into `result::err` (run() == 1), and // nothing traps. The sync-throw variant of this pin lives in // host_imports_test.ts; this is the same rail at resume time. - const { WitError } = await import("../../src/embedder/mod.ts"); + const { ComponentException } = await import("../../src/embedder/mod.ts"); const c = await instantiateFixture( "runtime/tests/embedder/host-result.wasm", { "host:api/fallible": { check: suspending(() => new Promise((_r, reject) => - setTimeout(() => reject(new WitError(undefined)), 0) + setTimeout(() => reject(new ComponentException(undefined)), 0) ) ), }, diff --git a/runtime/tests/embedder/values_test.ts b/runtime/tests/embedder/values_test.ts index a3daabb..d0be64d 100644 --- a/runtime/tests/embedder/values_test.ts +++ b/runtime/tests/embedder/values_test.ts @@ -8,7 +8,7 @@ import { assertEq } from "../support/asserts.ts"; import { caught, guest, haveFixture, instantiateFixture } from "./support.ts"; -import { WitError } from "../../src/embedder/mod.ts"; +import { ComponentException } from "../../src/embedder/mod.ts"; const ready = await haveFixture(guest("values")); @@ -77,28 +77,28 @@ Deno.test({ }); Deno.test({ - name: "values: variants are { tag, val? }, payloadless cases omit val", + name: "values: variants are { kind, value? }, payloadless cases omit value", ignore: !ready, fn: async () => { // `variant shape { point, circle(f64), label(string), rect(size) }`. - const point = await v.echoVariant({ tag: "point" }); - assertEq(point, { tag: "point" }); - assertEq("val" in point, false, "`val` is ABSENT, not undefined"); - assertEq(await v.echoVariant({ tag: "circle", val: 1.5 }), { - tag: "circle", - val: 1.5, + const point = await v.echoVariant({ kind: "point" }); + assertEq(point, { kind: "point" }); + assertEq("value" in point, false, "`value` is ABSENT, not undefined"); + assertEq(await v.echoVariant({ kind: "circle", value: 1.5 }), { + kind: "circle", + value: 1.5, }); - assertEq(await v.echoVariant({ tag: "label", val: "x" }), { - tag: "label", - val: "x", + assertEq(await v.echoVariant({ kind: "label", value: "x" }), { + kind: "label", + value: "x", }); - assertEq(await v.echoVariant({ tag: "rect", val: { w: 3, h: 4 } }), { - tag: "rect", - val: { w: 3, h: 4 }, + assertEq(await v.echoVariant({ kind: "rect", value: { w: 3, h: 4 } }), { + kind: "rect", + value: { w: 3, h: 4 }, }); // Case names are DATA: kebab-case verbatim, never camelCased. assertEq( - String(await caught(() => v.echoVariant({ tag: "nope" }))).includes( + String(await caught(() => v.echoVariant({ kind: "nope" }))).includes( "unknown variant case", ), true, @@ -148,32 +148,32 @@ Deno.test({ fn: async () => { // contracts/embedder-api.md §"Option rule", the Some(None) edge: // undefined -> none - // { tag: "none" } -> some(none) - // { tag: "some", val: 7 } -> some(some(7)) + // { kind: "none" } -> some(none) + // { kind: "some", value: 7 } -> some(some(7)) assertEq(await v.echoOptionNested(undefined), undefined); - const someNone = await v.echoOptionNested({ tag: "none" }); - assertEq(someNone, { tag: "none" }); - assertEq("val" in someNone, false); - assertEq(await v.echoOptionNested({ tag: "some", val: 7 }), { - tag: "some", - val: 7, + const someNone = await v.echoOptionNested({ kind: "none" }); + assertEq(someNone, { kind: "none" }); + assertEq("value" in someNone, false); + assertEq(await v.echoOptionNested({ kind: "some", value: 7 }), { + kind: "some", + value: 7, }); }, }); Deno.test({ - name: "values: result in FUNCTION-RESULT position resolves T / rejects WitError", + name: "values: result in FUNCTION-RESULT position resolves T / rejects ComponentException", ignore: !ready, fn: async () => { // `echo-result: func(v: result) -> result`: the - // parameter is a result nested as a VALUE ({tag,val} data, never throws), - // the return is a result in function-result position (T or WitError). - assertEq(await v.echoResult({ tag: "ok", val: 42 }), 42); - - const e = await caught(() => v.echoResult({ tag: "err", val: "boom" })); - assertEq(e instanceof WitError, true, `expected WitError, got ${e}`); - assertEq((e as WitError).payload, "boom"); - assertEq((e as WitError).name, "WitError"); + // parameter is a result nested as a VALUE ({kind,value} data, never throws), + // the return is a result in function-result position (T or ComponentException). + assertEq(await v.echoResult({ kind: "ok", value: 42 }), 42); + + const e = await caught(() => v.echoResult({ kind: "err", value: "boom" })); + assertEq(e instanceof ComponentException, true, `expected ComponentException, got ${e}`); + assertEq((e as ComponentException).payload, "boom"); + assertEq((e as ComponentException).name, "ComponentException"); }, }); @@ -222,15 +222,15 @@ Deno.test({ ignore: !ready, fn: async () => { // Symmetric with the variant path: silently lowering `null` for a missing - // `val` would put a zero where the guest expects data. + // `value` would put a zero where the guest expects data. assertEq( - String(await caught(() => v.echoResult({ tag: "ok" }))) - .includes("needs a 'val'"), + String(await caught(() => v.echoResult({ kind: "ok" }))) + .includes("needs a 'value'"), true, ); assertEq( - String(await caught(() => v.echoResult({ tag: "err" }))) - .includes("needs a 'val'"), + String(await caught(() => v.echoResult({ kind: "err" }))) + .includes("needs a 'value'"), true, ); }, diff --git a/tools/release-bundle/dual_copy_test.ts b/tools/release-bundle/dual_copy_test.ts index a432edc..86e0b61 100644 --- a/tools/release-bundle/dual_copy_test.ts +++ b/tools/release-bundle/dual_copy_test.ts @@ -16,7 +16,7 @@ // pass vacuously. // // What is pinned: the census sees both copies; the STATELESS contract values -// (`WitError`, the `suspending` mark, hand-rolled brands) are honored across +// (`ComponentException`, the `suspending` mark, hand-rolled brands) are honored across // the boundary; the STATEFUL ones (`Stream`) are refused with a named // cross-copy error rather than silently adapted; and an unbranded throw in a // multi-copy graph says so. @@ -29,7 +29,7 @@ import { isSuspending, runtimeCopies, Stream, - WitError, + ComponentException, } from "../../runtime/src/embedder/mod.ts"; import { lowerStreamSource } from "../../runtime/src/embedder/streams.ts"; import { Translator } from "../../runtime/src/shim/mod.ts"; @@ -93,7 +93,7 @@ Deno.test({ const census = copyCensus(); assert(census.startsWith(`${copies.length} deltic copies loaded: `), census); - // ---- 2. copy B's WitError is a WitError to copy A ------------------ + // ---- 2. copy B's ComponentException is a ComponentException to copy A ------------------ const translator = await Translator.create(await Deno.readFile(TRANSLATOR)); const componentBytes = await Deno.readFile(FIXTURE); const { plan, adapters } = translator.translate(componentBytes); @@ -102,16 +102,16 @@ Deno.test({ instantiate(artifacts, { "host:api/fallible": { tryIt } }); assert( - !(new B.WitError("boom") instanceof WitError), + !(new B.ComponentException("boom") instanceof ComponentException), "premise: the two copies' classes are distinct", ); const viaForeignClass = await withImport(() => { - throw new B.WitError("boom"); + throw new B.ComponentException("boom"); }); assertEq( await viaForeignClass.exports.run(), 1004, - "copy B's WitError became the guest's err case with its payload intact", + "copy B's ComponentException became the guest's err case with its payload intact", ); // ---- 3. the suspending mark crosses ------------------------------ @@ -143,7 +143,7 @@ Deno.test({ assert(m.includes("src.readable()"), `names the by-value remedy: ${m}`); for (const u of urls) assert(m.includes(u), `census names ${u}: ${m}`); - // ---- 5. a hand-rolled brand is a WitError to copy A ---------------- + // ---- 5. a hand-rolled brand is a ComponentException to copy A ---------------- // The zero-import host-module path: no deltic import anywhere. const viaHandRolled = await withImport(() => { throw Object.assign(new Error("x"), { @@ -154,7 +154,7 @@ Deno.test({ assertEq( await viaHandRolled.exports.run(), 1000 + "hand-rolled".length, - "a hand-rolled brand IS a WitError (brands are markers, not gatekeepers)", + "a hand-rolled brand IS a ComponentException (brands are markers, not gatekeepers)", ); // ---- 6. an unbranded throw names the multi-copy hypothesis --------- diff --git a/wasi-shims/src/io.ts b/wasi-shims/src/io.ts index 0e9464a..895d4e8 100644 --- a/wasi-shims/src/io.ts +++ b/wasi-shims/src/io.ts @@ -41,7 +41,7 @@ // ride datagrams). import { defineBrand, POLLABLE } from "@deltic/protocol"; -import { suspending, WitError } from "@deltic/runtime/embedder"; +import { suspending, ComponentException } from "@deltic/runtime/embedder"; /** The engine setTimeout ceiling: delays above 2^31-1 ms are clamped to * ~0 (node/Deno warn and fire at 1 ms). `Pollable.timer` sleeps in @@ -50,11 +50,11 @@ const TIMER_CHUNK_MAX_MS = 2 ** 31 - 1; /** A p2 `stream-error` value (variant): `closed` or `last-operation-failed`. */ export type StreamErrorValue = - | { tag: "closed" } - | { tag: "last-operation-failed"; val: IoError }; + | { kind: "closed" } + | { kind: "last-operation-failed"; value: IoError }; -function closedError(): WitError { - return new WitError({ tag: "closed" }); +function closedError(): ComponentException { + return new ComponentException({ kind: "closed" }); } /** diff --git a/wasi-shims/tests/io_test.ts b/wasi-shims/tests/io_test.ts index 3215ba5..03de3a0 100644 --- a/wasi-shims/tests/io_test.ts +++ b/wasi-shims/tests/io_test.ts @@ -1,8 +1,8 @@ -// wasi:io@0.2 — pollable tier (a), stream sink write paths, WitError +// wasi:io@0.2 — pollable tier (a), stream sink write paths, ComponentException // stream-error cases (contracts/embedder-api.md §"WASI examination"). import { assertEq, assertRejects, assertTrue } from "./asserts.ts"; -import { WitError } from "@deltic/runtime/embedder"; +import { ComponentException } from "@deltic/runtime/embedder"; import { InputStream, io, OutputStream, Pollable, poll } from "../src/io.ts"; import type { StreamErrorValue } from "../src/io.ts"; @@ -37,16 +37,16 @@ Deno.test("io: OutputStream.blockingWriteAndFlush degenerates to write (tier b)" assertEq(chunks.length, 1); }); -Deno.test("io: writes after drop throw WitError 'closed'", () => { +Deno.test("io: writes after drop throw ComponentException 'closed'", () => { const out = new OutputStream(() => {}); out[Symbol.dispose](); try { out.write(new Uint8Array([1])); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof WitError, "closed write throws WitError"); - const payload = (e as WitError).payload; - assertEq(payload.tag, "closed"); + assertTrue(e instanceof ComponentException, "closed write throws ComponentException"); + const payload = (e as ComponentException).payload; + assertEq(payload.kind, "closed"); } }); @@ -57,8 +57,8 @@ Deno.test("io: checkWrite after drop also throws the closed stream-error", () => out.checkWrite(); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof WitError); - assertEq((e as WitError).payload.tag, "closed"); + assertTrue(e instanceof ComponentException); + assertEq((e as ComponentException).payload.kind, "closed"); } }); @@ -84,8 +84,8 @@ Deno.test("io: reading a dropped input stream throws closed stream-error", () => s.read(1n); throw new Error("expected a throw"); } catch (e) { - assertTrue(e instanceof WitError); - assertEq((e as WitError).payload.tag, "closed"); + assertTrue(e instanceof ComponentException); + assertEq((e as ComponentException).payload.kind, "closed"); } }); @@ -109,7 +109,7 @@ Deno.test("io() provider fragment exposes error/poll/streams under @0.2 keys", ( }); // Guards the D-2-adjacent claim for streams specifically: no async parking -// anywhere in the tier-(b) synchronous fast path (WitError branded, never a +// anywhere in the tier-(b) synchronous fast path (ComponentException branded, never a // bare throw) — this doubles as the "no unbranded throw" smoke check the // error-model contract requires of every host import in this package. Deno.test("io: a closed-stream failure never leaks an unbranded throw type", async () => { @@ -119,7 +119,7 @@ Deno.test("io: a closed-stream failure never leaks an unbranded throw type", asy const rejected = await assertRejects(async () => { out.write(new Uint8Array([1])); }); - assertTrue(rejected instanceof Error && !(rejected instanceof WitError)); + assertTrue(rejected instanceof Error && !(rejected instanceof ComponentException)); // NOTE: this documents current behavior — a sink that itself throws // propagates its raw Error out of this synchronous host-import function. // Per contracts/embedder-api.md §"Error model", the *embedder facade*