From 9356f63c6470f045a9f218f2dd6e3fe8c129192c Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Tue, 13 Oct 2026 21:01:21 -0500 Subject: [PATCH] capsite/toybox: OCapN vats; createVat takes remote objects The admin socket now also speaks OCapN over Cap'n Web: a WebSocket there reaches the VatAdmin DO, an OCapN peer whose bootstrap (swissnum vatAdmin) offers createVat(src, params). params may hold remote objects; the vat's root comes back as one. Each OCapN vat is its own peer: SES and @endo/ocapn run in its facet (vat-runtime.js, bundled with esbuild), around the vat's own module, which exports buildRootObject(vatPowers, params). Passing a client's object to a vat, or a vat's root to the client, is an OCapN handoff, so the receiver dials the exporter. Nothing here can dial out, so the VatAdmin is also the router: a client registers its gateway under a designator, a vat's designator is its id, and every peer reaches every other through it. The netlayer is ertp-clerk's, made to work over Workers RPC stubs and plain promises as well as Cap'n Web. Not yet: sturdyrefs that survive a restart, an adminNode, and grants (egress, mount) for OCapN vats. App vats (export class App) still work beside them. Co-Authored-By: Claude Opus 5.5 (1M context) --- toybox/.gitignore | 1 + toybox/build.js | 16 ++ toybox/config.capnp | 8 +- toybox/netlayer.js | 133 +++++++++++ toybox/package.json | 9 +- toybox/pnpm-lock.yaml | 477 ++++++++++++++++++++++++++++++++++++++ toybox/supervisor.js | 167 ++++++++++++- toybox/test/lockdown.js | 10 + toybox/test/ocapn.test.js | 68 ++++++ toybox/test/vat.test.js | 47 +--- toybox/test/workerd.js | 51 ++++ toybox/vat-runtime.js | 51 ++++ toybox/vats/greeter.js | 6 + 13 files changed, 983 insertions(+), 61 deletions(-) create mode 100644 toybox/build.js create mode 100644 toybox/netlayer.js create mode 100644 toybox/test/lockdown.js create mode 100644 toybox/test/ocapn.test.js create mode 100644 toybox/test/workerd.js create mode 100644 toybox/vat-runtime.js create mode 100644 toybox/vats/greeter.js diff --git a/toybox/.gitignore b/toybox/.gitignore index 4340523..051b64b 100644 --- a/toybox/.gitignore +++ b/toybox/.gitignore @@ -1,3 +1,4 @@ state/ admin.sock switchboard-*.sock +dist/ diff --git a/toybox/build.js b/toybox/build.js new file mode 100644 index 0000000..fb870be --- /dev/null +++ b/toybox/build.js @@ -0,0 +1,16 @@ +/** @file Bundle the supervisor and the OCapN vat runtime for workerd (see config.capnp). */ +import { build } from "esbuild"; + +const common = { + bundle: true, + format: "esm", + platform: "neutral", + mainFields: ["module", "main"], + conditions: ["workerd", "import"], + external: ["cloudflare:workers", "node:*"], + logLevel: "warning", +}; + +await build({ ...common, entryPoints: ["supervisor.js"], outfile: "dist/supervisor.js", external: [...common.external, "vat-runtime.txt"] }); +// loaded as text, beside the vat's own module (vat.js) +await build({ ...common, entryPoints: ["vat-runtime.js"], outfile: "dist/vat-runtime.js", external: [...common.external, "./vat.js"] }); diff --git a/toybox/config.capnp b/toybox/config.capnp index a361282..eb518ef 100644 --- a/toybox/config.capnp +++ b/toybox/config.capnp @@ -24,19 +24,21 @@ const config :Workerd.Config = ( const supervisor :Workerd.Worker = ( compatibilityDate = "2026-10-01", + # bundled by build.js modules = [ - (name = "supervisor.js", esModule = embed "supervisor.js"), - (name = "labels.js", esModule = embed "labels.js"), - (name = "capnweb", esModule = embed "node_modules/capnweb/dist/index-workers.js"), + (name = "supervisor.js", esModule = embed "dist/supervisor.js"), + (name = "vat-runtime.txt", text = embed "dist/vat-runtime.js"), ], durableObjectNamespaces = [ (className = "Vat", uniqueKey = "toybox-vat", enableSql = true), (className = "Directory", uniqueKey = "toybox-directory", enableSql = true), + (className = "VatAdmin", uniqueKey = "toybox-vat-admin", enableSql = true), ], durableObjectStorage = (localDisk = "state"), bindings = [ (name = "VATS", durableObjectNamespace = "Vat"), (name = "DIRECTORY", durableObjectNamespace = "Directory"), + (name = "VAT_ADMIN", durableObjectNamespace = "VatAdmin"), (name = "LOADER", workerLoader = ()), (name = "SWITCHBOARD", service = "switchboard"), ], diff --git a/toybox/netlayer.js b/toybox/netlayer.js new file mode 100644 index 0000000..6510ea1 --- /dev/null +++ b/toybox/netlayer.js @@ -0,0 +1,133 @@ +/** + * @file An OCapN netlayer whose transport is an RPC session: Cap'n Web, or + * Workers RPC, or one proxied through the other. + * + * Each OCapN connection is a pair of {@link ByteInbox}es, one per + * direction, exchanged through a gateway; each `deliver` call carries + * exactly one OCapN message, so no framing is needed. The RPC session + * provides ordering and only-the-peer-can-inject; OCapN does the rest. + * + * After vattr97's ertp-clerk (`ocapn-capnweb-netlayer.ts`), with the stub + * methods only Cap'n Web has (`onRpcBroken`) made optional. + * + * @see https://github.com/ocapn/ocapn/blob/main/draft-specifications/Netlayers.md + */ +import { RpcTarget } from "capnweb"; + +export const TRANSPORT = "capnweb"; + +/** + * A peer's location, by designator alone: a router finds the peer. + * + * @param {string} designator + */ +export const locationOf = (designator) => ({ type: "ocapn-peer", transport: TRANSPORT, designator, hints: false }); + +/** Same as `locationToLocationId` in `@endo/ocapn`, which its exports map doesn't expose. */ +const locationId = ({ designator, transport }) => `ocapn://${designator}.${transport}`; + +/** Receives one direction of an OCapN connection, one message per call. */ +export class ByteInbox extends RpcTarget { + #onMessage; + + constructor(onMessage) { + super(); + this.#onMessage = onMessage; + } + + /** @param {Uint8Array} bytes */ + deliver(bytes) { + // OCapN's syrup reader needs offset 0; a pooled Buffer view may not have it. + this.#onMessage(bytes.byteOffset === 0 ? bytes : new Uint8Array(bytes)); + } +} + +const dispose = (stub) => stub?.[Symbol.dispose]?.(); + +/** Write to a remote inbox, discarding each result at once so the session retains none. */ +const makeSocket = (inbox, close) => ({ + write: (bytes) => dispose(inbox.deliver(bytes)), + end: close, +}); + +/** + * A remote inbox reached through a gateway that is only a promise (a + * local async call, not a pipelining stub): writes queue in order behind it. + */ +const deferredInbox = (gatewayP, myInbox) => { + const inboxP = Promise.resolve(gatewayP).then((gateway) => gateway.connect(myInbox)); + return { + deliver: (bytes) => void inboxP.then((inbox) => dispose(inbox.deliver(bytes))), + onRpcBroken: (cb) => void inboxP.then((inbox) => inbox.onRpcBroken?.(cb), cb), + }; +}; + +/** + * Make an OCapN netlayer for `client.registerNetlayer`. + * + * @param {object} opts + * @param {string} opts.designator this peer's designator + * @param {(designator: string) => any} opts.openGateway the gateway of + * the peer with this designator: a stub or pipelining promise + * (`openGateway(d).connect(...)`), or a plain promise for one + * @returns a netlayer maker; the netlayer has a `gateway` that accepts + * incoming connections + */ +export const makeRpcNetLayer = ({ designator, openGateway }) => { + const location = locationOf(designator); + const connections = new Set(); + + return (handlers, logger) => { + const track = (connection, inbox) => { + connections.add(connection); + inbox.onRpcBroken?.((reason) => { + if (connections.delete(connection)) logger.log("rpc netlayer: connection broken", reason); + handlers.handleConnectionClose(connection, reason); + }); + }; + + const netlayer = { + location, + locationId: locationId(location), + connect: (peer) => { + const gateway = openGateway(peer.designator); + let connection; + const myInbox = new ByteInbox((bytes) => handlers.handleMessageData(connection, bytes)); + // Pipelined: writes before the peer answers queue in order. + const peerInbox = + typeof gateway.connect === "function" ? gateway.connect(myInbox) : deferredInbox(gateway, myInbox); + connection = handlers.makeConnection( + netlayer, + true, + makeSocket(peerInbox, () => { + connections.delete(connection); + dispose(gateway); + }), + ); + track(connection, peerInbox); + return connection; + }, + shutdown: () => { + for (const connection of [...connections]) connection.end(); + }, + }; + + class Gateway extends RpcTarget { + connect(peerInbox) { + const peer = peerInbox.dup?.() ?? peerInbox; + const connection = handlers.makeConnection( + netlayer, + false, + makeSocket(peer, () => { + connections.delete(connection); + dispose(peer); + }), + ); + track(connection, peer); + return new ByteInbox((bytes) => handlers.handleMessageData(connection, bytes)); + } + } + + return Object.assign(netlayer, { gateway: new Gateway() }); + }; +}; diff --git a/toybox/package.json b/toybox/package.json index dbfce98..af2659a 100644 --- a/toybox/package.json +++ b/toybox/package.json @@ -4,15 +4,20 @@ "private": true, "type": "module", "scripts": { - "start": "mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l ./admin.sock -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state -e switchboard=unix:./switchboard-public.sock", + "start": "node build.js && mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l ./admin.sock -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state -e switchboard=unix:./switchboard-public.sock", "switchboard": "rm -f switchboard-public.sock switchboard-local.sock && systemd-socket-activate -l ./switchboard-public.sock -l ./switchboard-local.sock -- node_modules/workerd/bin/workerd serve --experimental switchboard.capnp -S public=3 -S local=4", - "test": "node --test" + "test": "node build.js && node --import ./test/lockdown.js --test test/*.test.js", + "build": "node build.js" }, "devDependencies": { + "esbuild": "^0.28.2", "workerd": "1.20261006.1", "ws": "8.22.0" }, "dependencies": { + "@endo/far": "^1.1.14", + "@endo/init": "^1.1.13", + "@endo/ocapn": "1.1.1", "capnweb": "0.12.0" } } diff --git a/toybox/pnpm-lock.yaml b/toybox/pnpm-lock.yaml index 996a657..6bd023f 100644 --- a/toybox/pnpm-lock.yaml +++ b/toybox/pnpm-lock.yaml @@ -8,10 +8,22 @@ importers: .: dependencies: + '@endo/far': + specifier: ^1.1.14 + version: 1.1.14 + '@endo/init': + specifier: ^1.1.13 + version: 1.1.13 + '@endo/ocapn': + specifier: 1.1.1 + version: 1.1.1 capnweb: specifier: 0.12.0 version: 0.12.0 devDependencies: + esbuild: + specifier: ^0.28.2 + version: 0.28.2 workerd: specifier: 1.20261006.1 version: 1.20261006.1 @@ -51,9 +63,242 @@ packages: cpu: [x64] os: [win32] + '@endo/base64@1.1.1': + resolution: {integrity: sha512-6Aiy5oU/5/LYrHLbEPz0OWQ+CQsnAOC3qQYQ3nlwWi2JsU8fIZWjwfa+/+MSx8LTzITC250ZnMvtR0y/FIOQKw==} + + '@endo/bytes@1.0.2': + resolution: {integrity: sha512-gKG8v4c7SD/QzULlFl1OKBuUCCy+9H78xsCL40hZn1q2/hVCNAHrxPJFO6VbA8Nk8UJP6P+NjvZY+xdrfwytMQ==} + + '@endo/cache-map@1.1.0': + resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==} + + '@endo/common@1.4.0': + resolution: {integrity: sha512-+d0CCzEYUP4PZDVGAUHMNfCEBn/GOyq/39y33N4FFUb6VPNJosfKFK5bI38mTaqwr6gxLGbzm8aeiElz+NaSDQ==} + + '@endo/env-options@1.1.11': + resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==} + + '@endo/errors@1.3.1': + resolution: {integrity: sha512-AmR2DSiahQR66SHlEczNWe0eUotMYE2uDwxPj9fAY2l2lovwHWGzZKXNXdQP/u62c8vetU8mG98Mh5vRZEvFTg==} + + '@endo/eventual-send@1.5.0': + resolution: {integrity: sha512-mIRewS15mk+oV8y7kszyE0KWHZAlVoe98bEo/WJFJvBv1Rd83IjnofbuZcyWToWd70vgCxfkutWRJBK2Xtwd5A==} + + '@endo/far@1.1.14': + resolution: {integrity: sha512-OlM+yCNWVqhwSZa6MyjkVB0oQSTAQ15KoiDGL1qowtnTnhuGUbOXuaUAsDf4eAJZ4L+h2KsJOUdyYEuhNsDPLg==} + + '@endo/harden@1.1.0': + resolution: {integrity: sha512-d0CF7EVqymx9YUbkRI2sj10NrMLhrpmsaq5aBbEEbWDFxNe7F/mM8Gv8zT+iHErKEfeMCId90n5mk665SnxrRA==} + + '@endo/hex@1.1.1': + resolution: {integrity: sha512-I2Q7roaHk0fE4JTj2GXfxHPR14qTFK9bOvp3nKf2Fk0r/CjgL4zFcHYolvqY15SRNmZDcywnQ+MBTW82b7BUpQ==} + + '@endo/immutable-arraybuffer@2.0.0': + resolution: {integrity: sha512-xvfJvo4CgETh6UjSKwhNsFLZrqrTf2k4ZFpcprEN2UtCQRSOk+CcLP70kFbrOesB2Z/c1z9MaUZDS1JY6IlnUg==} + + '@endo/init@1.1.13': + resolution: {integrity: sha512-7I94pHD0ax+WDj5dP1MKgzgSY6/GsdwCc/Ap2zFY4lzhCO944zQ4MChAW2yX28QKJK80VyJzqoW5BQ56ngEHHA==} + + '@endo/lockdown@1.0.19': + resolution: {integrity: sha512-pcc6TqTBlx43422gHFzS+FrUfyJlbDoWUrynp78Mv+FrIZv4947FPcfjlXolgwvO00alMlSYxfXrrXUV93ywaA==} + + '@endo/marshal@1.10.0': + resolution: {integrity: sha512-1ny6r0+XFpkpJNJEOApZQlFNB8FsDxzU2oCv1hpN1HjpszsKcTiyfXU/n/cJIs3KOOS06Tkt72pgPlasHpsVGA==} + + '@endo/nat@5.2.0': + resolution: {integrity: sha512-yTNaysErNbrAWV8H2oC4e83nytG8Vzcu6VrxOkSIJQt+IVS9BUlSa1KkiKrVwu2IcIC67gJIkolPq3SgMGDjfQ==} + + '@endo/ocapn@1.1.1': + resolution: {integrity: sha512-3VtHmyMsHwDPwcVusCTgtb8VXr539iZk+r8vzzOU6/cWyMhJuxRGOKR3r5EzDHVG4tY/R6tihoGt7QLnNDuceQ==} + + '@endo/pass-style@1.8.2': + resolution: {integrity: sha512-63kbFNT+mropjQ0wzCkjSR5uEOT8FAIk16qHqrE+ciqSjMVd2hsW6vx10dbyo1s2xtmvXduUiTl+LqtnPa2oSw==} + + '@endo/promise-kit@1.2.1': + resolution: {integrity: sha512-1nyXBOuUnVo/i47On8RCXPaT/1RIsSpJ1sGBClfZrcTtnsJfj8fElEVlJIE8NFsQiZPLNANYI+i/NYi837+s0Q==} + engines: {node: '>=11.0'} + + '@endo/stream@1.3.1': + resolution: {integrity: sha512-v21VRzo5wjzw7XMHCWAmDS50hkUQi/m9C0MQTIAAqLm6w9+/Er9AEXl8FGIbz+VjtJWk5SNVbuqA51N7oBaLDQ==} + + '@endo/syrup-frame@0.1.1': + resolution: {integrity: sha512-+uha+zI05RHPlSNVI7uaB2KD1EQ9gKAMcOzj1eT3y7Ng/vcW1bo7clCVVo3t+dOFOc6vYSsXgKufi40qxXRgYQ==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + capnweb@0.12.0: resolution: {integrity: sha512-jgZ/LMtMVTi+RVlooIslH78Gg23XbDTdvcLghWx3oz17D6u5GuJARt+uhZk/wcTo+f81eeabfnow4d3zLBj+JQ==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + ses@2.3.0: + resolution: {integrity: sha512-qd3iWzDqGKllI2FmExKsWLrJwt+6COWb8jUdzaCn8Cq5OTeoXdQ62Gn/xLy0GWLORC3du6yBAVe/V0B0dLVKew==} + workerd@1.20261006.1: resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==} engines: {node: '>=16'} @@ -88,8 +333,240 @@ snapshots: '@cloudflare/workerd-windows-64@1.20261006.1': optional: true + '@endo/base64@1.1.1': {} + + '@endo/bytes@1.0.2': + dependencies: + '@endo/harden': 1.1.0 + '@endo/immutable-arraybuffer': 2.0.0 + + '@endo/cache-map@1.1.0': {} + + '@endo/common@1.4.0': + dependencies: + '@endo/errors': 1.3.1 + '@endo/eventual-send': 1.5.0 + '@endo/harden': 1.1.0 + '@endo/promise-kit': 1.2.1 + + '@endo/env-options@1.1.11': {} + + '@endo/errors@1.3.1': + dependencies: + '@endo/harden': 1.1.0 + ses: 2.3.0 + + '@endo/eventual-send@1.5.0': + dependencies: + '@endo/env-options': 1.1.11 + '@endo/harden': 1.1.0 + + '@endo/far@1.1.14': + dependencies: + '@endo/errors': 1.3.1 + '@endo/eventual-send': 1.5.0 + '@endo/pass-style': 1.8.2 + + '@endo/harden@1.1.0': {} + + '@endo/hex@1.1.1': + dependencies: + '@endo/harden': 1.1.0 + + '@endo/immutable-arraybuffer@2.0.0': {} + + '@endo/init@1.1.13': + dependencies: + '@endo/base64': 1.1.1 + '@endo/eventual-send': 1.5.0 + '@endo/harden': 1.1.0 + '@endo/lockdown': 1.0.19 + '@endo/promise-kit': 1.2.1 + + '@endo/lockdown@1.0.19': + dependencies: + ses: 2.3.0 + + '@endo/marshal@1.10.0': + dependencies: + '@endo/common': 1.4.0 + '@endo/env-options': 1.1.11 + '@endo/errors': 1.3.1 + '@endo/eventual-send': 1.5.0 + '@endo/harden': 1.1.0 + '@endo/nat': 5.2.0 + '@endo/pass-style': 1.8.2 + + '@endo/nat@5.2.0': {} + + '@endo/ocapn@1.1.1': + dependencies: + '@endo/bytes': 1.0.2 + '@endo/eventual-send': 1.5.0 + '@endo/harden': 1.1.0 + '@endo/hex': 1.1.1 + '@endo/init': 1.1.13 + '@endo/marshal': 1.10.0 + '@endo/nat': 5.2.0 + '@endo/pass-style': 1.8.2 + '@endo/promise-kit': 1.2.1 + '@endo/stream': 1.3.1 + '@endo/syrup-frame': 0.1.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@endo/pass-style@1.8.2': + dependencies: + '@endo/common': 1.4.0 + '@endo/env-options': 1.1.11 + '@endo/errors': 1.3.1 + '@endo/eventual-send': 1.5.0 + '@endo/harden': 1.1.0 + '@endo/promise-kit': 1.2.1 + + '@endo/promise-kit@1.2.1': + dependencies: + '@endo/harden': 1.1.0 + + '@endo/stream@1.3.1': + dependencies: + '@endo/eventual-send': 1.5.0 + '@endo/harden': 1.1.0 + '@endo/promise-kit': 1.2.1 + ses: 2.3.0 + + '@endo/syrup-frame@0.1.1': + dependencies: + '@endo/harden': 1.1.0 + '@endo/init': 1.1.13 + '@endo/promise-kit': 1.2.1 + '@endo/stream': 1.3.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + capnweb@0.12.0: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + ses@2.3.0: + dependencies: + '@endo/cache-map': 1.1.0 + '@endo/env-options': 1.1.11 + '@endo/immutable-arraybuffer': 2.0.0 + workerd@1.20261006.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20261006.1 diff --git a/toybox/supervisor.js b/toybox/supervisor.js index f255f8c..f78f175 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -1,7 +1,12 @@ /** @file toybox supervisor: vats with code installed at runtime, each app a facet; ingress by mount, egress by grant. */ +import "@endo/init"; import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; -import { newWebSocketRpcSession } from "capnweb"; +import { RpcTarget, newWebSocketRpcSession, newWorkersRpcResponse } from "capnweb"; +import { E, Far } from "@endo/far"; +import { makeClient, swissnumFromBytes } from "@endo/ocapn"; import { carries, conflict, mergeLabels, urlLabels } from "./labels.js"; +import { locationOf, makeRpcNetLayer } from "./netlayer.js"; +import VAT_RUNTIME from "vat-runtime.txt"; /** * A stored grant: labels as JSON, or (before labels) a bare `host[:port]`. @@ -35,6 +40,8 @@ export class Vat extends DurableObject { ctx.storage.sql.exec( "CREATE TABLE IF NOT EXISTS incarnation (k INTEGER PRIMARY KEY CHECK (k = 0), n INTEGER, egress TEXT)", ); + // absent: an app vat (exports class App), from before OCapN vats + ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS kind (k INTEGER PRIMARY KEY CHECK (k = 0), kind TEXT)"); } /** @@ -42,42 +49,65 @@ export class Vat extends DurableObject { * * @param {string} src an ES module exporting `class App extends DurableObject` * @param {string | null} egress the app's grant: egress labels as JSON (see labels.js), or none + * @param {"app" | "ocapn"} kind `app`: `src` exports `class App`; `ocapn`: + * `src` exports `buildRootObject`, run by vat-runtime.js * @returns {Promise} the code's SHA-256, hex */ - async start(src, egress) { + async start(src, egress, kind = "app") { const hash = hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(src))); const { sql } = this.ctx.storage; sql.exec("INSERT OR IGNORE INTO bundle VALUES (?, ?)", hash, src); sql.exec("INSERT OR REPLACE INTO current VALUES (0, ?)", hash); const [prev] = sql.exec("SELECT n FROM incarnation").toArray(); sql.exec("INSERT OR REPLACE INTO incarnation VALUES (0, ?, ?)", (prev?.n ?? 0) + 1, egress); + sql.exec("INSERT OR REPLACE INTO kind VALUES (0, ?)", kind); this.ctx.facets.abort("app", new Error("upgraded")); return hash; } - #app({ hash, src, n, egress }) { + #app({ src, n, egress, kind }) { return this.ctx.facets.get("app", () => { + const env = egress ? { egress: this.ctx.exports.Egress({ props: { labels: grantLabels(egress) } }) } : {}; + const code = + kind === "ocapn" + ? { + mainModule: "runtime.js", + modules: { "runtime.js": VAT_RUNTIME, "vat.js": src }, + env: { ...env, vatId: this.ctx.id.toString(), router: this.ctx.exports.Router({ props: {} }) }, + } + : { mainModule: "app.js", modules: { "app.js": src }, env }; const worker = this.env.LOADER.get(`${this.ctx.id}:${n}`, () => ({ compatibilityDate: "2026-10-01", - mainModule: "app.js", - modules: { "app.js": src }, - env: egress ? { egress: this.ctx.exports.Egress({ props: { labels: grantLabels(egress) } }) } : {}, + ...code, globalOutbound: null, })); return { class: worker.getDurableObjectClass("App") }; }); } - fetch(request) { + #current() { const [row] = this.ctx.storage.sql .exec( - `SELECT b.hash, b.src, coalesce(i.n, 0) AS n, i.egress - FROM current c JOIN bundle b USING (hash) LEFT JOIN incarnation i ON i.k = 0`, + `SELECT b.hash, b.src, coalesce(i.n, 0) AS n, i.egress, coalesce(k.kind, 'app') AS kind + FROM current c JOIN bundle b USING (hash) + LEFT JOIN incarnation i ON i.k = 0 LEFT JOIN kind k ON k.k = 0`, ) .toArray(); + return row; + } + + fetch(request) { + const row = this.#current(); if (!row) return new Response("no code installed\n", { status: 404 }); return this.#app(row).fetch(request); } + + /** An OCapN vat's gateway, for the router. */ + gateway() { + const row = this.#current(); + if (row?.kind !== "ocapn") throw new Error("not an OCapN vat"); + return this.#app(row).gateway(); + } } /** @@ -129,6 +159,119 @@ export class Egress extends WorkerEntrypoint { } } +/** The vatAdmin's designator, and the swissnum of its bootstrap object. */ +const VAT_ADMIN = "vatAdmin"; +/** The swissnum of an OCapN vat's bootstrap (see vat-runtime.js). */ +const VAT_SWISSNUM = "vat"; +const encoder = new TextEncoder(); + +/** + * The vatAdmin: an OCapN peer, and the router through which every OCapN + * peer here reaches every other. One instance, by name. + * + * Peers find each other by designator: the vatAdmin's is `vatAdmin`, a + * vat's is its id, and a client registers its own when it connects to + * the admin socket. Nothing here can dial out, so a handoff's receiver + * reaches the exporter through this router. + */ +export class VatAdmin extends DurableObject { + #clients = new Map(); + #ready; + + constructor(ctx, env) { + super(ctx, env); + this.#ready = this.#boot(); + } + + async #boot() { + const vatAdmin = Far("vatAdmin", { + /** + * Create an OCapN vat running `src`; `params` (any passable, + * remote objects included) reach its `buildRootObject`. + * + * @param {string} src an ES module exporting `buildRootObject(vatPowers, params)` + */ + createVat: async (src, params = {}) => { + const id = this.env.VATS.newUniqueId(); + await this.env.VATS.get(id).start(src, null, "ocapn"); + const vatBootstrap = await this.#enliven(id.toString(), VAT_SWISSNUM); + const root = await E(vatBootstrap).start(params); + return harden({ id: id.toString(), root }); + }, + }); + this.ocapn = makeClient({ debugLabel: VAT_ADMIN, swissnumTable: new Map([[VAT_ADMIN, vatAdmin]]) }); + const netlayer = await this.ocapn.registerNetlayer( + makeRpcNetLayer({ designator: VAT_ADMIN, openGateway: (designator) => this.gateway(designator) }), + ); + this.own = netlayer.gateway; + } + + #enliven(designator, swissnum) { + return this.ocapn.enlivenSturdyRef( + this.ocapn.makeSturdyRef(locationOf(designator), swissnumFromBytes(encoder.encode(swissnum))), + ); + } + + /** + * The gateway of the peer with this designator. + * + * @param {string} designator + */ + async gateway(designator) { + await this.#ready; + if (designator === VAT_ADMIN) return this.own; + const client = this.#clients.get(designator); + if (client) return client; + let id; + try { + id = this.env.VATS.idFromString(designator); + } catch { + throw new Error("no such peer"); + } + return this.env.VATS.get(id).gateway(); + } + + /** A WebSocket here starts a Cap'n Web session whose main object is an {@link AdminSession}. */ + fetch(request) { + return newWorkersRpcResponse(request, new AdminSession(this, this.#clients)); + } +} + +/** + * What an OCapN client on the admin socket gets: register its own + * gateway under a designator, and reach any peer's. + */ +class AdminSession extends RpcTarget { + #vatAdmin; + #clients; + + constructor(vatAdmin, clients) { + super(); + this.#vatAdmin = vatAdmin; + this.#clients = clients; + } + + /** @param {string} designator @param {any} gateway */ + register(designator, gateway) { + if (designator === VAT_ADMIN || /^[0-9a-f]{64}$/.test(designator) || this.#clients.has(designator)) { + throw new Error("designator taken"); + } + this.#clients.set(designator, gateway.dup?.() ?? gateway); + } + + /** @param {string} designator */ + gateway(designator) { + return this.#vatAdmin.gateway(designator); + } +} + +/** A vat's way to the router: `env.router` in an OCapN vat. */ +export class Router extends WorkerEntrypoint { + gateway(designator) { + return this.env.VAT_ADMIN.getByName(VAT_ADMIN).gateway(designator); + } +} + /** * Mounts: which vat serves which label set. One instance, by name. * @@ -231,7 +374,8 @@ const grantParam = (url, name) => { }; /** - * The admin socket: `createVat` and its `adminNode`, over HTTP. + * The admin socket: a WebSocket upgrade reaches the {@link VatAdmin} (OCapN + * over Cap'n Web); otherwise, app vats over HTTP: * * - `POST /` with an ES module body creates a vat and starts it; the reply * is `{"vat": , "hash": }`, and the id addresses it here. @@ -245,6 +389,9 @@ const grantParam = (url, name) => { */ export class Admin extends WorkerEntrypoint { async fetch(request) { + if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + return this.env.VAT_ADMIN.getByName(VAT_ADMIN).fetch(request); + } const url = new URL(request.url); const [, first, second] = url.pathname.split("/"); let id; diff --git a/toybox/test/lockdown.js b/toybox/test/lockdown.js new file mode 100644 index 0000000..8a4a12d --- /dev/null +++ b/toybox/test/lockdown.js @@ -0,0 +1,10 @@ +// @endo/init/debug.js, but with `overrideTaming: 'moderate'`: Node 24's +// undici assigns `message` to its errors (ClientDestroyedError when a +// WebSocket closes), which 'min' makes a TypeError. +import '@endo/init/pre-remoting.js'; + +lockdown({ + errorTaming: 'unsafe', + overrideTaming: 'moderate', + domainTaming: 'unsafe', +}); diff --git a/toybox/test/ocapn.test.js b/toybox/test/ocapn.test.js new file mode 100644 index 0000000..456241e --- /dev/null +++ b/toybox/test/ocapn.test.js @@ -0,0 +1,68 @@ +/** @file OCapN vats: a client on the admin socket creates a vat and passes it a remote object. */ +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { E, Far } from "@endo/far"; +import { makeClient, swissnumFromBytes } from "@endo/ocapn"; +import { newWebSocketRpcSession } from "capnweb"; +import WebSocket from "ws"; +import { locationOf, makeRpcNetLayer } from "../netlayer.js"; +import { freePort, here, startWorkerd } from "./workerd.js"; + +const vatSrc = (name) => readFile(join(here, "vats", name), "utf8"); + +/** + * An OCapN client on the admin socket: registers its own gateway with the + * router there, and reaches every other peer through it. + * + * @param {string} adminSock + * @param {string} designator + */ +const connectClient = async (adminSock, designator) => { + const ws = new WebSocket(`ws+unix:${adminSock}:/`); + await new Promise((resolve, reject) => (ws.once("open", resolve), ws.once("error", reject))); + const session = newWebSocketRpcSession(ws); + const ocapn = makeClient({ debugLabel: designator, verbose: !!process.env.OCAPN_LOG }); + const netlayer = await ocapn.registerNetlayer( + makeRpcNetLayer({ designator, openGateway: (d) => session.gateway(d) }), + ); + await session.register(designator, netlayer.gateway); + const vatAdmin = await ocapn.enlivenSturdyRef( + ocapn.makeSturdyRef(locationOf("vatAdmin"), swissnumFromBytes(new TextEncoder().encode("vatAdmin"))), + ); + return { vatAdmin, close: () => (ocapn.shutdown(), ws.close()) }; +}; + +let dir, server, client; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), "toybox-ocapn-")); + await mkdir(join(dir, "state")); + const port = await freePort(); + server = await startWorkerd( + "config.capnp", + [ + "-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`, + "-d", `state=${join(dir, "state")}`, "-e", `switchboard=unix:${join(dir, "no-switchboard.sock")}`, + ], + { port, host: "127.0.0.1" }, + [join(dir, "admin.sock")], + ); + client = await connectClient(join(dir, "admin.sock"), "client-1"); +}); + +after(async () => { + client?.close(); + await server?.stop(); + await rm(dir, { recursive: true, force: true }); +}); + +test("createVat passes a remote object in; the vat's root comes back", async () => { + const carol = Far("carol", { greet: (name) => `hi ${name}, from carol` }); + const { id, root } = await E(client.vatAdmin).createVat(await vatSrc("greeter.js"), { carol }); + assert.match(id, /^[0-9a-f]{64}$/); + assert.equal(await E(root).hello(), "hello from the vat"); + assert.equal(await E(root).greetVia("vat"), "hi vat, from carol"); +}); diff --git a/toybox/test/vat.test.js b/toybox/test/vat.test.js index 23531a2..d50c71b 100644 --- a/toybox/test/vat.test.js +++ b/toybox/test/vat.test.js @@ -1,34 +1,14 @@ /** @file toybox end to end: a real workerd, an admin socket, a public port, a switchboard with two handles. */ -import { spawn } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { createServer as createHttpServer, request } from "node:http"; -import { connect, createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, test } from "node:test"; import assert from "node:assert/strict"; import { WebSocketServer } from "ws"; - -const here = new URL("..", import.meta.url).pathname; -// the package binary, not the .bin shim: the shim forks it, so a kill would orphan workerd -const workerd = join(here, "node_modules/workerd/bin/workerd"); +import { freePort, here, startWorkerd } from "./workerd.js"; const app = (name) => readFile(join(here, "apps", name), "utf8"); -const freePort = () => - new Promise((resolve) => { - const s = createServer().listen(0, "127.0.0.1", () => { - const { port } = s.address(); - s.close(() => resolve(port)); - }); - }); - -/** Resolves once something accepts connections at `opts` (a port or a socket path). */ -const accepting = (opts) => - new Promise((resolve) => { - const s = connect(opts, () => (s.end(), resolve(true))); - s.on("error", () => resolve(false)); - }); - /** * The admin socket: holding it is the authority to create and upgrade vats. * @@ -67,31 +47,6 @@ const makePublic = (base) => ({ put: async (pathAndQuery, src) => (await fetch(`${base}${pathAndQuery}`, { method: "PUT", body: src })).status, }); -/** - * Run workerd on `config` until `stop()`; resolves once `listening` accepts. - * - * @param {string} config - * @param {string[]} args socket, directory, and external overrides - * @param {{ port: number } | { path: string }} listening - * @param {string[]} stale socket files a crash leaves behind; bind() would fail on them - */ -const startWorkerd = async (config, args, listening, stale = []) => { - for (const f of stale) await rm(f, { force: true }); - const child = spawn(workerd, ["serve", "--experimental", join(here, config), ...args], { - stdio: ["ignore", "ignore", "pipe"], - }); - let log = ""; - child.stderr.on("data", (c) => (log += c)); - const exited = new Promise((resolve) => child.on("exit", resolve)); - for (let i = 0; i < 100; i++) { - // SIGKILL: a crash is the harder durability test - if (await accepting(listening)) return { stop: () => (child.kill("SIGKILL"), exited) }; - await new Promise((r) => setTimeout(r, 50)); - } - child.kill("SIGKILL"); - throw new Error(`workerd did not start: ${log}`); -}; - /** An HTTP server that answers GET with "plain ok", echoes WebSocket messages, and counts requests. */ const startEcho = async () => { let hits = 0; diff --git a/toybox/test/workerd.js b/toybox/test/workerd.js new file mode 100644 index 0000000..3804e31 --- /dev/null +++ b/toybox/test/workerd.js @@ -0,0 +1,51 @@ +/** @file Run real workerd processes for tests. */ +import { spawn } from "node:child_process"; +import { rm } from "node:fs/promises"; +import { connect, createServer } from "node:net"; +import { join } from "node:path"; + +export const here = new URL("..", import.meta.url).pathname; +// the package binary, not the .bin shim: the shim forks it, so a kill would orphan workerd +const workerd = join(here, "node_modules/workerd/bin/workerd"); + +export const freePort = () => + new Promise((resolve) => { + const s = createServer().listen(0, "127.0.0.1", () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); + +/** Resolves once something accepts connections at `opts` (a port or a socket path). */ +export const accepting = (opts) => + new Promise((resolve) => { + const s = connect(opts, () => (s.end(), resolve(true))); + s.on("error", () => resolve(false)); + }); + +/** + * Run workerd on `config` until `stop()`; resolves once `listening` accepts. + * + * @param {string} config + * @param {string[]} args socket, directory, and external overrides + * @param {{ port: number } | { path: string }} listening + * @param {string[]} stale socket files a crash leaves behind; bind() would fail on them + */ +export const startWorkerd = async (config, args, listening, stale = []) => { + for (const f of stale) await rm(f, { force: true }); + const child = spawn(workerd, ["serve", "--experimental", join(here, config), ...args], { + stdio: ["ignore", process.env.WORKERD_LOG ? "inherit" : "ignore", "pipe"], + }); + let log = ""; + // WORKERD_LOG=1 echoes workerd's stderr, for debugging + child.stderr.on("data", (c) => (process.env.WORKERD_LOG ? process.stderr.write(c) : (log += c))); + const exited = new Promise((resolve) => child.on("exit", resolve)); + for (let i = 0; i < 100; i++) { + // SIGKILL: a crash is the harder durability test + if (await accepting(listening)) return { stop: () => (child.kill("SIGKILL"), exited) }; + await new Promise((r) => setTimeout(r, 50)); + } + child.kill("SIGKILL"); + throw new Error(`workerd did not start: ${log}`); +}; + diff --git a/toybox/vat-runtime.js b/toybox/vat-runtime.js new file mode 100644 index 0000000..8b9c6b4 --- /dev/null +++ b/toybox/vat-runtime.js @@ -0,0 +1,51 @@ +/** + * @file The runtime of an OCapN vat: SES, an OCapN peer, and the vat's own + * module (`vat.js`), which exports `buildRootObject(vatPowers, params)`. + * + * Bundled (see build.js) and loaded as the facet's main module beside the + * vat's code. The vat's designator is its id; it reaches other peers, and + * they reach it, through the vatAdmin's router (`env.router`). + */ +import "@endo/init"; +import { DurableObject } from "cloudflare:workers"; +import { E, Far } from "@endo/far"; +import { makeClient } from "@endo/ocapn"; +import { makeRpcNetLayer } from "./netlayer.js"; +import * as vat from "./vat.js"; + +/** + * The swissnum of a vat's bootstrap: the vatAdmin starts the vat through it. + * Not exported: workerd takes every export of a main module for an entrypoint. + */ +const VAT_SWISSNUM = "vat"; + +export class App extends DurableObject { + #gateway; + + constructor(ctx, env) { + super(ctx, env); + this.#gateway = this.#boot(); + } + + async #boot() { + const { vatId, router } = this.env; + let root; + const vatPowers = Object.freeze({ E, Far, storage: this.ctx.storage.kv }); + const bootstrap = Far("vatBootstrap", { + start: (params) => { + root ??= vat.buildRootObject(vatPowers, params); + return root; + }, + }); + const ocapn = makeClient({ debugLabel: vatId.slice(0, 8), swissnumTable: new Map([[VAT_SWISSNUM, bootstrap]]) }); + const netlayer = await ocapn.registerNetlayer( + makeRpcNetLayer({ designator: vatId, openGateway: (designator) => router.gateway(designator) }), + ); + return netlayer.gateway; + } + + /** This vat's OCapN gateway, for the router. */ + gateway() { + return this.#gateway; + } +} diff --git a/toybox/vats/greeter.js b/toybox/vats/greeter.js new file mode 100644 index 0000000..2503cc6 --- /dev/null +++ b/toybox/vats/greeter.js @@ -0,0 +1,6 @@ +/** A vat that greets through the `carol` it was created with. */ +export const buildRootObject = ({ E, Far }, { carol }) => + Far("greeterRoot", { + greetVia: (name) => E(carol).greet(name), + hello: () => "hello from the vat", + });