diff --git a/.github/workflows/jsr-publish.yml b/.github/workflows/jsr-publish.yml new file mode 100644 index 0000000..575a351 --- /dev/null +++ b/.github/workflows/jsr-publish.yml @@ -0,0 +1,38 @@ +# Publish @polymorph/iroh (host-deltic) to JSR: build the endpoint +# component from this tree (against the .deps sibling checkouts +# scripts/setup.sh pins), embed it as the package's base64 asset module, +# and publish. +# +# Auth is GitHub Actions OIDC: the package's GitHub-repository link on +# jsr.io authorizes runs from this repository — no tokens. Dispatch after +# a release whose manifest version is not yet on JSR; JSR rejects +# duplicate versions, so a re-run is a no-op-by-failure. --allow-dirty is +# for the generated (gitignored) asset module only. +name: jsr-publish +on: + workflow_dispatch: +permissions: + contents: read + id-token: write +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - name: Install Rust toolchain + # rust-toolchain.toml pins the version and wasm target. + run: rustup show active-toolchain || rustup toolchain install + - uses: denoland/setup-deno@v2 + with: + deno-version: "2.9.5" + - name: Install dependencies (.deps sibling checkouts) + run: ./scripts/setup.sh + env: + GITHUB_TOKEN: ${{ github.token }} + - name: Build and embed the endpoint component + run: | + cargo build -p iroh-endpoint --target wasm32-wasip2 --release + deno run --allow-read=target --allow-write=host-deltic/src/endpoint_component.ts scripts/embed-endpoint-component.ts + - run: deno publish --allow-dirty + working-directory: host-deltic diff --git a/.gitignore b/.gitignore index 4e4ae05..81ff505 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules /experiments/iroh-relay-ws/host/dist /experiments/iroh-blobs/host/dist /experiments/ping-demo/web/site +/host-deltic/src/endpoint_component.ts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/host-deltic/README.md b/host-deltic/README.md index 6b69780..2060878 100644 --- a/host-deltic/README.md +++ b/host-deltic/README.md @@ -10,15 +10,16 @@ alongside this exam until it retired in favor of this host (see git history); this is now the repository's only JS host. The sibling repositories' own deltic host modules supply the non-WASI -imports, from the same pinned checkouts `scripts/setup.sh` maintains: +imports, consumed from JSR by caret constraint (the lockfile pins the +resolved versions): | Import | Module | | --- | --- | -| `polymorph:websocket/connections` | `.deps/websocket/js/deltic/websocket.ts` | -| `polymorph:webrtc-datachannels/connections` | `.deps/webrtc/deltic-impl/src/webrtc.ts` | -| `polymorph:webcrypto/*` | `.deps/webcrypto/js/deltic/src/mod.ts` | +| `polymorph:websocket/connections` | `jsr:@polymorph/websocket` | +| `polymorph:webrtc-datachannels/connections` | `jsr:@polymorph/webrtc-datachannels` | +| `polymorph:webcrypto/*` | `jsr:@polymorph/webcrypto` | | `wasi:sockets/types` | `src/sockets.ts` — fail-on-call stubs (the browser profile; see its header) | -| everything WASI | deltic's `wasi-shims` at the pinned release | +| everything WASI | deltic's `@deltic/wasi` package | ## The exam @@ -50,51 +51,40 @@ borrow that crosses the `block_on`(webcrypto sign) yield point, so a parked poller can trap the guest mid-handshake (`RefCell already borrowed`); panic counts are reported per attempt. The hazard is the guest's and is latent on every host. - ## The pin -deltic arrives as exactly-pinned JSR prereleases: -`jsr:@deltic/{runtime,wasi-shims,translator}@0.1.0-pre.g`, -where the short hash names one upstream commit (the same hash as the -corresponding GitHub `pre-` release). `@deltic/translator` -ships the translator wasm for that same commit, so the plan-format -coupling between runtime and translator is self-consistent inside each -graph by construction — there is no separate asset pin and no fetch -step. - -- `deno.json` — the versions in the import map; `deno.lock` carries - integrity, enforced with `--frozen`. The sibling host modules map to - their `.deps` checkouts (pinned by `scripts/setup.sh`), and the npm - mappings (`node-datachannel`, `werift`) serve the webrtc module's - bare specifiers, which resolve against this config as the entry - import map. `minimumDependencyAge` exempts `jsr:@deltic/*` from - Deno's default 24-hour supply-chain gate so same-day prereleases - resolve; everything else keeps the default. -- `experiments/iroh-relay-ws/host/deno.json` — the upstream-iroh - spikes' shared config, same versions by repo convention; the - `exam-deltic` recipe asserts the two configs agree before running. +deltic and the sibling host modules arrive from JSR under caret +constraints on one minor line (`jsr:@deltic/*@^0.1.0`, +`jsr:@polymorph/*@^0.1.0`); `deno.lock` pins the resolved versions and +carries integrity, enforced with `--frozen`. `@deltic/translator` ships +the translator wasm for the same commit as the runtime, so the +plan-format coupling between runtime and translator is self-consistent +inside each graph by construction — there is no separate asset pin and +no fetch step. `minimumDependencyAge` exempts `jsr:@deltic/*` and +`jsr:@polymorph/*` from Deno's default 24-hour supply-chain gate so +same-day releases resolve; everything else keeps the default. +`experiments/iroh-relay-ws/host/deno.json` (the upstream-iroh spikes' +shared config) exact-pins the same deltic packages; the `exam-deltic` +recipe asserts both `deno.lock`s resolve to one deltic version. -To bump: update the versions in both configs, delete both `deno.lock` -files, re-run `just deltic-setup` and +To bump: adjust the constraints (a new minor line) or just delete the +`deno.lock` files (within the line), re-run `just deltic-setup` and `deno install --allow-scripts=npm:node-datachannel` in `experiments/iroh-relay-ws/host/` to regenerate them, and commit the diff. ## Module identity -deltic's wasi-shims and the sibling deltic host modules import -`@deltic/runtime/embedder` by bare specifier internally. This config's -import map does NOT govern the sibling checkouts' files: a `.deps` -module under its own package-shaped `deno.json` (name + exports) -resolves its bare specifiers against THAT config — before the `.deps` -pins converged on JSR-consuming sibling revisions, the webcrypto module -silently rode a raw pinned-tag embedder while everything else used the -JSR one, and `instanceof ComponentException` did not hold across its boundary. -Identity therefore rests on every config in the graph — this one and -each pinned sibling's — naming the SAME `jsr:@deltic/*` version, so the -resolver dedupes to one `ComponentException`/`Stream` module instance. Two gates -in `just exam-deltic` keep it true: the pin grep (this repo's configs -agree) and `scripts/deltic-identity-gate.ts` (the RESOLVED run-endpoint -graph carries exactly one `@deltic/runtime` and no raw URLs). Bumping a -`.deps` pin to a sibling revision that consumes a different deltic -version trips the gate; converge the versions instead. +deltic's wasi package and the sibling host modules import +`@deltic/runtime/embedder` by bare specifier internally, each resolved +through its own package manifest. Identity rests on every manifest in +the graph carrying a constraint the resolver can satisfy with ONE +`@deltic/runtime` version, so it dedupes to one +`ComponentException`/`Stream` module instance — caret constraints on +one minor line guarantee that; an exact pin outside every other +manifest's range (or a raw-URL embedder module) splits the graph, and +`instanceof ComponentException` silently stops holding across that +boundary. Two gates in `just exam-deltic` keep it true: the lock check +(this repo's `deno.lock`s resolve to one deltic version) and +`scripts/deltic-identity-gate.ts` (the RESOLVED run-endpoint graph +carries exactly one `@deltic/runtime` and no raw URLs). diff --git a/host-deltic/deno.json b/host-deltic/deno.json index 59afd76..e7ef86a 100644 --- a/host-deltic/deno.json +++ b/host-deltic/deno.json @@ -1,19 +1,24 @@ { - "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi package and the sibling deltic host modules (.deps/{websocket,webrtc,webcrypto}) import @deltic/runtime/embedder by bare specifier internally; this file maps that specifier ONCE for the whole module graph, so `instanceof ComponentException` holds across every boundary. deltic arrives as exact-pinned JSR releases (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor; per-commit `-pre.g` prereleases exist between releases — pin exactly; @deltic/translator ships the translator wasm for the SAME commit, so the plan-format coupling is self-consistent per graph); deno.lock carries integrity, enforced with --frozen. The version matches experiments/iroh-relay-ws/host/deno.json by repo convention (see README.md, 'The pin'; exam-deltic asserts it). minimumDependencyAge keeps Deno's default supply-chain gate for everything else while letting same-day @deltic publishes resolve. The npm mappings serve the webrtc module's bare specifiers, which resolve against this config as the entry import map.", + "name": "@polymorph/iroh", + "version": "0.1.0", + "license": "Apache-2.0", + "exports": { ".": "./src/mod.ts" }, + "//": "MODULE-IDENTITY CONSTRAINT: deltic's wasi package and the sibling @polymorph host modules import @deltic/runtime/embedder by bare specifier internally. Every dependency constraint here is a caret range on one minor line (published-package convention, deltic's own precedent), so a consumer's resolver dedupes the whole family onto ONE @deltic/runtime module instance and `instanceof ComponentException` holds across every boundary; deno.lock carries integrity, --frozen enforced in the repo. The exam gate (justfile: exam-deltic) asserts the repo's deno.locks resolve to one @deltic version and pipes the resolved graph through scripts/deltic-identity-gate.ts (one runtime module, no raw URLs). src/endpoint_component.ts is GENERATED by `just build-components` (gitignored; the publish workflow regenerates it) — the packaged endpoint component, base64 in the module graph. minimumDependencyAge keeps Deno's default supply-chain gate for everything else while letting same-day @deltic/@polymorph publishes resolve.", "nodeModulesDir": "auto", "minimumDependencyAge": { "age": "P1D", - "exclude": ["jsr:@deltic/*"] + "exclude": ["jsr:@deltic/*", "jsr:@polymorph/*"] }, "imports": { - "@deltic/runtime/embedder": "jsr:@deltic/runtime@0.1.0/embedder", - "@deltic/translator": "jsr:@deltic/translator@0.1.0", - "@deltic/wasi": "jsr:@deltic/wasi@0.1.0", - "@deltic/wasi/io": "jsr:@deltic/wasi@0.1.0/io", - "@polymorph/webcrypto-deltic": "../.deps/webcrypto/js/deltic/src/mod.ts", - "node-datachannel": "npm:node-datachannel@0.32.3", - "node-datachannel/polyfill": "npm:node-datachannel@0.32.3/polyfill", - "werift": "npm:werift@0.22.2" + "@deltic/runtime/embedder": "jsr:@deltic/runtime@^0.1.0/embedder", + "@deltic/translator": "jsr:@deltic/translator@^0.1.0", + "@deltic/wasi": "jsr:@deltic/wasi@^0.1.0", + "@polymorph/webcrypto": "jsr:@polymorph/webcrypto@^0.1.0", + "@polymorph/websocket": "jsr:@polymorph/websocket@^0.1.0", + "@polymorph/webrtc-datachannels": "jsr:@polymorph/webrtc-datachannels@^0.1.0" + }, + "publish": { + "exclude": ["src/run-endpoint.ts", "src/scenario.ts", "src/repo.ts", "!src/endpoint_component.ts"] }, "tasks": { "check": "deno check src", diff --git a/host-deltic/deno.lock b/host-deltic/deno.lock index 0c92ad3..c41933c 100644 --- a/host-deltic/deno.lock +++ b/host-deltic/deno.lock @@ -3,9 +3,11 @@ "specifiers": { "jsr:@deltic/protocol@0.2": "0.2.0", "jsr:@deltic/runtime@0.1": "0.1.0", - "jsr:@deltic/runtime@0.1.0": "0.1.0", - "jsr:@deltic/translator@0.1.0": "0.1.0", - "jsr:@deltic/wasi@0.1.0": "0.1.0", + "jsr:@deltic/translator@0.1": "0.1.0", + "jsr:@deltic/wasi@0.1": "0.1.0", + "jsr:@polymorph/webcrypto@0.1": "0.1.0", + "jsr:@polymorph/webrtc-datachannels@0.1": "0.1.0", + "jsr:@polymorph/websocket@0.1": "0.1.0", "npm:node-datachannel@0.32.3": "0.32.3", "npm:werift@0.22.2": "0.22.2" }, @@ -22,14 +24,34 @@ "@deltic/translator@0.1.0": { "integrity": "c447f27a90d7637dd3d5709f3f25378ded0f4b985bedc890c3f6a8cb9ff1eb9f", "dependencies": [ - "jsr:@deltic/runtime@0.1" + "jsr:@deltic/runtime" ] }, "@deltic/wasi@0.1.0": { "integrity": "c35a19d139d684dafeca8ba200230a3eb1154627c80db298ae1393e3af938d68", "dependencies": [ "jsr:@deltic/protocol", - "jsr:@deltic/runtime@0.1" + "jsr:@deltic/runtime" + ] + }, + "@polymorph/webcrypto@0.1.0": { + "integrity": "3829ada3d3e1401f050f91d7a5faf51e0d4795271345cd1688791b083acf475e", + "dependencies": [ + "jsr:@deltic/runtime" + ] + }, + "@polymorph/webrtc-datachannels@0.1.0": { + "integrity": "ac1d1cff5bcff5bb71973ebe60d069a4132fc0918c7d44fe182d3560a7d7fb36", + "dependencies": [ + "jsr:@deltic/runtime", + "npm:node-datachannel", + "npm:werift" + ] + }, + "@polymorph/websocket@0.1.0": { + "integrity": "7799d6de4d722b0a4b85cac0d6d6d62c64d71c7c36f889223a9baf47fe08b4f3", + "dependencies": [ + "jsr:@deltic/runtime" ] } }, @@ -601,18 +623,12 @@ }, "workspace": { "dependencies": [ - "jsr:@deltic/runtime@0.1.0", - "jsr:@deltic/translator@0.1.0", - "jsr:@deltic/wasi@0.1.0", - "npm:node-datachannel@0.32.3", - "npm:werift@0.22.2" - ], - "links": { - "jsr:@polymorph/webcrypto-deltic@0.1.0": { - "dependencies": [ - "jsr:@deltic/runtime@0.1.0" - ] - } - } + "jsr:@deltic/runtime@0.1", + "jsr:@deltic/translator@0.1", + "jsr:@deltic/wasi@0.1", + "jsr:@polymorph/webcrypto@0.1", + "jsr:@polymorph/webrtc-datachannels@0.1", + "jsr:@polymorph/websocket@0.1" + ] } } diff --git a/host-deltic/src/harness.ts b/host-deltic/src/harness.ts index 5c32e6b..bc00c90 100644 --- a/host-deltic/src/harness.ts +++ b/host-deltic/src/harness.ts @@ -1,24 +1,25 @@ -// The exam harness: relay lifecycle, artifact translation, and one typed -// handle on `polymorph:iroh/endpoint@0.1.0` per endpoint instance. +// The endpoint harness: artifact translation and one typed handle on +// `polymorph:iroh/endpoint@0.1.0` per endpoint instance. // -// Everything here is host wiring; the scenarios in `run-endpoint.ts` carry -// the verdicts. `bindEndpoint(...)` mirrors the jco host's retired -// `iroh.Endpoint.bind(...)` driving shape over `instantiate` + the deltic -// embedder facade. +// Everything here is host wiring; the exam scenarios in `run-endpoint.ts` +// (repo-internal) carry the verdicts. `bindEndpoint(...)` mirrors the jco +// host's retired `iroh.Endpoint.bind(...)` driving shape over +// `instantiate` + the deltic embedder facade. // // MODULE-IDENTITY CONSTRAINT: deltic's wasi package and the sibling host // modules import `@deltic/runtime/embedder` by bare specifier internally; -// this package's `deno.json` maps that specifier ONCE for the whole module -// graph, so there is exactly one `ComponentException`/`Stream` module -// instance and `instanceof` holds across every boundary. +// this package's `deno.json` and the sibling packages' manifests all +// carry caret constraints on one `@deltic/runtime` minor line, so a +// consumer's resolver dedupes them onto one module instance and +// `instanceof ComponentException`/`Stream` holds across every boundary. import { defaultTranslator } from "@deltic/translator"; import type { ComponentArtifacts } from "@deltic/runtime/embedder"; import { instantiate, ComponentException } from "@deltic/runtime/embedder"; import { wasi } from "@deltic/wasi"; -import { webcryptoImports } from "@polymorph/webcrypto-deltic"; -import { websocketImports } from "../../.deps/websocket/js/deltic/websocket.ts"; -import { webrtcImports } from "../../.deps/webrtc/deltic-impl/src/webrtc.ts"; +import { webcryptoImports } from "@polymorph/webcrypto"; +import { websocketImports } from "@polymorph/websocket"; +import { webrtcImports } from "@polymorph/webrtc-datachannels"; import { socketsImports } from "./sockets.ts"; import type { BindConfig, @@ -27,34 +28,27 @@ import type { IrohEndpointExports, } from "./types.ts"; -// This file sits at host-deltic/src/harness.ts, so the repo root is two -// levels up. -const ROOT = new URL("../../", import.meta.url); - -/** The endpoint component, built by `just build-components`. */ -export const ENDPOINT_WASM = - new URL("target/wasm32-wasip2/release/iroh_endpoint.wasm", ROOT).pathname; - -/** The stock upstream relay, built by `just relay-build`. */ -export const RELAY_BIN = - new URL(".deps/iroh/target/release/iroh-relay", ROOT).pathname; -/** `iroh-relay --dev` serves ws on this address. */ -export const RELAY_PORT = 3340; -export const RELAY_URL = `http://127.0.0.1:${RELAY_PORT}`; - export const IROH_ENDPOINT_INTERFACE = "polymorph:iroh/endpoint@0.1.0"; export const IDENTITY_GENERATE_INTERFACE = "polymorph:iroh/identity-generate@0.1.0"; // --- artifact --------------------------------------------------------------- -let cachedArtifacts: ComponentArtifacts | undefined; +let cachedDefault: ComponentArtifacts | undefined; +let cachedExplicit: { bytes: Uint8Array; artifacts: ComponentArtifacts } | undefined; -async function exists(path: string): Promise { - try { - return (await Deno.stat(path)).isFile; - } catch { - return false; - } +export interface LoadArtifactsOptions { + /** + * Component bytes to translate instead of the packaged endpoint build. + * Memoized by reference: passing the same array repeatedly translates + * once. + */ + readonly componentBytes?: Uint8Array; +} + +async function translate(bytes: Uint8Array): Promise { + const translator = await defaultTranslator(); + const { plan, adapters } = translator.translate(bytes); + return { plan, componentBytes: bytes, adapters }; } /** @@ -62,24 +56,33 @@ async function exists(path: string): Promise { * across every `instantiate` in the run: two endpoint *instances* are two * separate component instances over the same immutable artifacts. * + * Without `componentBytes`, the packaged endpoint build is used. Its + * module (`endpoint_component.ts`, generated by `just build-components`) + * is reached through a string-literal dynamic import: the bytes ride the + * statically-analyzable module graph — package integrity, no read + * permission — but the multi-MiB module evaluates only when the packaged + * default is actually used. + * * The translator is `@deltic/translator`'s packaged asset — the same * pinned release as the runtime, loaded through the module graph * (permission-free on Deno), so there is no fetch step and no * plan-format skew to guard against. */ -export async function loadArtifacts(): Promise { - if (cachedArtifacts) return cachedArtifacts; - if (!await exists(ENDPOINT_WASM)) { - throw new Error( - `endpoint component not found at ${ENDPOINT_WASM} — build it with ` + - "`just build-components`.", - ); +export async function loadArtifacts( + options: LoadArtifactsOptions = {}, +): Promise { + const bytes = options.componentBytes; + if (bytes !== undefined) { + if (cachedExplicit?.bytes !== bytes) { + cachedExplicit = { bytes, artifacts: await translate(bytes) }; + } + return cachedExplicit.artifacts; } - const bytes = await Deno.readFile(ENDPOINT_WASM); - const translator = await defaultTranslator(); - const { plan, adapters } = translator.translate(bytes); - cachedArtifacts = { plan, componentBytes: bytes, adapters }; - return cachedArtifacts; + if (!cachedDefault) { + const { endpointComponentBytes } = await import("./endpoint_component.ts"); + cachedDefault = await translate(endpointComponentBytes()); + } + return cachedDefault; } // --- instances -------------------------------------------------------------- @@ -89,6 +92,13 @@ export interface EndpointInstanceOptions { readonly label: string; /** Extra environment for the guest's `wasi:cli/environment`. */ readonly env?: Record; + /** Component bytes to run instead of the packaged endpoint build. */ + readonly componentBytes?: Uint8Array; + /** + * Mirror the guest's stdout/stderr to the console as it runs. The + * captured text is available either way via `stdout()`/`stderr()`. + */ + readonly guestLogPassthrough?: boolean; } export interface EndpointInstance { @@ -111,12 +121,12 @@ export interface EndpointInstance { export async function newEndpointInstance( options: EndpointInstanceOptions, ): Promise { - const artifacts = await loadArtifacts(); + const artifacts = await loadArtifacts({ componentBytes: options.componentBytes }); const shims = wasi({ cli: { args: [`iroh-endpoint-${options.label}`], env: { ...options.env }, - passthrough: Deno.env.get("EXAM_GUEST_LOGS") === "1", + passthrough: options.guestLogPassthrough ?? false, }, }); const imports = { @@ -170,75 +180,6 @@ export async function bindEndpoint( return endpoint; } -// --- relay ------------------------------------------------------------------ - -export interface Relay { - readonly url: string; - stop(): Promise; -} - -async function portOpen(port: number): Promise { - try { - const conn = await Deno.connect({ hostname: "127.0.0.1", port }); - conn.close(); - return true; - } catch { - return false; - } -} - -/** - * Spawn `iroh-relay --dev` (ws on 127.0.0.1:3340) and wait for it to accept. - * - * If something is already listening on the port we adopt it rather than - * racing a second binder. - */ -export async function startRelay(): Promise { - if (await portOpen(RELAY_PORT)) { - console.error(`relay: adopting an already-listening 127.0.0.1:${RELAY_PORT}`); - return { url: RELAY_URL, stop: () => Promise.resolve() }; - } - if (!await exists(RELAY_BIN)) { - throw new Error( - `iroh-relay not found at ${RELAY_BIN} — build it with \`just relay-build\`.`, - ); - } - const child = new Deno.Command(RELAY_BIN, { - args: ["--dev"], - stdout: "piped", - stderr: "piped", - }).spawn(); - // Drain the pipes so the relay never blocks on a full stdio buffer, and - // so `stop()` can close them without an unresolved-read sanitizer hit. - const sink = (r: ReadableStream) => - r.pipeTo(new WritableStream({ write() {} })).catch(() => {}); - const drained = Promise.all([sink(child.stdout), sink(child.stderr)]); - - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (await portOpen(RELAY_PORT)) { - console.error(`relay: iroh-relay --dev listening on ${RELAY_URL} (pid ${child.pid})`); - return { - url: RELAY_URL, - stop: async () => { - try { - child.kill("SIGTERM"); - } catch { /* already gone */ } - await child.status; - await drained; - }, - }; - } - await new Promise((r) => setTimeout(r, 100)); - } - try { - child.kill("SIGKILL"); - } catch { /* ignore */ } - await child.status; - await drained; - throw new Error(`iroh-relay did not listen on ${RELAY_PORT} within 15s`); -} - // --- small helpers ---------------------------------------------------------- export const hex = (bytes: Uint8Array): string => @@ -247,8 +188,8 @@ export const hex = (bytes: Uint8Array): string => /** Endpoint ids are 64 hex chars; log them short (they are public keys). */ export const shortId = (bytes: Uint8Array): string => `${hex(bytes).slice(0, 12)}…`; -export const utf8 = new TextEncoder(); -export const fromUtf8 = new TextDecoder(); +export const utf8: TextEncoder = new TextEncoder(); +export const fromUtf8: TextDecoder = new TextDecoder(); /** Reject after `ms`, so a wedged scenario names itself instead of hanging. */ export function deadline(promise: Promise, ms: number, what: string): Promise { diff --git a/host-deltic/src/mod.ts b/host-deltic/src/mod.ts new file mode 100644 index 0000000..8c35adb --- /dev/null +++ b/host-deltic/src/mod.ts @@ -0,0 +1,35 @@ +/** + * @polymorph/iroh — the iroh endpoint as a WebAssembly component, + * runtime-linked under deltic. + * + * `newEndpointInstance()` stands up one endpoint instance over the sibling + * `@polymorph` deltic host modules — webcrypto (identity keys), websocket + * (relay transport), webrtc-datachannels (direct browser paths) — plus a + * browser-profile `wasi:sockets` provider whose UDP surface answers + * `not-supported` (the endpoint binds no socket when `udp-bind-addr` is + * `none`). The packaged endpoint component ships in the module graph; + * `componentBytes` swaps in a caller-supplied build. + * + * `bindEndpoint()` drives `polymorph:iroh/endpoint@0.1.0`'s + * generate-identity → options → bind sequence and returns the bound + * endpoint resource. + */ + +export { + bindEndpoint, + deadline, + describeError, + type EndpointInstance, + type EndpointInstanceOptions, + fromUtf8, + hex, + IDENTITY_GENERATE_INTERFACE, + IROH_ENDPOINT_INTERFACE, + loadArtifacts, + type LoadArtifactsOptions, + newEndpointInstance, + shortId, + utf8, +} from "./harness.ts"; +export { resetUdpCallLog, socketsImports, udpCallLog } from "./sockets.ts"; +export type * from "./types.ts"; diff --git a/host-deltic/src/repo.ts b/host-deltic/src/repo.ts new file mode 100644 index 0000000..b11777e --- /dev/null +++ b/host-deltic/src/repo.ts @@ -0,0 +1,116 @@ +// Repo-internal exam machinery, publish-excluded (deno.json +// `publish.exclude`): paths into this repository's build outputs and the +// locally built upstream relay. The published package carries none of +// this — consumers use the packaged endpoint component (or pass their own +// bytes) and bring their own relay. + +// This file sits at host-deltic/src/repo.ts, so the repo root is two +// levels up. +const ROOT = new URL("../../", import.meta.url); + +/** The endpoint component, built by `just build-components`. */ +export const ENDPOINT_WASM = + new URL("target/wasm32-wasip2/release/iroh_endpoint.wasm", ROOT).pathname; + +/** The stock upstream relay, built by `just relay-build`. */ +export const RELAY_BIN = + new URL(".deps/iroh/target/release/iroh-relay", ROOT).pathname; +/** `iroh-relay --dev` serves ws on this address. */ +export const RELAY_PORT = 3340; +export const RELAY_URL = `http://127.0.0.1:${RELAY_PORT}`; + +async function exists(path: string): Promise { + try { + return (await Deno.stat(path)).isFile; + } catch { + return false; + } +} + +let cachedBytes: Uint8Array | undefined; + +/** + * The freshly built endpoint component's bytes (read once). The exam runs + * against this tree's build rather than the packaged asset, so a code + * change is exercised without regenerating the embed. + */ +export async function endpointComponentBytes(): Promise { + if (cachedBytes) return cachedBytes; + if (!await exists(ENDPOINT_WASM)) { + throw new Error( + `endpoint component not found at ${ENDPOINT_WASM} — build it with ` + + "`just build-components`.", + ); + } + cachedBytes = await Deno.readFile(ENDPOINT_WASM); + return cachedBytes; +} + +// --- relay ------------------------------------------------------------------ + +export interface Relay { + readonly url: string; + stop(): Promise; +} + +async function portOpen(port: number): Promise { + try { + const conn = await Deno.connect({ hostname: "127.0.0.1", port }); + conn.close(); + return true; + } catch { + return false; + } +} + +/** + * Spawn `iroh-relay --dev` (ws on 127.0.0.1:3340) and wait for it to accept. + * + * If something is already listening on the port we adopt it rather than + * racing a second binder. + */ +export async function startRelay(): Promise { + if (await portOpen(RELAY_PORT)) { + console.error(`relay: adopting an already-listening 127.0.0.1:${RELAY_PORT}`); + return { url: RELAY_URL, stop: () => Promise.resolve() }; + } + if (!await exists(RELAY_BIN)) { + throw new Error( + `iroh-relay not found at ${RELAY_BIN} — build it with \`just relay-build\`.`, + ); + } + const child = new Deno.Command(RELAY_BIN, { + args: ["--dev"], + stdout: "piped", + stderr: "piped", + }).spawn(); + // Drain the pipes so the relay never blocks on a full stdio buffer, and + // so `stop()` can close them without an unresolved-read sanitizer hit. + const sink = (r: ReadableStream) => + r.pipeTo(new WritableStream({ write() {} })).catch(() => {}); + const drained = Promise.all([sink(child.stdout), sink(child.stderr)]); + + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (await portOpen(RELAY_PORT)) { + console.error(`relay: iroh-relay --dev listening on ${RELAY_URL} (pid ${child.pid})`); + return { + url: RELAY_URL, + stop: async () => { + try { + child.kill("SIGTERM"); + } catch { /* already gone */ } + await child.status; + await drained; + }, + }; + } + await new Promise((r) => setTimeout(r, 100)); + } + try { + child.kill("SIGKILL"); + } catch { /* ignore */ } + await child.status; + await drained; + throw new Error(`iroh-relay did not listen on ${RELAY_PORT} within 15s`); +} diff --git a/host-deltic/src/run-endpoint.ts b/host-deltic/src/run-endpoint.ts index 45218ee..d69c373 100644 --- a/host-deltic/src/run-endpoint.ts +++ b/host-deltic/src/run-endpoint.ts @@ -49,14 +49,31 @@ import { deadline, describeError, type EndpointInstance, + type EndpointInstanceOptions, hex, - newEndpointInstance, - type Relay, - RELAY_PORT, + newEndpointInstance as packageEndpointInstance, shortId, - startRelay, utf8, } from "./harness.ts"; +import { + endpointComponentBytes, + type Relay, + RELAY_PORT, + startRelay, +} from "./repo.ts"; + +// The exam always runs THIS TREE's freshly built component (repo.ts reads +// it from target/), never the packaged embed, and forwards the +// EXAM_GUEST_LOGS env knob the harness no longer reads ambiently. +async function newEndpointInstance( + options: EndpointInstanceOptions, +): Promise { + return await packageEndpointInstance({ + componentBytes: await endpointComponentBytes(), + guestLogPassthrough: Deno.env.get("EXAM_GUEST_LOGS") === "1", + ...options, + }); +} import { ComponentException } from "@deltic/runtime/embedder"; import { resetUdpCallLog, udpCallLog } from "./sockets.ts"; import type { diff --git a/justfile b/justfile index 2079326..618e13b 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,9 @@ build-components: cargo build -p iroh-endpoint -p iroh-endpoint-demo -p iroh-exec-model-guest --target wasm32-wasip2 --release mkdir -p target/components wac plug target/wasm32-wasip2/release/iroh_endpoint_demo.wasm --plug target/wasm32-wasip2/release/iroh_endpoint.wasm -o target/components/iroh-demo.wasm + # @polymorph/iroh's generated asset module (gitignored): host-deltic's + # `deno check src` and the jsr publish need it on disk. + deno run --allow-read=target --allow-write=host-deltic/src/endpoint_component.ts scripts/embed-endpoint-component.ts # Build the Wasmtime host binary the gates drive, and the native # interop peer. The exec-model probe driver is deliberately NOT here: @@ -89,16 +92,21 @@ deltic-setup: exam-deltic: build-components relay-build deltic-setup #!/usr/bin/env bash set -euo pipefail - # One deltic version repo-wide (host-deltic/README.md "The pin"): the - # exam is the natural fail-loud point, replacing the retired - # fetch-translator gate. Every jsr:@deltic/* pin in both configs must - # name the SAME prerelease — a translator/runtime split is exactly the - # plan-format skew (or double-runtime WitError identity break) the - # packaged translator exists to rule out. - v=$(grep -ho 'jsr:@deltic/[a-z-]*@[^/"]*' host-deltic/deno.json \ - experiments/iroh-relay-ws/host/deno.json | sed 's/.*@//' | sort -u) + # One RESOLVED deltic version repo-wide (host-deltic/README.md "The + # pin"): the exam is the natural fail-loud point, replacing the + # retired fetch-translator gate. host-deltic's published manifest + # carries caret ranges, so specifier strings no longer pin identity; + # what module identity needs is that every @deltic/* package (except + # @deltic/protocol, versioned independently) RESOLVES to the same + # version across both deno.locks — a translator/runtime split is + # exactly the plan-format skew (or double-runtime WitError identity + # break) the packaged translator exists to rule out. + v=$(jq -r '.jsr | keys[]' host-deltic/deno.lock \ + experiments/iroh-relay-ws/host/deno.lock \ + | grep '^@deltic/' | grep -v '^@deltic/protocol@' \ + | sed 's/.*@//' | sort -u) if [ "$(printf '%s\n' "$v" | wc -l)" != 1 ]; then - echo "deltic pin drift across deno.jsons: $v" >&2 + echo "deltic pin drift across deno.locks: $v" >&2 exit 1 fi # ...and the RESOLVED graph must agree: one embedder instance, no raw diff --git a/scripts/embed-endpoint-component.ts b/scripts/embed-endpoint-component.ts new file mode 100644 index 0000000..6ed698f --- /dev/null +++ b/scripts/embed-endpoint-component.ts @@ -0,0 +1,46 @@ +// Embed the built endpoint component as @polymorph/iroh's asset module: +// +// target/wasm32-wasip2/release/iroh_endpoint.wasm +// -> host-deltic/src/endpoint_component.ts (gitignored, GENERATED) +// +// Run by `just build-components` and by the jsr-publish workflow. Base64 +// in a TypeScript module keeps the bytes inside the statically-analyzable +// module graph — JSR package integrity, no read permission, no bundler +// special-casing — where a raw-bytes import would need Deno's unstable +// raw-imports flag. +// +// deno run --allow-read=target \ +// --allow-write=host-deltic/src/endpoint_component.ts \ +// scripts/embed-endpoint-component.ts + +const WASM = "target/wasm32-wasip2/release/iroh_endpoint.wasm"; +const OUT = "host-deltic/src/endpoint_component.ts"; + +const wasm = await Deno.readFile(WASM); +let bin = ""; +const CHUNK = 0x8000; +for (let i = 0; i < wasm.length; i += CHUNK) { + bin += String.fromCharCode(...wasm.subarray(i, i + CHUNK)); +} +const b64 = btoa(bin); + +const out = `// deno-fmt-ignore-file +// GENERATED by scripts/embed-endpoint-component.ts (\`just build-components\`) +// — do not edit. The iroh endpoint component (wasm32-wasip2, release), +// base64-embedded so the bytes ride the module graph: package integrity, +// no read permission, no unstable raw-import flag. Reached only through +// loadArtifacts()'s string-literal dynamic import, so this module +// evaluates only when the packaged default is used. + +const B64 = "${b64}"; + +/** The packaged endpoint component's bytes (decoded per call). */ +export function endpointComponentBytes(): Uint8Array { + const bin = atob(B64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} +`; +await Deno.writeTextFile(OUT, out); +console.log(`embedded ${wasm.length} bytes -> ${OUT}`);