import { IncomingMessage, ServerResponse } from 'node:http'; /** * RelayBroker — pure logic for the SSE+POST tunnel described in * docs/relay-protocol.md, hostable in any Node-compatible runtime * (Node, Bun, Deno-with-Node-compat, Cloudflare Workers via the Web * standards subset). No HTTP framework opinions; this class just * stores sessions, validates tokens, enqueues frames, and produces * SSE event payloads ready to flush. * * const broker = new RelayBroker(); * const reg = broker.register("session-id", "token"); // ok / error * broker.inbox("session-id", "token", '{"jsonrpc":"2.0",…}'); // enqueue inbound * const sub = broker.subscribe("session-id", "token", "inbound"); * for await (const payload of sub.frames()) yield encodeSse(payload); * * Storage is an in-memory Map by default — fine for a single relay * process. To run multiple instances behind a load balancer, swap * `MemoryStore` for a Redis-backed equivalent (same Store interface). */ type Direction = "inbound" | "outbound"; type Session = { id: string; /** SHA-256 hex of the original token. Compared with timing-safe equals. */ tokenHash: string; /** Last touched (ms since epoch). Used for TTL cleanup. */ lastSeen: number; }; type Subscriber = { id: string; direction: Direction; queue: string[]; resolveNext: ((frame: string | null) => void) | null; /** * The caller-supplied client label, when one was given. * * Used to route a RESPONSE back to whoever asked. Without it the broker * cannot tell two holders of one bearer token apart, and every reply goes to * every subscriber -- so a second token holder passively receives the results * of everyone else's tool calls without making one. */ client?: string; /** * Present only for a LONG-POLL subscriber, which outlives any one request: * it is read by a series of `GET /poll` calls rather than one open stream, * so it needs its own liveness record. */ poll?: { /** Last time a poll started or finished (ms since epoch). */ lastSeen: number; /** Polls currently parked on this subscriber. Never pruned while > 0. */ parked: number; /** Wake every parked poll — a frame landed, or the session ended. */ wakers: Set<() => void>; }; /** Set once the subscriber has been ended (unsubscribed, pruned, or its session is gone). */ ended?: boolean; }; type RelayBrokerOptions = { /** Sessions auto-expire after this many ms of inactivity. Default 4h. */ ttlMs?: number; /** Cleanup tick interval in ms. Default 60 000. */ reapIntervalMs?: number; /** * A long-poll subscriber that has not polled for this long is dropped (and, * for an outbound one, the page is told the peer left). Checked on the reap * tick. Default 60 000 — the PHP relay's window, and comfortably longer than * the 25 s maximum park, so a healthy poller is never pruned between polls. */ pollIdleMs?: number; /** Bring-your-own storage layer (redis, etc.). Defaults to in-memory. */ store?: Store; }; interface Store { putSession(s: Session): void; getSession(id: string): Session | undefined; deleteSession(id: string): void; /** Used by the reap tick — return ids whose lastSeen < cutoff. */ expiredSessionIds(cutoff: number): string[]; } declare class RelayBroker { private readonly ttlMs; private readonly pollIdleMs; private readonly store; /** Per-session, per-direction subscriber list. */ private subs; /** Per-session map of JSON-RPC request id -> the client that asked. */ private callers; private reaper?; constructor(opts?: RelayBrokerOptions); dispose(): void; /** Register a session id + token. Idempotent — same id+token re-registers, * different token fails. */ register(id: string, token: string): { ok: true; } | { ok: false; reason: string; }; unregister(id: string, token: string): boolean; /** * Validate an authenticated touch, saying WHY when it fails. * * `session_gone` and `invalid_token` are different facts and a client acts on * them differently. A gone session means the page has closed or the TTL has * elapsed — the NORMAL end of a relay lifecycle, since the browser is the * server and no state persists across restarts. A bad token means the caller * is wrong. * * They were collapsed into one boolean and reported as `invalid_token`, so an * agent holding a perfectly good token against a closed page was told its * credentials were wrong. Not a missing signal — an actively misleading one, * which sends the reader to debug an auth path that was never the problem. * * Missing credentials stay `invalid_token`: a request that never named a * session has not discovered a dead one. * * The reason is deliberately NOT widened to cover a wrong token on a live * session — answering `session_gone` there would tell an unauthenticated * caller which sessions exist. * * Requested by the Prism harness while designing a non-Node relay client. */ check(id: string, token: string): { ok: true; } | { ok: false; reason: "invalid_token" | "session_gone"; }; /** * Validate an authenticated touch and slide the TTL forward. * * Kept as-is for existing callers; `check()` is the one that says why. */ validate(id: string, token: string): boolean; /** Drop a session — the page closed, or a test needs it gone. */ dropSession(id: string): void; /** Push a frame onto the inbound queue (external agent → browser). */ inbox(id: string, token: string, payload: string, opts?: { client?: string; }): boolean; /** Push a frame onto the outbound queue (browser server → external agents). */ outbox(id: string, token: string, payload: string): boolean; /** * Subscribe to a session's queue for one direction. Returns an iterable * the caller (an HTTP handler) pumps as SSE. */ subscribe(id: string, token: string, direction: Direction, opts?: { client?: string; }): SubscribeResult; /** * Attach a LONG-POLL subscriber, or re-attach the one a previous poll created. * * The CDN-safe receive leg: a series of short requests instead of one open * stream, because Cloudflare's HTTP/3 edge resets long-lived SSE. Same * contract as the PHP relay in px-ui-sandbox and the one * `@particle-academy/fancy-cf-relay` and `mcp-relay-client` speak: * * - The first poll gets a fresh 16-hex subscriber id; the client echoes it back * as `subscriber` on every later poll and reads the same queue. * - An id in that shape that this session does not know (pruned, say) is * adopted as a NEW subscriber under that id, as the PHP relay does, so a * client that went quiet and came back keeps its identity. * - An id belonging to a STREAMING subscriber is never adopted: one queue read * by two consumers splits its frames between them. * * Frames queue on the subscriber between polls, so nothing is lost in the gap * between one request ending and the next arriving. A poller that stops is * pruned after `pollIdleMs`. */ poll(id: string, token: string, direction: Direction, opts?: { subscriber?: string; client?: string; }): PollResult; private getDirSubs; /** * Record which client asked, keyed by JSON-RPC id. * * Only for REQUESTS — a frame with both an `id` and a `method`. A * notification answers nobody and a response is not a question. */ private rememberCaller; /** * The client a RESPONSE belongs to, or `null` when the frame is a broadcast. * * Returns the empty string for a response whose id nobody is recorded as * having asked — routed to NOBODY rather than to everyone. Broadcasting an * uncorrelated reply "just in case" is precisely the leak being closed. */ private callerFor; /** * Has any subscriber on this session told the broker who it is? * * The discriminator between a scoped session and a legacy one. Checked across * BOTH directions: the page subscribes inbound and rarely labels itself, so * asking only about the outbound side would misread a scoped session whose * agents have not yet attached. */ private anyIdentifiedSubscriber; private parseFrame; /** Deliver to the subscribers of one client only. */ private fanOutTo; private fanOut; /** * Hand one frame to one subscriber. * * The resolver is CLEARED before it is called. It used to stay set until the * generator resumed on a later microtask, so a second frame in the same tick * called an already-settled resolver — a no-op — after shifting its frame out * of the queue, and that frame was lost. Cleared first, the second frame just * waits in the queue for the generator to come back for it. */ private deliver; /** Tell the page (the inbound side) that an agent arrived or left. */ private announcePeer; /** End one subscriber: close its stream, or release its parked polls. Idempotent. */ private endSubscriber; /** * End every subscriber of a session whose session is going away. * * Streams used to be dropped from the map WITHOUT being woken, so an SSE leg * stayed open, heartbeating, for a session that no longer existed — and its * client could not learn the page had closed. Ended, the stream closes, the * client reconnects, and `events` answers 410. */ private endSubscribers; /** Remove a subscriber from its session, announcing an agent's departure once. */ private removeSubscriber; /** Drop long-poll subscribers that stopped polling. Never one with a poll parked. */ private prunePollers; /** * Validate that a peer-supplied payload is a well-formed JSON-RPC 2.0 frame * (or batch) before it is fanned out. Previously this was a substring match on * `"jsonrpc"`, which let any token holder inject spoofed frames — a forged * `notifications/peer_joined` / `peer_left` (broker-reserved presence control), * a fake `notifications/agent_activity {source:"user"}` ("human took control"), * or a forged response with a matching id. We now parse + shape-check, and * reject the broker-reserved control methods (the broker emits those itself, * never a peer). */ private isFrame; private isValidFrame; private reap; } type PollResult = { ok: false; reason: "invalid_token" | "session_gone"; } | { ok: true; /** The id the client must echo back as `subscriber` on its next poll. */ subscriberId: string; /** Whether this poll created the subscriber (and, outbound, announced it). */ created: boolean; /** * Park up to `ms` for frames, then take everything queued. Returns at once * when frames are already waiting, when `ms` is 0, or when the session * ends. An aborted `signal` returns `[]` and takes nothing. */ wait: (ms: number, signal?: AbortSignal) => Promise; /** Put frames back at the head of the queue — a response that could not be written. */ requeue: (frames: string[]) => void; }; type SubscribeResult = { ok: false; reason: string; } | { ok: true; subscriberId: string; frames: AsyncGenerator; unsubscribe: () => void; }; type NodeRelayOptions = RelayBrokerOptions & { /** URL path prefix (without trailing slash). Default `""` — handlers * expect paths like `/register`, `/{id}/inbox`, etc. directly. */ pathPrefix?: string; /** * Browser origins allowed to read relay responses: `*`, or a comma-separated * list of origins (`"https://example.com,https://www.example.com"`). * Default `*` — relays are typically called cross-origin from the demo host. * * A list is enforced per request: a listed `Origin` is echoed back as * `Access-Control-Allow-Origin` with `Vary: Origin`, and an unlisted one * gets no allow-origin header at all. `*` mixed into a list, an empty value, * `null` or an entry with a path throws here, at construction. */ corsAllowOrigin?: string; /** * The same allow-list as an array. When non-empty it takes precedence over * `corsAllowOrigin`. Recommended for any browser-facing deployment: it blunts * DNS rebinding and hostile cross-origin pages reading relay responses. When * neither names origins, `*` is used and the session token is the only auth * — pair that with a loopback bind. */ allowedOrigins?: string[]; }; type NodeHandler = (req: IncomingMessage, res: ServerResponse) => unknown | Promise; type NodeRelay = { broker: RelayBroker; /** Single-handler shape — routes internally based on method + URL. */ handler: NodeHandler; /** Per-route handlers. Each handler ignores the URL prefix and * acts on the path remainder, so you can mount them under any * prefix in your existing app. */ register: NodeHandler; inbox: NodeHandler; outbox: NodeHandler; events: NodeHandler; /** Long-poll receive leg — the CDN-safe alternative to `events`. */ poll: NodeHandler; unregister: NodeHandler; }; declare function createNodeRelay(opts?: NodeRelayOptions): NodeRelay; export { type Direction, type NodeHandler, type NodeRelay, type NodeRelayOptions, type PollResult, RelayBroker, type RelayBrokerOptions, type Session, type Store, type SubscribeResult, type Subscriber, createNodeRelay };