agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
129 lines (128 loc) • 6.87 kB
TypeScript
/**
* hosting/webSocketConversation — one upgraded socket, presented as a
* {@link HostConversation}.
*
* This is the whole adapter side of the conversation port: it takes the
* `'upgrade'` event a `node:http` server hands it, answers the handshake, and
* turns the frames flowing over the socket into the six-member port a handler
* sees. Everything protocol-shaped lives in `webSocketFrames.ts`; everything
* port-shaped lives in `types.ts`; this file is the join.
*
* ── The laws it keeps, all of them pinned by tests ───────────────────────────
* - **A handshake the door guard refuses never gets a 101.** A WebSocket is
* not bound by the same-origin policy and its browser API cannot set a
* header, so there is no preflight to force: the host's guard judges Host,
* Origin and Fetch metadata on the handshake itself, and the session id the
* dialect read is bounded before the channel opens.
* - **A path this door does not own is not touched.** `node:http` calls EVERY
* `'upgrade'` listener for every upgrade, exactly as it calls every
* `'request'` listener — so a caller's own protocol lives beside this one on
* the same socket. Whether an unclaimed path gets an answer is the caller's
* business on a server they own, and this door's only on a server it owns.
* - **Frames that arrive before the handler subscribes are held, up to a
* declared bound.** An `async` handler that awaits before calling `onFrame`
* would otherwise lose the far side's opening frame. The bound is in BYTES
* and overflow ends the conversation with a stated reason, because an
* unbounded queue somebody else fills is a way to kill this process, and an
* undeclared ceiling is the exact thing the declared-ceilings rule exists to
* forbid.
* - **`close()` ends every live conversation before the socket is released.**
* Measured, not assumed: an upgraded socket keeps `server.close()` waiting
* forever, so a door that let go of its conversations would hang the whole
* shutdown.
* - **The port's frame is the whole message.** A message the transport
* delivered in fragments counts against `maxFrameBytes` in total, so
* fragmentation cannot be used to walk around the ceiling.
* - **Nothing in a conversation's lifecycle is ever the PROCESS's failure.**
* Node calls a socket's listeners from its own stack, so a throw inside one
* is uncaught and ends the container. Every listener body here that computes
* is wrapped: a surprise costs THIS conversation, with a reason, and the
* door keeps carrying everybody else's.
*
* Pattern: Adapter. Role: outer ring, one transport.
*/
/// <reference types="node" />
/// <reference types="node" />
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import { type DoorGuard } from './doorGuard.js';
import { type ResolvedHostSignIn } from './signin/hostSignIn.js';
import type { ConversationHandler, ConversationLimits, HostRefusal } from './types.js';
/**
* What a wire read out of one handshake — the conversation half of a
* deployment's dialect.
*
* There is no body to read here, which is the whole difference from a request:
* a handshake is a URL and some headers, so a dialect that wants a session id
* or a credential has to find it in those.
*/
export interface ConversationHandshake {
/** The session this conversation claims. Caller data; never identity. */
readonly sessionId?: string;
/**
* Headers to merge OVER the raw lower-cased ones the transport delivered.
*
* This is how an adapter maps its own spelling into the port's vocabulary —
* a credential a browser could only send as a subprotocol becomes an ordinary
* `authorization` header here. The raw headers are never removed, so nothing
* a mapping did not understand is lost.
*/
readonly headers?: Readonly<Record<string, string>>;
/**
* The subprotocol to echo in the 101, when this dialect selects one.
*
* Selecting is the wire's job because only the wire read the offer. Echoing
* something the client did not offer makes the client fail the connection.
*/
readonly protocol?: string;
}
/** Everything the door needs to answer one upgrade. */
export interface ConversationDoorOptions {
/** The adapter's name — every refusal carries it. */
readonly hostName: string;
/** The path this door owns. Anything else is not ours. */
readonly path: string;
/** The ceilings this door declares, already defaulted. */
readonly limits: ConversationLimits;
/** This deployment's handshake dialect. Absent ⇒ raw headers and no session. */
readonly readConversation?: (facts: HandshakeFacts) => ConversationHandshake;
/** Where a new conversation goes. */
readonly handler: ConversationHandler;
/** Whether the host is still taking conversations — false after `close()`. */
readonly accepting: () => boolean;
/**
* The host's door guard — Host and Origin (and the browser's Fetch
* metadata) judged on the handshake, BEFORE the 101. Absent, no handshake is
* judged, which is the conversation door as it was before the guard.
*/
readonly guard?: DoorGuard;
/** Told about every handshake this door refuses on the guard's or the session bound's word. */
readonly onRefusal?: (refusal: HostRefusal) => void;
/**
* The host's sign-in cookie. Present: the cookie is stripped from the
* conversation's headers, the handshake is verified BEFORE the 101, and a
* socket admitted by a sign-in is re-checked before every inbound frame and
* closed when the sign-in ends. Absent: the door as it was.
*/
readonly signIn?: ResolvedHostSignIn;
}
/** What a handshake dialect may read. Deliberately the same shape a request wire gets, minus the body. */
export interface HandshakeFacts {
/** Header names lower-cased. */
readonly headers: Readonly<Record<string, string>>;
/** The query string, already parsed — where a browser has to put things it cannot header. */
readonly query: URLSearchParams;
}
/** A door: hand it upgrades, close it when you are done. */
export interface ConversationDoor {
/**
* Take one upgrade, or decline it. Returns whether this door claimed the
* path — the caller decides what an unclaimed upgrade deserves.
*/
handleUpgrade(request: IncomingMessage, socket: Duplex): boolean;
/** End every live conversation politely, then resolve. */
closeAll(reason: string): Promise<void>;
/** How many conversations are open right now. */
readonly liveCount: number;
}
export declare function conversationDoor(options: ConversationDoorOptions): ConversationDoor;