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) <noreply@anthropic.com>
This commit is contained in:
1 parent
8d8900d84d
commit
9356f63c64
13 files changed
+983
-61
No files matched your search
+157
-10
@@ -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<string>} 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": <id>, "hash": <code 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;
|
||||
|
||||
Reference in new issue
Block a user