capsite/toybox: ingress is a capability; a hello-world web vat

E(vatAdmin).makeIngress(labels) reserves a mount and returns an ingress;
whoever holds it serves requests carrying those labels by calling
E(ingress).serve(handler). A vat gets one in its params, like any other
remote object; nothing in a vat's module is an entrypoint. A request
reaches the handler as passable data (method, path, labels, body), and
the response comes back as data (status, headers, body). The handler is
a live reference for now: after a restart its mount answers 503.

vats/hello.js serves hello, world; pnpm hello creates it on a running
toybox (client.js is the Node OCapN client, shared with the tests).

pnpm start and pnpm switchboard never ran: systemd-socket-activate wants
absolute paths for unix sockets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dckcandClaude Opus 5.5 committed 2026-10-13 21:44:29 -05:00
1 parent 8025ec4b20
commit a984461cee
6 files changed
+129 -32

No files matched your search

+43
View File
@@ -178,6 +178,8 @@ const encodeUtf8 = harden(TextEncoder.prototype.encode.bind(new TextEncoder()));
*/
export class VatAdmin extends DurableObject {
#clients = new Map();
/** mount key → the handler serving it (a live reference, for now) */
#handlers = new Map();
#ready;
constructor(ctx, env) {
@@ -200,6 +202,21 @@ export class VatAdmin extends DurableObject {
const root = await E(vatBootstrap).start(params);
return harden({ id: id.toString(), root });
},
/**
* Reserve a mount at `labels` and return the ingress for it: whoever
* holds the ingress (a vat it is passed to, say) serves requests
* that carry those labels.
*
* @param {Record<string, string>} labels
*/
makeIngress: async (labels) => {
const key = `ingress:${crypto.randomUUID()}`;
await this.env.DIRECTORY.getByName("directory").mount(key, harden({ ...labels }));
return Far("ingress", {
/** @param {{ handle: (request: object) => object }} handler */
serve: (handler) => void this.#handlers.set(key, handler),
});
},
});
// workerd runs FinalizationRegistry callbacks outside any request, where
// sending the resulting GC message is forbidden I/O. So imports are
@@ -240,6 +257,19 @@ export class VatAdmin extends DurableObject {
return this.env.VATS.get(id).gateway();
}
/**
* One HTTP request, as passable data, for the handler serving `key`;
* `null` when nothing serves it.
*
* @param {string} key
* @param {object} request
*/
async serveHttp(key, request) {
await this.#ready;
const handler = this.#handlers.get(key);
return handler ? E(handler).handle(harden(request)) : null;
}
/** 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));
@@ -361,6 +391,7 @@ export class Public extends WorkerEntrypoint {
}
const mount = await this.env.DIRECTORY.getByName("directory").lookup(labels);
if (!mount) return new Response("nothing mounted here\n", { status: 404 });
if (mount.vat.startsWith("ingress:")) return this.#serveOcapn(mount, labels, request);
const forwarded = new Request(request);
forwarded.headers.set("Toybox-Connection-Labels", JSON.stringify(mergeLabels(mount.labels, labels)));
const resp = await this.env.VATS.get(this.env.VATS.idFromString(mount.vat)).fetch(forwarded);
@@ -369,6 +400,18 @@ export class Public extends WorkerEntrypoint {
sealed.headers.set("Referrer-Policy", "no-referrer");
return sealed;
}
/** A mount made by `makeIngress`: the request goes, as data, to whoever serves it. */
async #serveOcapn(mount, labels, request) {
const { pathname } = new URL(request.url);
const body = ["GET", "HEAD"].includes(request.method) ? "" : await request.text();
const req = { method: request.method, path: pathname, labels: mergeLabels(mount.labels, labels), body };
const res = await this.env.VAT_ADMIN.getByName(VAT_ADMIN).serveHttp(mount.vat, req);
if (!res) return new Response("mounted, but nothing serves it now\n", { status: 503 });
const resp = new Response(res.body ?? "", { status: res.status ?? 200, headers: res.headers ?? {} });
resp.headers.set("Referrer-Policy", "no-referrer");
return resp;
}
}
/**