agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,079 lines • 52.4 kB
JavaScript
/**
* hosting/httpHost — one HTTP host, parameterised by the JSON dialect it speaks.
*
* Everything hard about serving an agent over HTTP happens once, here: draining
* on close, aborting when the caller hangs up, failing a handler that throws,
* failing a handler that answers nothing, mapping refusal codes onto status
* codes, and choosing between one JSON body and Server-Sent Events based on
* what the caller asked for.
*
* Everything a deployment target gets to re-decide is an {@link HttpWire}: the
* two paths, and the JSON body shapes — how a request names its input and its
* session, and what a health probe, a completion, a failure, a streamed piece,
* a pending question and a resolved artifact look like on the wire.
*
* ── Why this file exists ─────────────────────────────────────────────────────
* `nodeHost` shipped first and hard-coded its own dialect: `{ input }` in,
* `{ output }` out, `{ status: 'ok' }` on the health path. That was fine while
* it was the only HTTP adapter and wrong the moment there was a second one. A
* container runtime dictates its own body shape as surely as it dictates its
* paths, and an adapter for it should be a CONFIGURATION of the HTTP work, not
* a second copy of it — a copy is where the drain semantics of two adapters
* silently diverge.
*
* Note what did NOT have to change for that: the ports. `AgentHost`,
* `HostRequest` and `HostReply` say exactly what they said before. The gap was
* in the first adapter, which had no seam, not in the port, which needed none.
*
* ── The second thing a deployment gets to re-decide: who owns the socket ─────
* By default this file creates a server and listens on it. Pass `server` and it
* attaches to yours instead — because a container is sometimes given exactly
* one port, and an agent that privately owns the socket cannot share it with a
* WebSocket upgrade or with routes that were there first. Attached, the host
* answers its two paths, writes nothing on anyone else's, and `close()`
* detaches and drains without closing a socket it never opened. That is the
* whole difference; every other law on this page is the same either way.
*
* ── …and the same seam, inverted: `onUnhandled` ──────────────────────────────
* `server` lends the host a socket somebody else owns. `onUnhandled` lends the
* CALLER every path this host does not own on a socket the host owns. One port
* either way; which side binds it is the only difference. The host still never
* answers for the application — with this hook it no longer has to 404 for it
* either. Refused alongside `server`, where unmatched paths are already the
* caller's and a second answer would just race the first.
*
* ── The law under all of it ──────────────────────────────────────────────────
* **Nothing in a request's lifecycle may ever be the process's failure.** Every
* listener body on this page that computes is wrapped, because node calls them
* from its own stack and a throw there is uncaught — the death of a container,
* bought with one malformed request. Stated in full at `readJson`, which is
* where the field found it.
*
* ── The third door: a conversation ───────────────────────────────────────────
* `serveConversations(handler)` sits beside `serve(handler)` and takes upgrades
* on `conversationPath`, because `HostRequest → HostReply` is one exchange and
* some doors are not. Give a host a `conversationPath` and it declares
* `'conversation'`; leave it out and `serveConversations` refuses by name.
*
* **Both doors share ONE socket.** That is not an optimisation, it is the
* premise: the runtimes that need a conversation are the ones that hand a
* container exactly one port, so a host whose two doors each bound their own
* would fail with `EADDRINUSE` on the deployment it exists for. On a private
* socket the server is created by whichever door opens first and closed by
* whichever closes last; on a caller-owned one each door attaches and detaches
* its own listener and neither touches the socket.
*
* ── The door guard, on both doors ────────────────────────────────────────────
* Being able to reach the port is not permission: every browser inside the
* network can be steered by any page it opens. So before any handler sees a
* request, the request door and the conversation door ask one guard
* (`doorGuard.ts`): a Host this door was not configured for (421), a browser
* origin it does not allow (403), a state-changing request that does not say
* it is JSON (415) — and, once a dialect has read it, a session id over the
* bound (400). Judged on the headers alone — a refused body is never parsed,
* only drained when small so its sender reads the answer — and never invited
* with a `100 Continue` on a socket this host owns. Answered with the
* refusal's own sentence, reported to `onRefusal` listeners. Probes (the
* health GET, the HEAD probe) are never asked; routes handed to `onUnhandled`
* are the caller's. A loopback bind with `allowedHosts` unset answers the
* loopback names only.
*
* Pattern: Template method via configuration (Strategy on the wire format).
* Everything HTTP lives here and in the wires; `types.ts` knows none of it.
*/
import { encodeSSE } from './stream.js';
import { checkSessionId, doorGuard, warnAllowedHostsUnset, warnBrowserRulesOff, } from './doorGuard.js';
import { ArtifactNotCarriedError, HostClosedError, InvalidWireOpError, RequestTooLargeError, SessionsNotCarriedError, WireRequestRefusal, } from './errors.js';
import { REFUSAL_STATUS } from './refusalStatus.js';
import { SESSION_LIST_OP, SESSION_TRANSCRIPT_OP, SESSION_PENDING_OP } from './sessionWire.js';
import { lowerCasedHeaders } from './headers.js';
import { bearerToken } from './identityVerification.js';
import { readSignIn } from './signin/cookie.js';
import { checkHostSignIn } from './signin/hostSignIn.js';
import { conversationDoor, } from './webSocketConversation.js';
/**
* What a run that stopped to ask a person answers with: **202 Accepted.**
*
* The request was understood and acted on, and the work is not finished — which
* is what 202 means and what nothing else in the 2xx range means. Not 200: there
* is no answer. Not 4xx or 5xx: nothing was refused and nothing broke.
*/
const AWAITING_STATUS = 202;
const DEFAULT_CAPABILITIES = ['streaming'];
/**
* What a conversation door caps when the deployment did not say.
*
* Both are memory this process pays for while somebody else fills it, so
* "unlimited" is not one of the options — and since what is enforced must be
* what is declared, the resolved numbers are what the host reports.
* One mebibyte is chosen as a number large enough that no ordinary protocol
* frame meets it and small enough that a hostile one cannot spend the heap;
* a deployment with a real ceiling passes its own.
*/
const DEFAULT_CONVERSATION_LIMITS = {
maxFrameBytes: 1_048_576,
maxPendingBytes: 1_048_576,
};
/**
* An HTTP host for one handler, speaking the dialect you hand it.
*
* @example The same machinery, two dialects
* httpHost({ name: 'nodeHost', wire: jsonWire, invokePath: '/invoke', healthPath: '/health' });
* httpHost({ name: 'myRuntime', wire: myWire, invokePath: '/v1/run', healthPath: '/up' });
*/
export function httpHost(options) {
const { name, wire, invokePath, healthPath, conversationPath } = options;
const { maxBodyBytes, invokeHeadProbe = false } = options;
if (maxBodyBytes !== undefined && (!Number.isInteger(maxBodyBytes) || maxBodyBytes <= 0)) {
throw new TypeError(`[hosting] ${name}: maxBodyBytes must be a positive integer; received ${String(maxBodyBytes)}.`);
}
const ownServer = options.server;
// Refused at construction, not ignored at serve time: a `port` next to a
// server this host does not bind is a caller who believes something untrue
// about where their agent will answer, and silently dropping it is how they
// stay believing it.
if (ownServer && (options.port !== undefined || options.hostname !== undefined)) {
throw new Error(`[hosting] httpHost('${name}') was given both a caller-owned 'server' and a ` +
`'${options.port !== undefined ? 'port' : 'hostname'}'. A server you own already has ` +
`an address, and this host binds nothing when you pass one. Drop the port/hostname, ` +
`or drop the server and let this host bind its own socket.`);
}
// Refused at construction for the same reason and in the same breath: on a
// server the caller owns, unmatched paths ALREADY reach their own listeners,
// so a hook for them here is a second answer to one question and the winner
// would be whichever listener was registered first.
const onUnhandled = options.onUnhandled;
if (ownServer && onUnhandled !== undefined) {
throw new Error(`[hosting] httpHost('${name}') was given both a caller-owned 'server' and an ` +
`'onUnhandled'. On a server you own, a path this host does not own is already yours — ` +
`it falls through to your own 'request' listeners untouched — so this hook would be a ` +
`second answer to the same question. Route unmatched paths on your server, or drop ` +
`'server' and let this host own the socket and hand you what it does not answer.`);
}
const port = options.port ?? 8080;
const hostname = options.hostname ?? '0.0.0.0';
const signIn = checkHostSignIn(name, options.signIn, {
...(options.allowedOrigins !== undefined && { allowedOrigins: options.allowedOrigins }),
...(options.allowedHosts !== undefined && { allowedHosts: options.allowedHosts }),
...(options.requireJsonContentType !== undefined && {
requireJsonContentType: options.requireJsonContentType,
}),
...(!ownServer && { bindHost: hostname }),
});
/**
* The paths this host answers on. Everything else is unowned and may be
* handed to `onUnhandled` — the conversation path included in the ownership,
* because a door that takes upgrades still OWNS its path when something
* arrives on it that is not one.
*/
const ownsPath = (path) => path === invokePath || path === healthPath || path === conversationPath;
// A door that exists is a capability that can be honoured; one built without
// a path is not, and says so rather than being discovered at runtime.
const capabilities = options.capabilities ??
(conversationPath !== undefined
? [...DEFAULT_CAPABILITIES, 'conversation']
: DEFAULT_CAPABILITIES);
// Resolved once, declared as resolved: `host.conversationLimits` is what is
// actually enforced, never what was passed in and quietly topped up.
const conversationLimits = {
...DEFAULT_CONVERSATION_LIMITS,
...options.conversationLimits,
};
// ── The door guard ─────────────────────────────────────────────────
//
// Built at construction, so a configuration it could only misread (an
// `allowedOrigins` of 'null', an empty `allowedHosts`) is refused here, by
// name, before a socket exists. Both doors ask it; every refusal it makes —
// and every session id the bound refuses — goes to the listeners below,
// which is how a request no handler ever saw still reaches an ingress
// record.
//
// A LOOPBACK bind this host makes itself, with `allowedHosts` unset, answers
// the loopback names only: such a socket can be reached by no other name, so
// the default refuses nobody and closes DNS rebinding on a developer's own
// machine. (A caller-owned server's address is the caller's; it is not
// guessed.)
const guard = doorGuard({
name,
...(!ownServer && { bindHost: hostname }),
...(options.allowedOrigins !== undefined && { allowedOrigins: options.allowedOrigins }),
...(options.allowedHosts !== undefined && { allowedHosts: options.allowedHosts }),
...(options.requireJsonContentType !== undefined && {
requireJsonContentType: options.requireJsonContentType,
}),
});
const refusalListeners = new Set();
/** Tell every listener; a listener that throws is contained — the refusal is already answered. */
function reportRefusal(refusal) {
for (const listener of [...refusalListeners]) {
try {
listener(refusal);
}
catch {
// A broken log must not turn an answered refusal into this process's failure.
}
}
}
/** The staged default and the rules-off notice, each said once at boot (per host name, per process). */
function announceBoot() {
if (guard.allowedHostsUnset)
warnAllowedHostsUnset(name);
if (guard.browserRulesOff)
warnBrowserRulesOff(name);
}
// ── The socket both doors stand on ─────────────────────────────────
//
// Refcounted, because the deployment this exists for hands a container ONE
// port: two doors that each bound their own would be `EADDRINUSE` on exactly
// the machine that needed both. First door in creates and listens; last door
// out drains and closes. On a caller-owned server there is nothing to count —
// the socket was never ours to open or to close.
let socket;
let holders = 0;
async function acquireSocket() {
if (ownServer) {
if (!ownServer.listening) {
throw new Error(`[hosting] httpHost('${name}') was handed a server that is not listening yet. ` +
`This handle promises the url and port it is answering on, and a server with ` +
`no address has neither. Call server.listen(...) first, then serve() — ` +
`attaching after listen() is safe and is the intended order.`);
}
return ownServer;
}
holders += 1;
socket ??= (async () => {
const { createServer } = await import('node:http');
const created = createServer();
await new Promise((resolve, reject) => {
created.once('error', reject);
created.listen(port, hostname, resolve);
});
return created;
})();
try {
return await socket;
}
catch (err) {
// A socket that never came up is not one anybody is holding.
holders -= 1;
socket = undefined;
throw err;
}
}
async function releaseSocket() {
if (ownServer)
return;
holders -= 1;
if (holders > 0)
return;
const held = socket;
socket = undefined;
if (!held)
return;
const server = await held.catch(() => undefined);
if (!server)
return;
server.closeIdleConnections();
await new Promise((resolve) => server.close(() => resolve()));
}
/** Where this host is answering — the caller's address, or the one we bound. */
function addressOf(server) {
const address = server.address();
const tcp = typeof address === 'object' && address !== null ? address : undefined;
if (ownServer && !tcp) {
throw new Error(`[hosting] httpHost('${name}') was handed a server bound to ${JSON.stringify(address)}` +
` — a pipe or socket path, which has no port. This handle promises a url and a ` +
`port, and inventing one would be worse than refusing. Serve on a TCP server, or ` +
`drive the handler yourself.`);
}
const boundPort = tcp ? tcp.port : port;
// With our own socket the requested hostname IS the answer; with the
// caller's we ask the socket, because we never chose it.
const boundHost = ownServer ? tcp?.address ?? '127.0.0.1' : hostname;
const displayHost = boundHost === '0.0.0.0' || boundHost === '::' ? '127.0.0.1' : boundHost;
return { url: `http://${displayHost}:${boundPort}`, port: boundPort };
}
let conversationsServing = false;
return {
name,
capabilities,
conversationLimits,
onRefusal(listener) {
refusalListeners.add(listener);
return () => {
refusalListeners.delete(listener);
};
},
async serveConversations(handler) {
if (conversationPath === undefined) {
throw new Error(`[hosting] the '${name}' host was built without a 'conversationPath', so it has no ` +
`door to take conversations on and does not declare 'conversation' in its ` +
`capabilities. Build it with a conversationPath, or feature-detect with ` +
`host.capabilities.includes('conversation') and use serve() instead.`);
}
if (conversationsServing) {
throw new Error(`[hosting] the '${name}' host is already serving conversations on ` +
`'${conversationPath}'. One door, one handler — two would make which handler ` +
`receives a conversation depend on registration order. Close the first handle ` +
`before serving again.`);
}
conversationsServing = true;
let accepting = true;
let closing;
const door = conversationDoor({
hostName: name,
path: conversationPath,
limits: conversationLimits,
...(wire.readConversation && { readConversation: wire.readConversation.bind(wire) }),
handler,
accepting: () => accepting,
guard,
onRefusal: reportRefusal,
...(signIn !== undefined && { signIn }),
});
const onUpgrade = (request, socketOfConversation) => {
// THE LAW again, on the other door: an `'upgrade'` listener runs on
// node's own stack too, so a throw here is uncaught and would end every
// OTHER conversation — and every request — for one bad handshake.
let claimed;
try {
claimed = door.handleUpgrade(request, socketOfConversation);
}
catch {
// The door answers its own refusals; anything that got past them
// costs THIS socket and nothing else.
socketOfConversation.destroy();
return;
}
if (claimed)
return;
// Not our path. On the caller's server that is theirs to answer or to
// ignore, exactly as an unmatched request path is; on ours nobody else
// can, so leaving the socket hanging would be the one dishonest option.
//
// `onUnhandled` deliberately does NOT extend here: it is a hook for
// ROUTES, and an upgrade is a protocol handover with a socket to answer
// by hand rather than a response to write. An unclaimed upgrade on a
// private socket keeps exactly the answer it has always had.
if (ownServer)
return;
socketOfConversation.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\n\r\n' +
`[hosting] the '${name}' host takes conversations on ${conversationPath}.`);
};
let server;
try {
server = await acquireSocket();
}
catch (err) {
conversationsServing = false;
throw err;
}
announceBoot();
server.on('upgrade', onUpgrade);
let where;
try {
where = addressOf(server);
}
catch (err) {
server.off('upgrade', onUpgrade);
conversationsServing = false;
await releaseSocket();
throw err;
}
return {
url: where.url,
port: where.port,
close() {
closing ??= (async () => {
accepting = false;
if (ownServer)
server.off('upgrade', onUpgrade);
// End the live conversations BEFORE letting go of the socket. An
// upgraded socket keeps `server.close()` waiting forever — measured,
// not assumed — so a door that walked away from its conversations
// would hang every shutdown that shares this socket.
await door.closeAll(`the '${name}' host is shutting down`);
if (!ownServer)
server.off('upgrade', onUpgrade);
conversationsServing = false;
await releaseSocket();
})();
return closing;
},
};
},
async serve(handler) {
const startedAt = Date.now();
const inFlight = new Set();
let accepting = true;
let closing;
/**
* Answer a door-guard refusal, report it, and close the connection.
*
* The body the caller is still sending is never parsed. When it was
* INVITED (no `Expect: 100-continue`, so the client is already
* uploading) and is small — at most {@link REFUSAL_DRAIN_BYTES}, within
* {@link REFUSAL_DRAIN_MS} — it is read and discarded first, so the
* client finishes its upload and READS the refusal instead of meeting a
* reset: closing a socket with unread bytes in it makes the kernel send
* RST, which a client mid-upload reports as `ECONNRESET` and never sees
* the 415. A body announced larger than that, or one that overruns it,
* is not drained — the answer goes out at once and a client still
* sending may see the reset. That is the bound, stated.
*/
const refuseAtRequestDoor = (req, res, refusal, bodyInvited) => {
reportRefusal({
door: 'request',
error: refusal,
bearerPresent: bearerToken(lowerCasedHeaders(req.headers)) !== undefined,
});
const answer = () => {
try {
if (res.headersSent || res.writableEnded)
return;
sendJson(res, refusal.status, wire.failure(refusal.message, refusal.code), {
connection: 'close',
});
}
catch (err) {
failSafely(res, wire, err);
}
};
if (bodyInvited)
drainThenAnswer(req, answer);
else
answer();
};
/**
* `Expect: 100-continue` on a socket this host owns: judge the headers
* BEFORE node would say "100 Continue", so a refused request is never
* invited to send the body it is about to be refused for. Everything
* else gets its 100 and the ordinary route, exactly as node's default.
* (On a caller-owned server node answers 100 itself before any listener
* runs — that server's `checkContinue` is the caller's to own.)
*/
const onCheckContinue = (req, res) => {
try {
const path = (req.url ?? '').split('?')[0];
if (accepting && req.method === 'POST' && path === invokePath) {
const crossSite = guard.check(req);
if (crossSite !== undefined) {
refuseAtRequestDoor(req, res, crossSite, false);
return;
}
}
res.writeContinue();
route(req, res);
}
catch (err) {
failSafely(res, wire, err);
}
};
const route = (req, res) => {
// On a shared server an earlier listener may already have answered.
// Writing again would corrupt its reply; there is nothing to add.
if (res.headersSent)
return;
const path = (req.url ?? '').split('?')[0];
if (req.method === 'GET' && path === healthPath) {
sendJson(res, 200, wire.health(Date.now() - startedAt));
return;
}
// A capability probe, not a turn: nothing is read and nothing is run,
// so it is answered before the body machinery below ever sees it.
if (invokeHeadProbe && req.method === 'HEAD' && path === invokePath) {
res.writeHead(204, { allow: 'HEAD, POST' });
res.end();
return;
}
if (req.method !== 'POST' || path !== invokePath) {
// On a server we own, an unmatched path is nobody else's, so saying
// so is the honest answer. On a server the CALLER owns it is theirs,
// and a 404 from us would answer for their application.
if (ownServer)
return;
// …and on a socket THIS host owns, the caller can still claim the
// paths it does not: their code, here, INSTEAD of our 404. What the
// host owns never reaches them — a wrong method on an owned path is
// still this host's question to answer, and a route that could shadow
// a door would be a second door with the same name.
if (onUnhandled !== undefined && !ownsPath(path)) {
onUnhandled(req, res);
return;
}
sendJson(res, 404, wire.failure(`no route for ${req.method ?? '?'} ${path}`));
return;
}
if (!accepting) {
const refusal = new HostClosedError(name);
sendJson(res, REFUSAL_STATUS[refusal.code] ?? 500, wire.failure(refusal.message, refusal.code));
return;
}
// The door guard, on the headers alone: a forged request's body is
// never parsed. See `refuseAtRequestDoor` for what happens to the
// bytes it is still sending.
const crossSite = guard.check(req);
if (crossSite !== undefined) {
refuseAtRequestDoor(req, res, crossSite, true);
return;
}
const served = serveOne(req, res, handler, wire, name, maxBodyBytes, reportRefusal, signIn);
inFlight.add(served);
// `serveOne` never rejects, by construction — see its doc.
void served.finally(() => inFlight.delete(served));
};
// THE LAW (see `readJson`): nothing in a request's lifecycle may ever be
// the process's failure. Routing computes — a wire's `health()`, a body
// that has to stringify, a caller's `onUnhandled` — and every one of
// those is somebody else's code running on node's own stack, where a
// throw is uncaught. So the whole body is wrapped, and whatever went
// wrong is answered to the request that caused it.
const onRequest = (req, res) => {
try {
route(req, res);
}
catch (err) {
failSafely(res, wire, err);
}
};
const server = await acquireSocket();
announceBoot();
server.on('request', onRequest);
if (!ownServer)
server.on('checkContinue', onCheckContinue);
let where;
try {
where = addressOf(server);
}
catch (err) {
server.off('request', onRequest);
server.off('checkContinue', onCheckContinue);
await releaseSocket();
throw err;
}
return {
url: where.url,
port: where.port,
close() {
// Idempotent: the first call owns the shutdown, later ones await it.
closing ??= (async () => {
accepting = false;
if (ownServer) {
// Detach FIRST. The paths stop being ours the moment close() is
// called, so a request arriving now falls through to the caller
// rather than collecting a refusal from a host that is leaving.
server.off('request', onRequest);
// Then drain what we are already serving — and stop there. The
// socket, the idle connections and the shutdown are the caller's.
await Promise.allSettled([...inFlight]);
return;
}
// Drain BEFORE touching sockets — an in-flight request is work the
// caller is still waiting on, and dropping it would be the exact
// thing close() promises not to do.
await Promise.allSettled([...inFlight]);
server.off('request', onRequest);
server.off('checkContinue', onCheckContinue);
// The socket goes only when the LAST door lets go of it. A
// conversation still open on this port is not this door's to end.
await releaseSocket();
})();
return closing;
},
};
},
};
}
/**
* Run one request through the handler and write whatever it decides.
*
* Total by construction: this promise NEVER rejects. It is held in a Set and
* voided at the call site, so a rejection would be an unhandled rejection —
* which on node's defaults is the same crash the law above forbids, reached by
* a different road.
*/
async function serveOne(req, res, handler, wire, hostName, maxBodyBytes, reportRefusal, signIn) {
try {
await dispatchOne(req, res, handler, wire, hostName, maxBodyBytes, reportRefusal, signIn);
}
catch (err) {
// Everything inside answers its own failures; anything that got past them
// — a dialect that threw, a body that would not stringify — is still this
// REQUEST's failure and not the process's.
failSafely(res, wire, err);
}
}
async function dispatchOne(req, res, handler, wire, hostName, maxBodyBytes, reportRefusal, signIn) {
const controller = new AbortController();
let body;
try {
body = await readJson(req, maxBodyBytes, hostName);
}
catch (err) {
// A body over the ceiling is not a malformed body, and telling the caller
// its JSON was invalid would send them looking for a syntax error that is
// not there.
if (err instanceof RequestTooLargeError) {
sendJson(res, REFUSAL_STATUS[err.code] ?? 413, wire.failure(err.message, err.code));
return;
}
sendJson(res, 400, wire.failure(`invalid JSON body: ${asError(err).message}`));
return;
}
// The sign-in cookie comes off here, before any dialect or handler reads a
// header, and only its KEY goes on. Without a sign-in cookie configured this
// is the plain lower-casing it always was.
const { headers, key: signInKey } = signIn === undefined
? { headers: lowerCasedHeaders(req.headers), key: undefined }
: readSignIn(req.headers, signIn.cookieName);
const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
const facts = { body, headers, query };
// Read AFTER the body, because a dialect that carries the choice in the body
// cannot answer before there is one. Defaults to the HTTP question when the
// dialect does not say.
const wantsStream = wire.wantsStream
? wire.wantsStream(facts)
: (req.headers.accept ?? '').includes('text/event-stream');
let read;
try {
read = wire.readRequest(facts);
}
catch (err) {
// A body that NAMED a wire operation this dialect does not speak — or
// named one and left out what it needs. The request is what is wrong, so
// it gets its own 400 with the teaching refusal, and never falls through
// to a model turn. Anything else a dialect throws stays what it always
// was: this request's 500, via serveOne's catch.
if (err instanceof InvalidWireOpError) {
sendJson(res, REFUSAL_STATUS[err.code] ?? 400, wire.failure(err.message, err.code, facts));
return;
}
// A dialect that looked at the body and refused it by name — an input kind
// it does not carry, a field of the wrong type. Same law as above: the
// REQUEST is what is wrong, so it gets a status the caller can act on and
// never falls through to a model turn.
if (err instanceof WireRequestRefusal) {
sendJson(res, err.status, wire.failure(err.message, err.code, facts));
return;
}
throw err;
}
// The session-id bound, on whatever the dialect read it from — a body field,
// a header, a cookie — and on the id a session-history op names inside
// itself. Here, once, so no dialect can forget it, and before anything is
// framed: an id past this line rides every span of every turn it touches.
const unboundedSession = checkSessionId(read.sessionId, hostName) ?? checkSessionId(sessionOpId(read.session), hostName);
if (unboundedSession !== undefined) {
sendJson(res, unboundedSession.status, wire.failure(unboundedSession.message, unboundedSession.code, facts));
reportRefusal({
door: 'request',
error: unboundedSession,
bearerPresent: bearerToken(headers) !== undefined,
});
return;
}
const { input, sessionId, userId, decision, artifact, session, responseHeaders } = read;
// The dialect's own reply headers — a `Set-Cookie` that issues a session, and
// nothing else so far. Stripped of `content-type` because the framing below
// is this host's to decide: a dialect that set it would be choosing between
// one JSON body and Server-Sent Events on the caller's behalf.
const extraHeaders = withoutContentType(responseHeaders);
// One framing per streaming response, so whatever it remembers across its
// frames belongs to this caller alone.
const framing = wantsStream ? wire.stream?.(facts) ?? defaultFraming(wire, facts) : undefined;
if (wantsStream) {
res.writeHead(200, {
...extraHeaders,
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
});
writeFrames(res, framing?.open?.() ?? []);
}
let settled = false;
let handlerThrew = false;
// The caller hung up before we answered — tell the handler so it can stop
// paying for work nobody is waiting for.
res.once('close', () => {
try {
if (!settled)
controller.abort();
}
catch {
// An abort listener the HANDLER installed threw while being told the
// caller left. That is a fault in somebody's teardown, and a fault in
// teardown is not a reason for this process to stop serving everyone.
}
});
const reply = {
emit(chunk) {
// Not streaming? The chunk is a preview of text `complete` will deliver
// in full, so there is nothing to send and nothing to keep.
if (settled || !wantsStream)
return;
writeFrames(res, framing?.chunk(chunk) ?? []);
},
complete(output) {
if (settled)
return;
settled = true;
if (wantsStream) {
writeFrames(res, framing?.complete(output) ?? []);
res.end();
}
else {
sendJson(res, 200, wire.output(output, facts), extraHeaders);
}
},
artifact(result) {
if (settled)
return;
// A wire that cannot describe an artifact result must not answer 200
// with an improvised body — a shape no client was written against is a
// blank pane with extra steps. The named refusal says what resolved and
// why this wire could not carry it.
if (!wire.artifact) {
reply.fail(new ArtifactNotCarriedError(result.ref, hostName));
return;
}
settled = true;
const payload = wire.artifact(result);
if (wantsStream) {
writeFrames(res, framing?.artifact?.(result) ?? [{ event: 'artifact', data: payload }]);
res.end();
}
else {
sendJson(res, 200, payload, extraHeaders);
}
},
sessions(result) {
if (settled)
return;
// Same law as `artifact` above: a wire that cannot describe the result
// says so by name rather than answering 200 with a shape no client was
// written against.
if (!wire.sessions) {
reply.fail(new SessionsNotCarriedError(result.op === 'list'
? SESSION_LIST_OP
: result.op === 'pending'
? SESSION_PENDING_OP
: SESSION_TRANSCRIPT_OP, hostName));
return;
}
settled = true;
const payload = wire.sessions(result);
if (wantsStream) {
writeFrames(res, framing?.sessions?.(result) ?? [{ event: 'sessions', data: payload }]);
res.end();
}
else {
sendJson(res, 200, payload, extraHeaders);
}
},
awaiting(pending) {
if (settled)
return;
// A wire that cannot describe a question must not answer 202 with an
// empty body — that would look like a completed request. Fall through to
// the named refusal, which at least says what happened and where the
// paused run is.
if (!wire.awaiting) {
const refusal = new Error(`[hosting] the run is waiting on a person and this host's wire has no ` +
`awaiting() body shape, so the question cannot be described on the wire. ` +
`The paused run is stored; read the pending ask from the session store.`);
refusal.code = 'ERR_PAUSE_NOT_CARRIED';
reply.fail(refusal);
return;
}
settled = true;
const payload = wire.awaiting(pending);
if (wantsStream) {
writeFrames(res, framing?.awaiting?.(pending) ?? [{ event: 'awaiting', data: payload }]);
res.end();
}
else {
sendJson(res, AWAITING_STATUS, payload, extraHeaders);
}
},
fail(error) {
if (settled)
return;
settled = true;
const code = error.code;
const origin = handlerThrew ? 'threw' : 'refused';
const payload = wire.failure(error.message, code, facts, origin);
if (wantsStream) {
writeFrames(res, framing?.failure(error.message, code, origin) ?? []);
res.end();
}
else {
sendJson(res, code !== undefined ? REFUSAL_STATUS[code] ?? 500 : 500, payload, extraHeaders);
}
},
};
try {
await handler({
input,
...(sessionId !== undefined && { sessionId }),
...(userId !== undefined && { userId }),
...(decision !== undefined && { decision }),
...(artifact !== undefined && { artifact }),
...(session !== undefined && { session }),
headers,
...(signInKey !== undefined && { signInKey }),
signal: controller.signal,
}, reply);
}
catch (err) {
// A handler that throws is a failed request, never a hung one — and the
// words it threw are not the same kind of thing as the words a handler
// CHOOSES to fail with. Dialects that care are told which this was.
handlerThrew = true;
reply.fail(asError(err));
}
// A handler that returned without answering gets one authored answer rather
// than a socket the caller waits on until it times out.
if (!settled) {
reply.fail(new Error('[hosting] the handler returned without calling complete() or fail().'));
}
}
/**
* The body, as JSON — and never as the process's failure.
*
* ── A chunk is not always a Buffer ───────────────────────────────────────────
* `'data'` delivers whatever encoding the stream is in, and the encoding is not
* this host's to assume. Anything else holding the same request may have called
* `req.setEncoding(...)` — a co-listener on a shared server, a framework that
* installs its own catch-all `'request'` handler — and from that moment every
* chunk arrives as a STRING. `Buffer.concat` on strings throws, and it threw
* INSIDE the `'end'` listener. Field-reported, and process-fatal: see the law
* below.
*
* A string chunk is coerced back rather than refused, and no bytes are lost
* doing it: `setEncoding` decodes through a `StringDecoder`, which holds a
* partial multi-byte sequence across a chunk boundary rather than splitting it,
* so what arrives is whole text and `Buffer.from(chunk, 'utf8')` reproduces
* exactly the bytes that were sent.
*
* ── THE LAW, stated where it was broken ─────────────────────────────────────
* **Nothing in a request's lifecycle may ever be the process's failure.**
* `node:http` calls these listeners from its own stack: a throw inside one is
* not a rejected promise anybody awaits and not an exception anybody catches —
* it is an UNCAUGHT exception, and an uncaught exception ends the process. One
* malformed request would take every other request with it, every open
* conversation, and — on the shared socket this file exists for — everything
* else the container was serving.
*
* So every listener body here that COMPUTES is wrapped, not just the line that
* was found to break: a surprise this file has not imagined yet becomes THIS
* REQUEST's 400 or 500, the failure of the thing that caused it.
*/
/** The largest refused body read-and-discarded so its sender sees the refusal. */
const REFUSAL_DRAIN_BYTES = 1_048_576;
/** How long a refused body may take to arrive before the answer goes out anyway. */
const REFUSAL_DRAIN_MS = 2_000;
/**
* Read and discard a refused request's body — bounded in bytes and in time —
* then answer. A body announced over the bound (or none at all) is answered at
* once. Every listener is wrapped by `answer` itself, so nothing here can be
* the process's failure.
*/
function drainThenAnswer(req, answer) {
const declared = req.headers['content-length'];
const chunked = req.headers['transfer-encoding'] !== undefined;
const length = declared === undefined ? 0 : Number(declared);
if (!chunked && !(length > 0 && length <= REFUSAL_DRAIN_BYTES)) {
answer();
return;
}
let read = 0;
let done = false;
const finish = () => {
if (done)
return;
done = true;
clearTimeout(timer);
req.off('data', onData);
req.off('end', finish);
req.off('error', finish);
answer();
};
const onData = (chunk) => {
read += chunk.length;
if (read > REFUSAL_DRAIN_BYTES)
finish();
};
const timer = setTimeout(finish, REFUSAL_DRAIN_MS);
if (typeof timer.unref === 'function')
timer.unref();
req.on('data', onData);
req.once('end', finish);
req.once('error', finish);
}
/**
* Write a framing's frames, in order, as Server-Sent Events.
*
* A framing that returns nothing writes nothing — which is how "this lifecycle
* point has no frame in this dialect" is said.
*/
function writeFrames(res, frames) {
for (const frame of frames)
res.write(encodeSSE(frame.event, frame.data));
}
/**
* This host's own stream shape, expressed as a {@link StreamFraming}.
*
* One `chunk` frame per piece, one terminal frame, both carrying the wire's own
* body shapes — byte for byte what this file wrote before the seam existed. It
* lives here as an ordinary framing rather than as a branch, so the shape every
* shipped adapter depends on is exercised by the same code path a dialect's own
* framing takes.
*/
function defaultFraming(wire, facts) {
return {
chunk: (text) => [{ event: 'chunk', data: wire.chunk(text) }],
complete: (output) => [{ event: 'complete', data: wire.output(output, facts) }],
failure: (message, code, origin) => [
{ event: 'error', data: wire.failure(message, code, facts, origin) },
],
};
}
/**
* Read one JSON body, refusing at the ceiling rather than after it.
*
* The refusal fires on the chunk that CROSSES the line: what was buffered is
* dropped and nothing further is kept, so a caller cannot spend this process's
* memory by sending a body it was always going to be refused for. With no
* ceiling the read is what it has always been.
*/
function readJson(req, maxBodyBytes, hostName) {
return new Promise((resolve, reject) => {
let chunks = [];
let bytes = 0;
let refused = false;
req.on('data', (c) => {
if (refused)
return;
try {
const chunk = typeof c === 'string' ? Buffer.from(c, 'utf8') : c;
bytes += chunk.byteLength;
if (maxBodyBytes !== undefined && bytes > maxBodyBytes) {
refused = true;
// Let go of what was read: this request is already answered, and
// holding its bytes until the socket drains is the cost the ceiling
// exists to refuse.
chunks = [];
reject(new RequestTooLargeError(maxBodyBytes, hostName));
return;
}
chunks.push(chunk);
}
catch (err) {
reject(asError(err));
}
});
req.on('error', (err) => {
if (!refused)
reject(err);
});
req.on('end', () => {
if (refused)
return;
try {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw)
return resolve({});
const parsed = JSON.parse(raw);
resolve(parsed && typeof parsed === 'object' ? parsed : {});
}
catch (err) {
reject(asError(err));
}
});
});
}
/** The conversation a session-history op names inside itself, when it names one. */
function sessionOpId(session) {
return session !== undefined && 'sessionId' in session ? session.sessionId : undefined;
}
function sendJson(res, status, body, extraHeaders) {
res.writeHead(status, { ...extraHeaders, 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
/**
* A dialect's reply headers, minus the one it does not get to choose.
*
* `content-type` is the framing, and the framing is this host's decision — one
* JSON body or a stream of SSE frames, picked from what the CALLER asked for. A
* wire that set it would be answering that question for every caller at once.
*/
function withoutContentType(headers) {
if (headers === undefined)
return undefined;
const kept = {};
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() === 'content-type')
continue;
kept[name] = value;
}
return Object.keys(kept).length > 0 ? kept : undefined;
}
/**
* The last answer a request can be given: its own 500.
*
* This is the floor under the law — reached where the ORDINARY reply path is
* what went wrong (a dialect that threw, a body that would not stringify, a
* caller's route handler that raised), so it assumes as little as possible and
* cannot itself throw.
*
* A response already committed to the wire is ENDED rather than overwritten:
* two half-answers on one socket is worse than one answer that stopped early,
* and a status line cannot be taken back.
*/
function failSafely(res, wire, err) {
const message = asError(err).message;
try {
if (res.writableEnded)
return;
if (res.headersSent) {
res.end();
return;
}
let body;
try {
body = JSON.stringify(wire.failure(message));
}
catch {
// The DIALECT is what broke. Answer in the one shape nothing can refuse.
body = JSON.stringify({ error: message });
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(body);
}
catch {
// The socket went away while we were answering it. There is nothing left
// to say, and nothing here that is the process's business either.
}
}
function asError(err) {
return err instanceof Error ? err : new Error(String(err));
}
/**
* Read a header case-insensitively from already-lower-cased facts.
*
* Exported because every wire that maps a header needs it and re-deriving it
* per adapter is how one of them ends up matching only the exact casing the
* author happened to test with.
*
* Takes anything carrying lower-cased headers, so a request wire and a
* handshake wire read a header the same way rather than each growing their own.
*/
export function headerValue(facts, name, ...fallbacks) {
for (const candidate of [name, ...fallbacks]) {
const found = facts.headers[candidate.toLowerCase()];
if (typeof found === 'string' && found.length > 0)
return found;
}
return undefined;
}
//# sourceMappingURL=httpHost.js.map