a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a plugin installed afterwards could never add a socket route. That was a hard ceiling, not a preference: /minesweeper had to live in the platform and plugins/desktop could not stream a display at all. The way out was already in the tree and had one consumer. dev-proxy serves a socket with no WS_ROUTES entry, through a WILDCARD route whose handler calls server.upgrade itself. Nothing has to become dynamic, because a plugin name is a path segment rather than a route — one wildcard declared at boot serves every plugin that will ever be installed. Authorisation is the PLUGIN's own capability, asked as if the socket were a GET against its HTTP prefix. That is the safety argument: there is no second permission to grant, forget, or drift, so granting a plugin grants its socket and revoking it closes it. It is also why plugin-ws is exempt from the boot totality check rather than carrying a registry entry — one entry covering every plugin's socket would mean granting one plugin granted them all. A target is a TCP address and never a URL, which keeps the relay ignorant of what it carries. That ignorance is the only reason one relay can serve an RFB display and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an arbitrary host would be a request-forgery primitive handed to every plugin author. sidecarPort() is new because createSidecarProxy learned announced ports per-proxy and that state was unreachable from anywhere else — a second subscription would have been a second thing to keep in step. Verified: boots past the totality gate, and both an existing and a non-existent plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte. Minesweeper is about to be the first caller, which is what proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -120,6 +120,25 @@ export function unregisterSidecar(id: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Every sidecar's announced HTTP port, by name.
|
||||
|
||||
`createSidecarProxy` already learns this for the prefix it serves, by subscribing to `<name>:server`. That
|
||||
is per-proxy state and unreachable from anywhere else, which was fine while the only consumer was the
|
||||
proxy itself — and stopped being fine when a plugin needed a WEBSOCKET relayed onto the same sidecar. A
|
||||
second subscription in a second place would be a second thing to keep in step.
|
||||
|
||||
So the port is recorded here, once, from the same event. A sidecar that has not announced yet is absent
|
||||
rather than zero: "not listening" and "listening on nothing" are different answers and only one of them is
|
||||
true.
|
||||
*/
|
||||
const sidecarPorts = new Map<string, number>();
|
||||
|
||||
/** The port a named sidecar announced, or null if it has not announced one. */
|
||||
export function sidecarPort(name: string): number | null {
|
||||
return sidecarPorts.get(name) ?? null;
|
||||
}
|
||||
|
||||
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
// Check if this is a response to a pending request
|
||||
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
||||
@@ -130,6 +149,13 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record any `<name>:server` announcement before dispatching, so `sidecarPort` is current by the time a
|
||||
// subscriber reacts to the same event.
|
||||
if ('type' in msg && typeof msg.type === 'string' && msg.type.endsWith(':server')) {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port === 'number') sidecarPorts.set(msg.type.slice(0, -':server'.length), port);
|
||||
}
|
||||
|
||||
// Otherwise dispatch as event
|
||||
dispatchEvent(msg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user