From 10b337cc5fde92711cf1395a406599076878e944 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Wed, 12 Aug 2026 10:59:21 -0400 Subject: [PATCH] deltic: bump pin to 0.1.0-pre.g078aa15; A10 rename (ComponentException, {kind, value}) Coordinated family migration for deltic amendment A10 (breaking): WitError -> ComponentException (isWitError -> isComponentException) and variant-family discriminants {tag, val} -> {kind, value} (payloadless cases still omit the payload property; case names kebab-case verbatim). Pin moves to the first prerelease whose @deltic/protocol dependency (0.2.0) carries the rename. jco-era surfaces keep jco conventions. Gates: js/deltic check+test 12/12; conformance deltic rows 55/55; pin gate green --- AGENTS.md | 4 +- README.md | 2 +- conformance/driver-ct/deltic/README.md | 2 +- .../driver-ct/deltic/browser-bundle-entry.ts | 2 +- .../driver-ct/deltic/browser/worker-entry.ts | 2 +- conformance/driver-ct/deltic/deno.json | 12 +- conformance/driver-ct/deltic/deno.lock | 53 +++--- conformance/driver-ct/deltic/run.ts | 2 +- examples/deltic-demo/run.ts | 8 +- js/componentize/wpt/parity/deltic-carrier.ts | 2 +- js/deltic/README.md | 4 +- js/deltic/deno.json | 4 +- js/deltic/deno.lock | 14 +- js/deltic/tests/websocket_test.ts | 74 ++++---- js/deltic/websocket.ts | 176 +++++++++--------- 15 files changed, 177 insertions(+), 184 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aed500f..aff47ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Layout (each directory's justfile module in parentheses): - `js/deltic/` (`just deltic-module-check`) — `websocket.ts`, THE browser-first JS host module, over [deltic](https://github.com/lann/deltic)'s embedder conventions (typed - streams, `WitError`) for its runtime-linked, no-transpile legs + streams, `ComponentException`) for its runtime-linked, no-transpile legs (`conformance/driver-ct/deltic/`, the WPT parity round trip, the demo). Its knobs are exported functions (`configure`, `setMaxInboundBufferBytes`, `setConnectTimeoutMs`, `setCloseTimeoutMs`); @@ -42,7 +42,7 @@ Layout (each directory's justfile module in parentheses): upstream commit) which must agree on the `@deltic/runtime` version (deltic's `wasi-shims` imports `@deltic/runtime/embedder` by bare specifier internally, so a drift would load the embedder module twice - and break `instanceof WitError` across the boundary); the root + and break `instanceof ComponentException` across the boundary); the root justfile's `exam-deltic` recipe (CI-wired) asserts one version repo-wide. Bump procedure: `conformance/driver-ct/deltic/README.md`. - `js/componentize/` (`just wpt::…`) — `websocket.js`, the WHATWG-API diff --git a/README.md b/README.md index 296d57c..dba236b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ in-guest. | Path | Deliverable | | --- | --- | | [`wit/`](wit) | The `polymorph:websocket` package: a `types` interface for structural types and a `connections` interface owning the `websocket` resource. One copy at the root; consumers pull it in via `wit/deps` symlinks. Package-wide contracts live in [`wit/README.md`](wit/README.md). | -| [`js/deltic`](js/deltic) | The **browser-first JS host module** (`websocket.ts`): the standard `WebSocket` API only, no `node:` modules, no runtime dependencies, over [deltic](https://github.com/lann/deltic)'s embedder conventions (typed streams, `WitError`). Shared verbatim by the demo runner, the conformance JS-host rows, and the WPT parity round trip. | +| [`js/deltic`](js/deltic) | The **browser-first JS host module** (`websocket.ts`): the standard `WebSocket` API only, no `node:` modules, no runtime dependencies, over [deltic](https://github.com/lann/deltic)'s embedder conventions (typed streams, `ComponentException`). Shared verbatim by the demo runner, the conformance JS-host rows, and the WPT parity round trip. | | [`rust/wasmtime`](rust/wasmtime) | The **Wasmtime host crate** (`wasmtime-websocket`): `add_to_linker` + `WasiWebsocketView`, per-store `WasiWebsocketCtx` knobs for the bounds the WIT leaves implementation-defined. | | [`js/componentize`](js/componentize) | The **browser-API shim** for componentize-js guests: the WHATWG `WebSocket` interface implemented over the WIT imports — the inverse of `js/deltic` — plus the **WPT parity gate**: vendored web-platform-tests run round-trip (shim → WIT → deltic → platform WebSocket) and held to the platform baseline's pass set. | | [`rust/guest-provider`](rust/guest-provider) | The **in-guest provider** (`websocket-guest-provider`): a WebSocket client stack over `wasi:sockets` TCP, exporting the package surface as a composable component; `wss:` via the composed [`polymorph:tls`](https://github.com/polymorph-components/polymorph-tls) component. See its README for the TLS posture and configuration channel. | diff --git a/conformance/driver-ct/deltic/README.md b/conformance/driver-ct/deltic/README.md index fe7ffe2..2859008 100644 --- a/conformance/driver-ct/deltic/README.md +++ b/conformance/driver-ct/deltic/README.md @@ -36,7 +36,7 @@ prereleases"). It is pinned in **two** places, both required to agree: SAME `@deltic/runtime` version (the module-identity constraint: deltic's `wasi-shims` imports `@deltic/runtime/embedder` by bare specifier internally, so every config resolving it must agree, or the embedder - module loads twice and `instanceof WitError` stops holding across the + module loads twice and `instanceof ComponentException` stops holding across the boundary). `@deltic/translator` ships the translator wasm asset **for the same diff --git a/conformance/driver-ct/deltic/browser-bundle-entry.ts b/conformance/driver-ct/deltic/browser-bundle-entry.ts index a182f7e..c41bd1c 100644 --- a/conformance/driver-ct/deltic/browser-bundle-entry.ts +++ b/conformance/driver-ct/deltic/browser-bundle-entry.ts @@ -9,7 +9,7 @@ // map — the single deltic pin site), the emitted module is what // browser/worker-entry.ts imports as `@deltic/release-bundle-entry` // used to. Same export surface: instantiate/Translator/runSuite/ -// wasiShims/WitError/artifactsFromEnvelope and friends (49 exports +// wasiShims/ComponentException/artifactsFromEnvelope and friends (49 exports // verified against the retired upstream entry). export * from "@deltic/runtime/embedder"; export { Translator } from "@deltic/runtime/shim"; diff --git a/conformance/driver-ct/deltic/browser/worker-entry.ts b/conformance/driver-ct/deltic/browser/worker-entry.ts index 9c3573e..97f7aa2 100644 --- a/conformance/driver-ct/deltic/browser/worker-entry.ts +++ b/conformance/driver-ct/deltic/browser/worker-entry.ts @@ -3,7 +3,7 @@ // JSR packages' public surface), the upstream worker message loop, and // this repo's deltic host module, resolved through ONE import map so the // emitted bundle carries exactly one embedder module instance — which is -// what keeps `instanceof WitError` true when the host module throws +// what keeps `instanceof ComponentException` true when the host module throws // across the component boundary (workers resolve no import maps, so // bundling is the only sound shape; see @polymorph/component-test-js's // runner-deltic README). diff --git a/conformance/driver-ct/deltic/deno.json b/conformance/driver-ct/deltic/deno.json index 4cfc6be..6a272b1 100644 --- a/conformance/driver-ct/deltic/deno.json +++ b/conformance/driver-ct/deltic/deno.json @@ -1,11 +1,11 @@ { - "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. The @deltic/runtime/embedder entry here must map to the IDENTICAL exact-pinned JSR version as ../../../js/deltic/deno.json's entry, or the embedder module loads twice and `instanceof WitError` stops holding across the module boundary. Pin story: 0.1.0-pre.g50778a1 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", + "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. The @deltic/runtime/embedder entry here must map to the IDENTICAL exact-pinned JSR version as ../../../js/deltic/deno.json's entry, or the embedder module loads twice and `instanceof ComponentException` stops holding across the module boundary. Pin story: 0.1.0-pre.g078aa15 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", "imports": { - "@deltic/ct-runner": "jsr:@deltic/ct-runner@0.1.0-pre.g50778a1", - "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0-pre.g50778a1/embedder", - "@deltic/runtime/shim": "jsr:@deltic/runtime@0.1.0-pre.g50778a1/shim", - "@deltic/wasi-shims": "jsr:@deltic/wasi-shims@0.1.0-pre.g50778a1", - "@deltic/translator": "jsr:@deltic/translator@0.1.0-pre.g50778a1", + "@deltic/ct-runner": "jsr:@deltic/ct-runner@0.1.0-pre.g078aa15", + "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0-pre.g078aa15/embedder", + "@deltic/runtime/shim": "jsr:@deltic/runtime@0.1.0-pre.g078aa15/shim", + "@deltic/wasi-shims": "jsr:@deltic/wasi-shims@0.1.0-pre.g078aa15", + "@deltic/translator": "jsr:@deltic/translator@0.1.0-pre.g078aa15", "@polymorph/component-test-js/deltic-worker-main": "./node_modules/@polymorph/component-test-js/js/runner-deltic/worker-main.mjs" }, "minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@deltic/*"] }, diff --git a/conformance/driver-ct/deltic/deno.lock b/conformance/driver-ct/deltic/deno.lock index b8203fe..116e26b 100644 --- a/conformance/driver-ct/deltic/deno.lock +++ b/conformance/driver-ct/deltic/deno.lock @@ -1,56 +1,49 @@ { "version": "5", "specifiers": { - "jsr:@deltic/ct-runner@0.1.0-pre.g50778a1": "0.1.0-pre.g50778a1", - "jsr:@deltic/protocol@0.1": "0.1.0", - "jsr:@deltic/runtime@0.1.0-pre.g50778a1": "0.1.0-pre.g50778a1", - "jsr:@deltic/runtime@~0.1.0-pre.g50778a1": "0.1.0-pre.g50778a1", - "jsr:@deltic/translator@0.1.0-pre.g50778a1": "0.1.0-pre.g50778a1", - "jsr:@deltic/wasi-shims@0.1.0-pre.g50778a1": "0.1.0-pre.g50778a1", - "npm:playwright-core@^1.61.1": "1.62.1" + "jsr:@deltic/ct-runner@0.1.0-pre.g078aa15": "0.1.0-pre.g078aa15", + "jsr:@deltic/protocol@0.2": "0.2.0", + "jsr:@deltic/runtime@0.1.0-pre.g078aa15": "0.1.0-pre.g078aa15", + "jsr:@deltic/runtime@~0.1.0-pre.g078aa15": "0.1.0-pre.g078aa15", + "jsr:@deltic/translator@0.1.0-pre.g078aa15": "0.1.0-pre.g078aa15", + "jsr:@deltic/wasi-shims@0.1.0-pre.g078aa15": "0.1.0-pre.g078aa15" }, "jsr": { - "@deltic/ct-runner@0.1.0-pre.g50778a1": { - "integrity": "312e40aebc95411d1dbf12f74f5465c621007e1bb38815e9be56933b58a2fdcd", + "@deltic/ct-runner@0.1.0-pre.g078aa15": { + "integrity": "d777a53e6be337d999a80de030628e1226ae7649ca62e49dd444e58d0973e6be", "dependencies": [ - "jsr:@deltic/runtime@~0.1.0-pre.g50778a1" + "jsr:@deltic/runtime@~0.1.0-pre.g078aa15" ] }, - "@deltic/protocol@0.1.0": { - "integrity": "c9d0adff11eac77ce56a28db163dc22bc74427864e0034d2b9d381daea144a1b" + "@deltic/protocol@0.2.0": { + "integrity": "028be6a3623c5e910598aa7a199209b85e8931ae484ac7f6638d610ddb0e19fa" }, - "@deltic/runtime@0.1.0-pre.g50778a1": { - "integrity": "e2e17d3be84cadadfcf5de3284f23c06172e1ed6d5c38dfb7bdcdb878050e10e", + "@deltic/runtime@0.1.0-pre.g078aa15": { + "integrity": "5e64f8dc6d32190ecd394d6ad9881eb0fa97b6598b1e7f2ef1d52cff4674287c", "dependencies": [ "jsr:@deltic/protocol" ] }, - "@deltic/translator@0.1.0-pre.g50778a1": { - "integrity": "4d95ca60719300b482cf90c147a8b33cf92550d408bf0a5718fbd6483887510a", + "@deltic/translator@0.1.0-pre.g078aa15": { + "integrity": "7125f99ac46af4a69f1c432def2b7ea4be63509c3a7ebae83537357615ec33ef", "dependencies": [ - "jsr:@deltic/runtime@~0.1.0-pre.g50778a1" + "jsr:@deltic/runtime@~0.1.0-pre.g078aa15" ] }, - "@deltic/wasi-shims@0.1.0-pre.g50778a1": { - "integrity": "e1a3fe2da0ba7d9c4c632e905c9dbdcd50c1a3da9eaadaecf86d54add39d0446", + "@deltic/wasi-shims@0.1.0-pre.g078aa15": { + "integrity": "fba2081f804eaaccf95e31cdd634fd85a2d724f6be7f62578ddc26aef03524a1", "dependencies": [ "jsr:@deltic/protocol", - "jsr:@deltic/runtime@~0.1.0-pre.g50778a1" + "jsr:@deltic/runtime@~0.1.0-pre.g078aa15" ] } }, - "npm": { - "playwright-core@1.62.1": { - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "bin": true - } - }, "workspace": { "dependencies": [ - "jsr:@deltic/ct-runner@0.1.0-pre.g50778a1", - "jsr:@deltic/runtime@0.1.0-pre.g50778a1", - "jsr:@deltic/translator@0.1.0-pre.g50778a1", - "jsr:@deltic/wasi-shims@0.1.0-pre.g50778a1" + "jsr:@deltic/ct-runner@0.1.0-pre.g078aa15", + "jsr:@deltic/runtime@0.1.0-pre.g078aa15", + "jsr:@deltic/translator@0.1.0-pre.g078aa15", + "jsr:@deltic/wasi-shims@0.1.0-pre.g078aa15" ], "packageJson": { "dependencies": [ diff --git a/conformance/driver-ct/deltic/run.ts b/conformance/driver-ct/deltic/run.ts index c4e7009..f42539c 100644 --- a/conformance/driver-ct/deltic/run.ts +++ b/conformance/driver-ct/deltic/run.ts @@ -31,7 +31,7 @@ // `@deltic/runtime/embedder` by bare specifier internally; this leg's // `deno.json` AND `js/deltic/deno.json` must map that specifier to the // IDENTICAL exact-pinned JSR version, or the embedder module loads twice -// and `instanceof WitError` stops holding across the module boundary. +// and `instanceof ComponentException` stops holding across the module boundary. // // The translator comes from the packaged `@deltic/translator` JSR // prerelease (defaultTranslator()) by default — no fetch step, no diff --git a/examples/deltic-demo/run.ts b/examples/deltic-demo/run.ts index 7519c38..82fc8a4 100644 --- a/examples/deltic-demo/run.ts +++ b/examples/deltic-demo/run.ts @@ -17,12 +17,12 @@ // packaged `@deltic/translator` JSR prerelease by default (no fetch // step); `--translator ` remains as an optional override for a // locally-built translator shim. The same import map is what keeps -// `instanceof WitError` holding across the host-module boundary (see +// `instanceof ComponentException` holding across the host-module boundary (see // that deno.json's MODULE-IDENTITY note). import { Translator } from "@deltic/runtime/shim"; import type { ComponentArtifacts } from "@deltic/runtime/embedder"; -import { instantiate, WitError } from "@deltic/runtime/embedder"; +import { ComponentException, instantiate } from "@deltic/runtime/embedder"; import { defaultTranslator } from "@deltic/translator"; import { wasiShims } from "@deltic/wasi-shims"; import { websocketImports } from "../../js/deltic/websocket.ts"; @@ -121,12 +121,12 @@ async function main() { }; // The export's `result` lifts to return-or-throw in // return position (contracts/embedder-api.md §"Value mapping"): the - // err payload rides a branded `WitError`. + // err payload rides a branded `ComponentException`. try { const received = await demo.run(`${echod.base}/echo`, cli.count); console.log(`round-tripped ${received}/${cli.count} messages`); } catch (err) { - const detail = err instanceof WitError ? err.payload : err; + const detail = err instanceof ComponentException ? err.payload : err; console.error(`demo failed: ${detail}`); Deno.exitCode = 1; } diff --git a/js/componentize/wpt/parity/deltic-carrier.ts b/js/componentize/wpt/parity/deltic-carrier.ts index c330915..c8cbe09 100644 --- a/js/componentize/wpt/parity/deltic-carrier.ts +++ b/js/componentize/wpt/parity/deltic-carrier.ts @@ -25,7 +25,7 @@ // JSR package's lock-pinned module cache (no fetch step). Bundling from // there is also what keeps `@deltic/runtime/embedder` a single module // instance across this bundle and js/deltic/websocket.ts, so -// `instanceof WitError` holds. +// `instanceof ComponentException` holds. import { Translator } from "@deltic/runtime/shim"; import { instantiate } from "@deltic/runtime/embedder"; diff --git a/js/deltic/README.md b/js/deltic/README.md index 4524197..dc3fc39 100644 --- a/js/deltic/README.md +++ b/js/deltic/README.md @@ -7,7 +7,7 @@ Chromium). It began as the [deltic](https://github.com/lann/deltic)-native port of the browser-first reference host `js/jco/websocket.js` (retired with the jco legs at commit c9252be; see git history), rewritten over deltic's embedder API (typed `Stream` / `ReadableStream` rather than -bare-payload `Stream`, and `WitError` throws rather than +bare-payload `Stream`, and `ComponentException` throws rather than `throw { tag, val }`). It was developed as deltic's own `ports/websocket` reference-host port and is upstreamed here per [lann/deltic#14](https://github.com/lann/deltic/issues/14); the WIT @@ -36,7 +36,7 @@ identically on Deno. pinned URL as `conformance/driver-ct/deltic/deno.json`. deltic's `wasi-shims` module imports that specifier by bare name internally; if the two configs ever disagreed, the embedder module would load twice and -`instanceof WitError` would stop holding across the boundary. Keep both +`instanceof ComponentException` would stop holding across the boundary. Keep both import maps byte-identical for that one entry. ## Unit tests diff --git a/js/deltic/deno.json b/js/deltic/deno.json index 136793a..464d3c1 100644 --- a/js/deltic/deno.json +++ b/js/deltic/deno.json @@ -2,9 +2,9 @@ "name": "@polymorph/websocket-deltic", "version": "0.0.0", "exports": "./websocket.ts", - "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. Every config in this repo (this file AND conformance/driver-ct/deltic/deno.json) must map @deltic/runtime to the IDENTICAL exact-pinned JSR version, or the embedder module loads twice and `instanceof WitError` stops holding across the module boundary. Pin story: 0.1.0-pre.g50778a1 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", + "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi-shims module imports @deltic/runtime/embedder by bare specifier internally. Every config in this repo (this file AND conformance/driver-ct/deltic/deno.json) must map @deltic/runtime to the IDENTICAL exact-pinned JSR version, or the embedder module loads twice and `instanceof ComponentException` stops holding across the module boundary. Pin story: 0.1.0-pre.g078aa15 names one upstream deltic commit; @deltic/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; one version repo-wide is asserted by the pin gate (justfile: exam-deltic).", "imports": { - "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0-pre.g50778a1/embedder" + "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0-pre.g078aa15/embedder" }, "minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@deltic/*"] }, "compilerOptions": { diff --git a/js/deltic/deno.lock b/js/deltic/deno.lock index 5487c06..c55cd8a 100644 --- a/js/deltic/deno.lock +++ b/js/deltic/deno.lock @@ -1,17 +1,17 @@ { "version": "5", "specifiers": { - "jsr:@deltic/protocol@0.1": "0.1.0", - "jsr:@deltic/runtime@0.1.0-pre.g50778a1": "0.1.0-pre.g50778a1", + "jsr:@deltic/protocol@0.2": "0.2.0", + "jsr:@deltic/runtime@0.1.0-pre.g078aa15": "0.1.0-pre.g078aa15", "jsr:@std/assert@1": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14" }, "jsr": { - "@deltic/protocol@0.1.0": { - "integrity": "c9d0adff11eac77ce56a28db163dc22bc74427864e0034d2b9d381daea144a1b" + "@deltic/protocol@0.2.0": { + "integrity": "028be6a3623c5e910598aa7a199209b85e8931ae484ac7f6638d610ddb0e19fa" }, - "@deltic/runtime@0.1.0-pre.g50778a1": { - "integrity": "e2e17d3be84cadadfcf5de3284f23c06172e1ed6d5c38dfb7bdcdb878050e10e", + "@deltic/runtime@0.1.0-pre.g078aa15": { + "integrity": "5e64f8dc6d32190ecd394d6ad9881eb0fa97b6598b1e7f2ef1d52cff4674287c", "dependencies": [ "jsr:@deltic/protocol" ] @@ -28,7 +28,7 @@ }, "workspace": { "dependencies": [ - "jsr:@deltic/runtime@0.1.0-pre.g50778a1" + "jsr:@deltic/runtime@0.1.0-pre.g078aa15" ] } } diff --git a/js/deltic/tests/websocket_test.ts b/js/deltic/tests/websocket_test.ts index 55f4ae4..4d36b17 100644 --- a/js/deltic/tests/websocket_test.ts +++ b/js/deltic/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 "@deltic/runtime/embedder"; +import { ComponentException } from "@deltic/runtime/embedder"; import { currentConfig, resetConfig, @@ -21,19 +21,19 @@ import { } from "../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 assertComponentKind(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 assertRejectsComponentKind( 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 assertRejectsComponentKind( () => 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 assertRejectsComponentKind( () => 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 assertRejectsComponentKind(() => 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"); + assertComponentKind(() => 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"); + assertComponentKind(() => ws.close(undefined, "reason"), "invalid-argument"); + assertComponentKind(() => 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"); + assertComponentKind(() => 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 assertRejectsComponentKind(() => ws.receive(), "closed"); + await assertRejectsComponentKind( + () => 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 assertRejectsComponentKind(() => pending, "receiving-via-stream"); + assertComponentKind(() => ws.receiveViaStream(), "receiving-via-stream"); + await assertRejectsComponentKind(() => 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 assertRejectsComponentKind(() => 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 assertRejectsComponentKind( () => 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/js/deltic/websocket.ts b/js/deltic/websocket.ts index 66c60e8..7b4250e 100644 --- a/js/deltic/websocket.ts +++ b/js/deltic/websocket.ts @@ -14,7 +14,7 @@ // // the retired jco host | 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,32 +27,32 @@ // as the reference does (websocket.js:57-63). import { + ComponentException, Stream, type StreamSource, - WitError, } from "@deltic/runtime/embedder"; // ----- 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) }`. * * The WIT case name is `%string`; `%` is WIT's identifier escape, not part - * of the name, so the conventions' "kebab-case verbatim" tag is `"string"`. + * of the name, so the conventions' "kebab-case verbatim" kind 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"; @@ -87,10 +87,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 componentError(payload: WebsocketError): ComponentException { + return new ComponentException( payload, - payload.tag + ("val" in payload ? `: ${payload.val}` : ""), + payload.kind + ("value" in payload ? `: ${payload.value}` : ""), ); } @@ -221,30 +221,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 componentError({ 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 componentError({ + 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 componentError({ + 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 componentError({ 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 componentError({ kind: "invalid-url", value: "URL must not have userinfo" }); } } @@ -253,15 +253,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 componentError({ + 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 componentError({ + kind: "invalid-argument", + value: `subprotocol ${JSON.stringify(protocol)} is offered twice`, }); } } @@ -275,22 +275,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 componentError({ + 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 componentError({ + 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 componentError({ + kind: "invalid-argument", + value: `close reason must be at most 123 bytes, got ${bytes}`, }); } } @@ -333,7 +333,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); @@ -345,9 +345,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 componentError({ + kind: "connect-failed", + value: String((err as Error)?.message ?? err), }); } ws.binaryType = "arraybuffer"; @@ -368,9 +368,9 @@ export class Websocket { const ce = event as CloseEvent; settle( reject, - witError({ - tag: "connect-failed", - val: ce.reason || `connection failed (code ${ce.code})`, + componentError({ + kind: "connect-failed", + value: ce.reason || `connection failed (code ${ce.code})`, }), ); }; @@ -384,9 +384,9 @@ export class Websocket { timer = setTimeout(() => { settle( reject, - witError({ - tag: "connect-failed", - val: `handshake timed out after ${connectTimeoutMs}ms`, + componentError({ + kind: "connect-failed", + value: `handshake timed out after ${connectTimeoutMs}ms`, }), ); try { @@ -404,9 +404,9 @@ export class Websocket { try { ws.close(); } catch { /* already closing */ } - throw witError({ - tag: "connect-failed", - val: ws.protocol + throw componentError({ + 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", }); @@ -415,9 +415,9 @@ export class Websocket { try { ws.close(); } catch { /* already closing */ } - throw witError({ - tag: "connect-failed", - val: `server selected subprotocol ${ + throw componentError({ + kind: "connect-failed", + value: `server selected subprotocol ${ JSON.stringify(ws.protocol) } although none was offered`, }); @@ -452,7 +452,7 @@ export class Websocket { async send(message: Message): Promise { for (;;) { if (this.#localClosed || this.#ws.readyState !== WebSocket.OPEN) { - throw witError({ tag: "closed" }); + throw componentError({ kind: "closed" }); } if (this.#ws.bufferedAmount <= MAX_BUFFERED_AMOUNT) break; // No `bufferedamountlow` on WebSocket: poll the drain. @@ -462,9 +462,9 @@ export class Websocket { // `` (the dom-lib `send` signature excludes SAB-backed // views): a guest-lifted `list` is always a fresh copy // (contracts/embedder-api.md §"Value mapping"), never SAB-backed. - 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 componentError({ kind: "other", value: String((err as Error)?.message ?? err) }); } } @@ -473,16 +473,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(componentError({ kind: "closed" })); if (this.#streamClaimed) { - return Promise.reject(witError({ tag: "receiving-via-stream" })); + return Promise.reject(componentError({ 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; @@ -493,9 +493,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 componentError({ + kind: "other", + value: `stream-message payload was ${ bytes.length + excess } bytes but length declared ${item.length}`, }); @@ -508,14 +508,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 componentError({ + 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; @@ -523,10 +523,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)`, ); @@ -544,11 +544,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 componentError({ kind: "closed" }); + if (this.#streamClaimed) throw componentError({ 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; @@ -560,11 +560,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), }); @@ -756,18 +756,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(componentError(endError())); } }; ws.addEventListener("close", end); @@ -781,14 +781,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(componentError({ kind: "receive-buffer-overflow" })); } - if (closed) return Promise.reject(witError({ tag: "closed" })); + if (closed) return Promise.reject(componentError({ 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(componentError(error)); } }, end, @@ -799,7 +799,7 @@ function incomingQueue(ws: WebSocket, onOverflowClose: () => void): IncomingQueu buffered = 0; closed = true; while (waiters.length) { - waiters.shift()!.reject(witError({ tag: "closed" })); + waiters.shift()!.reject(componentError({ kind: "closed" })); } }, };