/** @file switchboard: Flower-style egress handles, served over Cap'n Web. */ import { WorkerEntrypoint } from "cloudflare:workers"; import { RpcTarget, newWorkersRpcResponse } from "capnweb"; import { checkUrl, mergeLabels } from "./labels.js"; /** * One egress handle: reaches only URLs within its labels, through the * network its `network` label names. Like a Flower switchboard handle, it * can only be narrowed; unlike one, it is a Cap'n Web stub, not an fd. */ export class Handle extends RpcTarget { #labels; #net; /** * @param {Record} labels * @param {Fetcher} net */ constructor(labels, net) { super(); this.#labels = labels; this.#net = net; } /** @param {Record} more */ constrain(more) { return new Handle(mergeLabels(this.#labels, more), this.#net); } labels() { return this.#labels; } /** @param {Request} request */ fetch(request) { checkUrl(this.#labels, request.url); return this.#net.fetch(request); } /** * Open a WebSocket to `url`; its messages cross the session as streams. * * @param {string} url `http:` or `https:`, as for a `fetch` upgrade * @param {string[]} [protocols] */ async webSocket(url, protocols = []) { checkUrl(this.#labels, url); const headers = { Upgrade: "websocket" }; if (protocols.length) headers["Sec-WebSocket-Protocol"] = protocols.join(", "); const resp = await this.#net.fetch(url, { headers }); const ws = resp.webSocket; if (!ws) throw new Error(`no websocket: ${resp.status}`); ws.accept(); const readable = new ReadableStream({ start(controller) { ws.addEventListener("message", (e) => controller.enqueue(e.data)); ws.addEventListener("close", () => controller.close()); ws.addEventListener("error", () => controller.error(new Error("websocket error"))); }, }); const writable = new WritableStream({ write: (chunk) => ws.send(chunk), close: () => ws.close(), abort: () => ws.close(1011), }); return { readable, writable, protocol: resp.headers.get("Sec-WebSocket-Protocol") ?? "" }; } } /** * A switchboard socket: a WebSocket upgrade here starts a Cap'n Web * session whose main object is a {@link Handle} with this socket's labels. */ export default class Root extends WorkerEntrypoint { fetch(request) { const labels = Object.freeze({ ...this.ctx.props }); const net = { public: this.env.PUBLIC, local: this.env.LOCAL }[labels.network]; if (!net) return new Response("socket has no network\n", { status: 500 }); return newWorkersRpcResponse(request, new Handle(labels, net)); } }