agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
565 lines • 25.9 kB
JavaScript
/**
* 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.
*/
import { checkSessionId } from './doorGuard.js';
import { ConversationClosedError, FrameTooLargeError } from './errors.js';
import { lowerCasedHeaders } from './headers.js';
import { readSignIn } from './signin/cookie.js';
import { checkHandshake, signInStillLive } from './signin/hostSignIn.js';
import { bearerToken } from './identityVerification.js';
import { CLOSE_CODE, decodeClose, decodeText, encodeClose, encodeFrame, encodeText, FrameProtocolError, FrameReader, isControlFrame, OPCODE, handshakeResponse, } from './webSocketFrames.js';
/** How long a politely-closed socket has to say goodbye before it is dropped. */
const CLOSE_GRACE_MS = 1_000;
export function conversationDoor(options) {
const live = new Set();
return {
get liveCount() {
return live.size;
},
handleUpgrade(request, socket) {
const url = request.url ?? '';
if (url.split('?')[0] !== options.path)
return false;
// From here the upgrade is ours, and every exit answers it.
socket.on('error', () => undefined);
if (!options.accepting()) {
refuse(socket, 503, `the '${options.hostName}' host is closed and is not taking new conversations`);
return true;
}
const { headers, key: signInKey } = options.signIn === undefined
? { headers: lowerCasedHeaders(request.headers), key: undefined }
: readSignIn(request.headers, options.signIn.cookieName);
// The door guard, before anything else about the handshake is read. 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 browser's
// own Origin (and Sec-Fetch-Site) is the whole defence, and it has to be
// judged here — after the 101 the channel is open.
const crossSite = options.guard?.check(request);
if (crossSite !== undefined) {
refuseAtDoor(socket, crossSite, headers, options.onRefusal);
return true;
}
const key = headers['sec-websocket-key'];
const upgrade = (headers.upgrade ?? '').toLowerCase();
if (upgrade !== 'websocket' || headers['sec-websocket-version'] !== '13' || !key) {
refuse(socket, 400, `the '${options.hostName}' host serves version-13 WebSocket conversations on ` +
`${options.path}, and this upgrade did not offer one`);
return true;
}
const facts = {
headers,
query: new URLSearchParams(url.split('?')[1] ?? ''),
};
// A dialect is somebody else's code, and it runs on node's own stack: a
// throw here is UNCAUGHT and would end the process — every other
// conversation on this door and every request beside them — for one
// handshake it could not read. Nothing in a request's lifecycle may ever
// be the process's failure, and a handshake is the start of one.
let read;
try {
read = options.readConversation?.(facts) ?? {};
}
catch (err) {
refuse(socket, 500, `the '${options.hostName}' host could not read this handshake: ${asMessage(err)}`);
return true;
}
// The session-id bound, on whatever the dialect read — a header, a
// cookie, a query parameter. Judged before the 101, because after it the
// id would be on the conversation and in everything it touches.
const unboundedSession = checkSessionId(read.sessionId, options.hostName);
if (unboundedSession !== undefined) {
refuseAtDoor(socket, unboundedSession, { ...headers, ...read.headers }, options.onRefusal);
return true;
}
const admit = () => {
socket.write(handshakeResponse(key, read.protocol));
const watched = options.signIn?.identity.signIn;
const conversation = openConversation(socket, {
hostName: options.hostName,
limits: options.limits,
...(read.sessionId !== undefined && { sessionId: read.sessionId }),
headers: { ...headers, ...read.headers },
...(signInKey !== undefined && { signInKey }),
...(signInKey !== undefined &&
watched !== undefined && { watch: { source: watched, key: signInKey } }),
});
live.add(conversation);
void conversation.ended.finally(() => live.delete(conversation));
// A handler that throws ends THAT conversation and nothing else: one
// caller's bad frame is not an outage for everyone else on this door.
void (async () => {
try {
await options.handler(conversation.port);
}
catch (err) {
conversation.end(`the conversation handler threw: ${asMessage(err)}`);
}
})();
};
if (options.signIn === undefined) {
admit();
return true;
}
// The handshake is a GET, and a cross-site socket can read replies: it is
// verified HERE, before the 101 — the same funnel a request goes through,
// so a sign-in, a bearer token and "two credentials" answer exactly as
// they do at the request door.
const signIn = options.signIn;
void checkHandshake(signIn, { ...headers, ...read.headers }, signInKey)
.then((refusal) => {
if (socket.destroyed || !socket.writable)
return;
if (refusal !== undefined) {
refuseAtDoor(socket, Object.assign(refusal.error, { status: refusal.status }), headers, options.onRefusal);
return;
}
if (!options.accepting()) {
refuse(socket, 503, `the '${options.hostName}' host is closed and is not taking new conversations`);
return;
}
admit();
})
.catch((err) => {
refuse(socket, 500, `the '${options.hostName}' host could not verify this handshake: ${asMessage(err)}`);
});
return true;
},
async closeAll(reason) {
const ending = [...live];
for (const conversation of ending)
conversation.end(reason);
// Waited on, not fired and forgotten: an upgraded socket that is still
// open keeps `server.close()` waiting forever, so "the door is closed"
// has to mean the sockets are actually gone.
await Promise.allSettled(ending.map((conversation) => conversation.ended));
},
};
}
/** The statuses this door answers a pre-101 socket with. */
const REFUSAL_REASON = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
415: 'Unsupported Media Type',
421: 'Misdirected Request',
500: 'Internal Server Error',
503: 'Service Unavailable',
};
/** Answer an upgrade we will not carry, in the one language a pre-101 socket speaks. */
function refuse(socket, status, message) {
refuseWithText(socket, status, `[hosting] ${message}.`);
}
function refuseWithText(socket, status, text) {
const reason = REFUSAL_REASON[status] ?? 'Bad Request';
socket.end(`HTTP/1.1 ${status} ${reason}\r\n` +
`content-type: text/plain\r\n` +
`content-length: ${Buffer.byteLength(text)}\r\n` +
`connection: close\r\n\r\n${text}`);
}
/**
* Refuse a handshake on the door guard's or the session bound's word — the
* error's own status and sentence, which never repeat what the caller sent —
* and tell the host's refusal listeners. The listener runs AFTER the answer is
* written, and a listener that throws costs nothing but its own record: a
* broken log must not turn a refusal into a hung socket.
*/
function refuseAtDoor(socket, error, headers, onRefusal) {
refuseWithText(socket, error.status, error.message);
if (onRefusal === undefined)
return;
try {
onRefusal({
door: 'conversation',
error,
bearerPresent: bearerToken(headers) !== undefined,
});
}
catch {
// The refusal is answered; a listener's fault is the listener's.
}
}
/**
* One socket, presented as a conversation.
*
* Written as a closure rather than a class because everything here is one
* socket's state and none of it is anybody else's business — there is nothing
* to subclass and nothing to inject.
*/
function openConversation(socket, options) {
const { hostName, limits, sessionId } = options;
const maxFrameBytes = limits.maxFrameBytes;
const maxPendingBytes = limits.maxPendingBytes;
const reader = new FrameReader(maxFrameBytes);
const frameSubscribers = new Set();
const closeSubscribers = new Set();
/** Frames the far side sent before anybody was listening. Bounded (see below). */
let pending = [];
let pendingBytes = 0;
/** The message being assembled out of fragments, if any. */
let assembling = [];
let assemblingBytes = 0;
let assemblingText = false;
let ending;
let resolveEnded;
const ended = new Promise((resolve) => {
resolveEnded = resolve;
});
function finish(close, wireCode, wireReason) {
if (ending !== undefined)
return;
ending = close;
// Saying goodbye is the part that touches the transport, so it is the part
// that can fail — and a failure to say goodbye must not stop the handler
// being TOLD, or leave `ended` unresolved and every shutdown sharing this
// socket waiting on it.
try {
if (wireCode !== undefined && socket.writable) {
socket.write(encodeClose(wireCode, wireReason ?? close.reason));
}
// end() flushes what is queued and then sends FIN — the polite half of
// the close. The timer is the impolite half, for a peer that never
// answers.
socket.end();
const grace = setTimeout(() => socket.destroy(), CLOSE_GRACE_MS);
if (typeof grace.unref === 'function')
grace.unref();
socket.once('close', () => clearTimeout(grace));
}
catch {
// The socket could not be told. It is going away either way, and the
// conversation is over regardless of how politely.
socket.destroy();
}
const subscribers = [...closeSubscribers];
closeSubscribers.clear();
for (const cb of subscribers) {
try {
cb(close);
}
catch {
// A close subscriber that throws has nothing left to be told; the
// conversation is already over and there is nothing to protect.
}
}
resolveEnded();
}
/**
* The sign-in re-check (rule 21): a socket admitted by a sign-in hands on no
* frame until the sign-in is confirmed live, in arrival order. A sign-in that
* ended closes the socket with 1008 and the frame is never delivered; a store
* that cannot answer closes it with 1011 — an outage, never a sign-out.
*
* Frames that arrive while a check runs are COALESCED behind ONE next check
* (review idI57 N-13): every frame is still handed on only after a check that
* began after it arrived, but a burst costs two store lookups, not one per
* frame. The bytes waiting count against `maxPendingBytes`.
*/
let waiting = [];
let checking = false;
/** Bytes of frames waiting on the re-check — bounded like frames waiting for a subscriber. */
let gatedBytes = 0;
function deliver(frame) {
const watch = options.watch;
if (watch === undefined) {
deliverNow(frame);
return;
}
gatedBytes += Buffer.byteLength(frame, 'utf8');
if (maxPendingBytes !== undefined && gatedBytes > maxPendingBytes) {
finish({
by: 'host',
reason: `${gatedBytes} bytes were waiting on the sign-in re-check, past the declared ` +
`maxPendingBytes of ${maxPendingBytes}`,
}, CLOSE_CODE.tooBig);
return;
}
waiting.push(frame);
if (!checking)
void recheck(watch);
}
async function recheck(watch) {
checking = true;
try {
while (waiting.length > 0 && ending === undefined) {
const batch = waiting;
waiting = [];
const state = await signInStillLive(watch.source, watch.key);
for (const frame of batch)
gatedBytes -= Buffer.byteLength(frame, 'utf8');
if (ending !== undefined)
return;
if (state === 'live') {
for (const frame of batch)
deliverNow(frame);
}
else if (state === 'ended') {
signInEnded();
}
else {
finish({ by: 'host', reason: 'the sign-in could not be checked (its store did not answer)' }, CLOSE_CODE.internalError);
}
}
}
finally {
checking = false;
}
}
function signInEnded() {
finish({ by: 'host', reason: 'the sign-in ended' }, CLOSE_CODE.policyViolation);
}
function deliverNow(frame) {
if (ending !== undefined)
return;
if (frameSubscribers.size === 0) {
pendingBytes += Buffer.byteLength(frame, 'utf8');
if (maxPendingBytes !== undefined && pendingBytes > maxPendingBytes) {
pending = [];
finish({
by: 'host',
reason: `${pendingBytes} bytes arrived before onFrame(...) was subscribed, past the ` +
`declared maxPendingBytes of ${maxPendingBytes}`,
}, CLOSE_CODE.tooBig);
return;
}
pending.push(frame);
return;
}
for (const cb of [...frameSubscribers]) {
try {
cb(frame);
}
catch (err) {
// Feeding more frames to a consumer whose parser already threw is how a
// channel keeps looking healthy while nothing on it is being read.
finish({ by: 'host', reason: `a frame subscriber threw: ${asMessage(err)}` });
return;
}
}
}
function handleFrame(frame) {
if (ending !== undefined)
return;
if (isControlFrame(frame.opcode)) {
if (frame.opcode === OPCODE.ping) {
// Answered because the protocol says so, and because a peer's liveness
// check is not the same thing as this port inventing a heartbeat.
if (socket.writable)
socket.write(encodeFrame(OPCODE.pong, frame.payload));
return;
}
if (frame.opcode === OPCODE.pong)
return;
const said = decodeClose(frame.payload);
finish({ by: 'far-side', ...(said.reason !== undefined && { reason: said.reason }) }, said.code === CLOSE_CODE.noStatus ? CLOSE_CODE.normal : said.code, said.reason);
return;
}
if (frame.opcode === OPCODE.binary) {
// Refused by name rather than silently stringified: this port carries
// text, and binary is a capability nobody has shown evidence for yet.
finish({
by: 'host',
reason: 'a binary frame arrived, and this port carries text frames only — binary is a ' +
'capability that will be minted when a consumer needs it, not guessed at now',
}, CLOSE_CODE.unsupportedData);
return;
}
if (frame.opcode === OPCODE.text) {
if (assemblingText) {
protocolFailure(new FrameProtocolError(CLOSE_CODE.protocolError, 'a new message started before the previous fragmented one finished'));
return;
}
assemblingText = true;
}
else if (!assemblingText) {
protocolFailure(new FrameProtocolError(CLOSE_CODE.protocolError, 'a continuation frame arrived with no message to continue'));
return;
}
assemblingBytes += frame.payload.length;
if (maxFrameBytes !== undefined && assemblingBytes > maxFrameBytes) {
// The ceiling is on the MESSAGE, so a peer cannot fragment its way past it.
protocolFailure(new FrameProtocolError(CLOSE_CODE.tooBig, `a fragmented message reached ${assemblingBytes} bytes, past the declared ` +
`maxFrameBytes of ${maxFrameBytes}`));
return;
}
assembling.push(frame.payload);
if (!frame.fin)
return;
const whole = assembling.length === 1 ? assembling[0] : Buffer.concat(assembling);
assembling = [];
assemblingBytes = 0;
assemblingText = false;
let text;
try {
text = decodeText(whole);
}
catch (err) {
protocolFailure(err);
return;
}
deliver(text);
}
function protocolFailure(err) {
const code = err instanceof FrameProtocolError ? err.closeCode : CLOSE_CODE.protocolError;
finish({ by: 'far-side', reason: asMessage(err) }, code, asMessage(err));
}
/**
* One socket's version of the law the request door states: **nothing in a
* conversation's lifecycle may ever be the process's failure.** An upgraded
* socket is a plain stream and node calls its listeners from its own stack,
* so a throw inside one is uncaught — and would end every OTHER conversation
* on this door, plus the requests beside them, for one peer's bad frame.
* Wrapped, it ends THIS conversation and says why.
*/
function contained(body) {
return (...args) => {
try {
body(...args);
}
catch (err) {
finish({ by: 'host', reason: `this conversation could not be read: ${asMessage(err)}` }, CLOSE_CODE.internalError);
socket.destroy();
}
};
}
// Chunks here are always BYTES, and that is node's guarantee rather than this
// door's assumption: an upgraded socket refuses `setEncoding` outright
// (`ERR_HTTP_SOCKET_ENCODING`, "not allowed per RFC7230 Section 3"), so the
// co-listener that can turn a REQUEST body into strings cannot do it to a
// conversation. Pinned by a test, because the reason this is safe lives in
// node and could change there.
socket.on('data', contained((chunk) => {
if (ending !== undefined)
return;
let frames;
try {
frames = reader.push(chunk);
}
catch (err) {
protocolFailure(err);
return;
}
for (const frame of frames)
handleFrame(frame);
}));
socket.on('error', contained((err) => {
finish({ by: 'transport', reason: err.message });
socket.destroy();
}));
// BOTH halves, and `'end'` is the one that matters. An upgraded socket is
// half-open: when the far side vanishes, node delivers `'end'` and then waits
// for THIS side to end too — no `'error'`, and no `'close'` until we act. A
// door listening only for `'close'` would never fire onClose for a caller
// that walked away, and would hold a socket open that keeps every shutdown
// sharing this port waiting. Measured, not assumed.
socket.on('end', contained(() => {
finish({ by: 'transport', reason: 'the connection ended without a close frame' });
}));
socket.on('close', contained(() => {
// No close frame from either side: the connection went away rather than
// ended, and those are different facts to whoever is watching.
finish({ by: 'transport', reason: 'the connection closed without a close frame' });
}));
// Close on sign-out: told by the source when the sign-in ends, not only
// found on the next frame. Feature-detected; unsubscribed when this ends.
const watch = options.watch;
if (watch?.source.onEnd !== undefined) {
const unsubscribe = watch.source.onEnd((ended) => {
if (ended === watch.key)
signInEnded();
});
void ended.then(unsubscribe);
}
const port = {
...(sessionId !== undefined && { sessionId }),
headers: options.headers,
...(options.signInKey !== undefined && { signInKey: options.signInKey }),
send(frame) {
if (ending !== undefined)
throw new ConversationClosedError(hostName, sessionId);
const bytes = Buffer.byteLength(frame, 'utf8');
if (maxFrameBytes !== undefined && bytes > maxFrameBytes) {
throw new FrameTooLargeError(hostName, bytes, maxFrameBytes);
}
socket.write(encodeText(frame));
},
onFrame(cb) {
frameSubscribers.add(cb);
if (pending.length > 0) {
const held = pending;
pending = [];
pendingBytes = 0;
for (const frame of held)
cb(frame);
}
return () => {
frameSubscribers.delete(cb);
};
},
onClose(cb) {
// Already over? Say so now. A subscriber that arrives late is asking the
// same question as one that arrived early, and "never" is a wrong answer.
if (ending !== undefined) {
cb(ending);
return () => undefined;
}
closeSubscribers.add(cb);
return () => {
closeSubscribers.delete(cb);
};
},
close(reason) {
finish({ by: 'host', ...(reason !== undefined && { reason }) }, CLOSE_CODE.normal, reason);
},
};
return {
port,
ended,
end(reason) {
finish({ by: 'host', reason }, CLOSE_CODE.goingAway, reason);
},
};
}
function asMessage(err) {
return err instanceof Error ? err.message : String(err);
}
//# sourceMappingURL=webSocketConversation.js.map