/** * 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. * * 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 type { IncomingMessage, Server, ServerResponse } from 'node:http'; import type { ArtifactWireRequest, ArtifactWireResult } from './artifactWire.js'; import type { SessionWireRequest, SessionWireResult } from './sessionWire.js'; import type { AgentHost, ConversationHandler, ConversationHost, ConversationLimits, HostCapability, HostHandle, HostHandler, PendingAsk } from './types.js'; import { type ConversationHandshake, type HandshakeFacts } from './webSocketConversation.js'; export type { ConversationHandshake, HandshakeFacts }; /** Everything a {@link HttpWire} may read when pulling a request apart. */ export interface HttpRequestFacts { /** The parsed JSON body, or `{}` for an empty one. */ readonly body: Readonly>; /** Request headers with lower-cased names — so a wire never has to guess casing. */ readonly headers: Readonly>; /** The query string, already parsed. */ readonly query: URLSearchParams; } /** * The JSON dialect one deployment target speaks. * * A wire is pure: it reads facts and returns values. It never touches the * socket, never decides a status code, and never knows whether the reply is * going out as one body or as a stream of frames — those are {@link httpHost}'s * job, identical for every wire, which is the entire point of separating them. */ /** * One Server-Sent Event: the name it is announced under, and the body that * rides with it. */ /** * Where a failure came from. * * `'refused'` is the handler CHOOSING to fail with words it picked for the * caller. `'threw'` is an exception this host caught — the message is the * author's note to their own logs, and may name a query, a path or a token. * * The distinction exists because a dialect that sanitises has to sanitise the * right one: replacing both silences deliberate refusals, and replacing * neither publishes stack-trace prose to whoever is on the other end. */ export type FailureOrigin = 'refused' | 'threw'; export interface StreamFrame { readonly event: string; readonly data: unknown; } /** * The frames ONE streaming response is made of. * * ── Why this exists ────────────────────────────────────────────────────────── * A wire's body methods answer "what do the fields say". This answers a * different question: "what SHAPE is a stream". This host's own dialect frames * one homogeneous `chunk` per piece and one terminal frame, and that is a real * shape — but it is not the only one. Several protocols in wide use frame a * stream as a LIFECYCLE instead: named events that open a response, announce * that output is beginning, carry deltas, close each part, and close the * response — every event referring to one response object by id, often * numbered. * * Those cannot be expressed as "a body shape per chunk", because one host * lifecycle point becomes SEVERAL frames, and because the frames share state * (an id minted once, a counter that only goes up). So a dialect that frames * this way supplies one of these PER RESPONSE and keeps that state in it. * * ── The default is one of these ────────────────────────────────────────────── * A wire that supplies none gets framing built from its own body methods — * `chunk`/`complete`/`error`, exactly as before. The incumbent shape is an * INSTANCE of this seam rather than a special case beside it, which is the * reason to believe the seam is in the right place. */ export interface StreamFraming { /** * Frames to write the moment the stream opens, before the handler runs. * Absent or empty means the stream announces itself with nothing, which is * this host's own behaviour. */ open?(): readonly StreamFrame[]; /** Frames for one piece of streamed output. */ chunk(text: string): readonly StreamFrame[]; /** Frames that close a response that completed. */ complete(output: string): readonly StreamFrame[]; /** Frames that close a response that failed. */ failure(message: string, code?: string, origin?: FailureOrigin): readonly StreamFrame[]; /** * Frames that close a response ending in one of the other terminals. Absent * means the single-frame default, so a framing written for text alone keeps * working when an artifact or a paused run comes back through it. */ awaiting?(pending: PendingAsk): readonly StreamFrame[]; artifact?(result: ArtifactWireResult): readonly StreamFrame[]; sessions?(result: SessionWireResult): readonly StreamFrame[]; } export interface HttpWire { /** * Pull the port's vocabulary out of one request. Anything the wire cannot * find is simply absent — a missing input is the empty string, and a missing * session id means "no session", never an error, because refusing a request * on the shape of its body is a policy decision that belongs above the * transport. */ readRequest(facts: HttpRequestFacts): { readonly input: string; readonly sessionId?: string; /** * The end user this request is for, when this dialect has a place the * transport puts one (9.12.0). Lands on {@link HostRequest.userId}, whose * note says what it is worth and what it is not. * * Optional, and a dialect with no such place returns nothing — the honest * answer for a wire whose transport never carried a user. Deriving one from * the session id would be this file inventing an actor. */ readonly userId?: string; /** * A person's answer to an outstanding question, when this request carries * one. Its presence is what makes a request a RESUME rather than a new * message, so a wire that never returns it can only ever start new turns. */ readonly decision?: unknown; /** * An artifact operation this request carries instead of a message * (9.23.0). Read it with `readArtifactWireOp(facts.body)` — the one owner * of the `{ op: 'artifact-head' | 'artifact-get', ref }` grammar — rather * than re-deriving the op names per dialect. A dialect that never returns * it simply cannot serve artifact resolution; a dialect that DOES must * also implement {@link HttpWire.artifact}, or every resolved ref answers * with the named not-carried refusal. */ readonly artifact?: ArtifactWireRequest; /** * A session-history operation this request carries instead of a message * (9.26.0). Read it with `readSessionWireOp(facts.body)` — the one owner * of the `{ op: 'session-list' | 'session-transcript', sessionId? }` * grammar. A dialect that returns it must also implement * {@link HttpWire.sessions}, or every resolved listing answers with the * named not-carried refusal. */ readonly session?: SessionWireRequest; /** * Headers to put on THIS request's reply, whatever terminal it ends with * (9.10.0). * * It exists for one shape: a dialect that ISSUES the session it just read — * a `Set-Cookie` for a session the caller did not carry. The wire stays * pure either way; it returns a value and this file writes it, the same as * the body shapes beside it. * * They are merged over the reply's own `content-type` (or the SSE headers), * so a dialect cannot accidentally break the framing this host chose: * `content-type` set here is ignored. */ readonly responseHeaders?: Readonly>; }; /** * Did THIS caller ask for a stream? * * Absent, the answer is the HTTP one: an `Accept` of `text/event-stream`. * That is the right default and the wrong rule for dialects that carry the * choice in the body instead — where a client sets a field and never touches * `Accept`, and a host reading only the header answers one JSON body to a * caller waiting for events. * * Present, this is the whole answer: a dialect that says how its callers ask * is not second-guessed by the header. Read before any reply is framed, so it * decides `content-type` for the request. */ wantsStream?(facts: HttpRequestFacts): boolean; /** * Framing for ONE streaming response — see {@link StreamFraming}. * * Called once per streaming request, so whatever the framing has to remember * across its frames (an id minted for this response, a sequence counter) is * per-response state and never shared between callers. Absent, this host * frames the stream with the wire's own body shapes. */ stream?(facts: HttpRequestFacts): StreamFraming; /** Body for a health probe. `uptimeMs` is how long this host has been serving. */ health(uptimeMs: number): unknown; /** * Body for a reply that completed. * * `facts` is the request that produced it, for dialects whose reply repeats * something the request said — a model name, a conversation id. Absent only * where there is no request to show: this host has none to give when a body * is built outside a request's own lifecycle. */ output(output: string, facts?: HttpRequestFacts): unknown; /** * Body for a reply that failed. `code` is the refusal's stable code, when it * has one — and its PRESENCE is the signal that the message was authored by * this library rather than thrown by somebody's handler, which is what lets a * dialect decide what is safe to repeat to a caller. */ failure(message: string, code?: string, facts?: HttpRequestFacts, origin?: FailureOrigin): unknown; /** Body for one streamed piece, when the caller asked for Server-Sent Events. */ chunk(text: string): unknown; /** * Body for a reply that is WAITING on a person — the run paused, it is stored, * and a later request carrying a decision continues it. * * Optional so a wire written before this terminal existed keeps compiling and * keeps working. A host whose wire has no `awaiting` cannot describe the * question, so it reports the named refusal instead — the run is still stored * either way. */ awaiting?(pending: PendingAsk): unknown; /** * Body for a RESOLVED artifact operation (9.23.0) — the metadata for a * `head`, metadata + payload for a `get`. Compose it with * `artifactWireBody(result)` (both shipped dialects do) so clients read one * shape; add dialect envelope fields beside it when the deployment's * contract demands them. * * Optional exactly as `awaiting` is: a wire without it keeps compiling, and * a resolved ref on such a wire is answered with the named * `ArtifactNotCarriedError` refusal instead of an improvised body. */ artifact?(result: ArtifactWireResult): unknown; /** * Body for a RESOLVED session-history operation (9.26.0) — the caller's own * sessions for a list, one owned session's messages for a transcript. * Compose it with `sessionWireBody(result)` (both shipped dialects do) so * clients read one shape. * * Optional exactly as `artifact` is: a wire without it keeps compiling, and * a resolved listing on such a wire is answered with the named * `SessionsNotCarriedError` refusal instead of an improvised body. */ sessions?(result: SessionWireResult): unknown; /** * Pull the port's vocabulary out of one conversation handshake — the session * this conversation claims, any header mapping this dialect performs, and the * subprotocol to echo. * * Optional, and absent means "raw headers, no session": there is no body in a * handshake, so a dialect that wants a session id has to name where it looks, * and guessing on its behalf would invent an affinity rule the deployment * never agreed to. */ readConversation?(facts: HandshakeFacts): ConversationHandshake; } /** Options for {@link httpHost}. */ export interface HttpHostOptions { /** * Which adapter this is. Every refusal names it, so a caller reading an error * learns which adapter said no rather than which file it came from. */ readonly name: string; /** The JSON dialect this host speaks. */ readonly wire: HttpWire; /** * Path that takes a request. **Required, deliberately.** A default here would * be inherited by every adapter built on this file, and a default that * silently matched one runtime's container contract is exactly how a vendor * leaks into a library that promises not to know about one. */ readonly invokePath: string; /** Path that answers a health probe. Required, for the same reason. */ readonly healthPath: string; /** * Port to bind. Default `8080`. Pass `0` for an ephemeral port. * * Refused together with {@link HttpHostOptions.server}: a server you own * already has an address, and a port here would name a socket this host does * not bind. */ readonly port?: number; /** Interface to bind. Default `'0.0.0.0'`. Refused together with `server`, for the same reason. */ readonly hostname?: string; /** What this adapter claims beyond the baseline. Default `['streaming']`. */ readonly capabilities?: readonly HostCapability[]; /** * A `node:http` server **you** own. Given one, this host ATTACHES its two * routes to it instead of creating and listening on a server of its own. * * ── Why ────────────────────────────────────────────────────────────────── * Some runtimes hand a container exactly one port, and a container that must * also answer a WebSocket upgrade — or anything else — on that port cannot * use a host that privately owns the socket. Attaching costs nothing anyone * else was using: `node:http` calls EVERY `'request'` listener for every * request, so this host and your own routes share the port by taking turns. * * ── What changes, exactly ──────────────────────────────────────────────── * - **You own the socket.** `listen()` is yours, and so is closing it. The * server must ALREADY be listening when `serve()` is called — a handle * that promises `url` and `port` cannot honestly report an address that * does not exist yet, so `serve()` refuses rather than guess one. * - **The host never writes a 404.** A path it does not own is yours to * answer, and answering it with a refusal from this host would be this * host answering for your application. Note the consequence: a request no * listener answers is not a 404, it HANGS — if this server has no other * `'request'` listener, unmatched paths go unanswered until the socket * times out. With no `server`, the 404 behaviour is unchanged. * - **`close()` detaches and drains, and leaves your server listening.** It * removes this host's listener, waits for the requests it is already * serving, and touches nothing else — not your connections, not your * socket. * - It never writes to a response an earlier listener already answered. * - **A framework in front of it may mean this host never sees a request at * all.** Frameworks that install a catch-all handler answer everything * that reaches them, and a request they answered is finished before this * host's listener runs. Register the framework's own route for these two * paths and delegate to the host from inside it — or let the host own the * socket and put your routes on {@link HttpHostOptions.onUnhandled}. * * @example One port, an agent and a WebSocket upgrade * const server = createServer(); * server.on('upgrade', (req, socket, head) => acceptWebSocket(req, socket, head)); * await new Promise((r) => server.listen(8080, '0.0.0.0', r)); * const handle = await httpHost({ ...wireOptions, server }).serve(handler); * // …later: the host goes away, the socket and the upgrade stay. * await handle.close(); */ readonly server?: Server; /** * Path that takes a conversation upgrade. * * **No default, for exactly the reason `invokePath` has none** — a default * here is inherited by every adapter built on this file, and one that * silently matched somebody's runtime contract is that runtime leaking into a * library that promises not to know about one. * * ABSENT is meaningful: the host does not declare `'conversation'` and * {@link HttpHost.serveConversations} refuses by name. Present, and the host * can carry conversations whether or not anybody serves them. */ readonly conversationPath?: string; /** * What the conversation door caps. Whatever is left out is filled with this * file's defaults and then DECLARED, so `conversationLimits` on the host is * always what is actually enforced rather than what was passed in. * * An unset `maxFrameBytes` would mean an unbounded buffer somebody else * fills, which is a way to kill this process; an unset `maxPendingBytes` the * same, one layer up. `idleMs` has no default because this door does not idle * anything out — it reports the ceiling of whatever sits in front of it, and * inventing one would be reporting a fact nobody established. */ readonly conversationLimits?: ConversationLimits; /** * Answer a request whose path this host does not own — **your** code, on this * host's socket, INSTEAD of this host's 404. * * ── The law it states ──────────────────────────────────────────────────── * The host never answers for the application. With this hook it no longer has * to 404 for it either: an unowned path arrives exactly as it came off the * wire, and what happens next is yours — a route of your own, a file, your * own 404, or nothing at all. * * This is the inverse of {@link HttpHostOptions.server}. There, you own the * socket and lend the host two paths; here, the host owns the socket and * lends you everything else. Same single port, opposite direction, and the * one to reach for when the host is the only thing that needs to bind. * * ── What it never receives ─────────────────────────────────────────────── * The paths this host OWNS: `invokePath`, `healthPath` and `conversationPath` * — including a wrong METHOD on one of them, which is still this host's * question to answer. A hook that could claim `POST /invoke` would be a * second door wearing the first one's name. * * Absent, nothing changes: an unmatched path gets the same 404 it always did, * byte for byte. * * ── Private-server mode only ───────────────────────────────────────────── * Passed together with a caller-owned {@link HttpHostOptions.server} it is * REFUSED at construction, by name. There, unmatched paths are already yours * — they fall through to your own `'request'` listeners untouched — so this * would be a second answer to one question, and which answer won would depend * on the order two listeners were registered in. * * ── Two costs, stated rather than discovered ───────────────────────────── * - A throw here is THAT REQUEST's 500 and never the process's failure, the * same as everything else in a request's lifecycle. * - A hook that answers NOTHING leaves the request hanging until it times * out — the same price, for the same reason, as the 404 a caller-owned * server does not get. * * @example A diagnostic route beside the agent, on one port * httpHost({ * ...wireOptions, * onUnhandled: (req, res) => { * if (req.url === '/debug/trace') { * res.writeHead(200, { 'content-type': 'application/json' }); * res.end(JSON.stringify(lastTrace)); * return; * } * res.writeHead(404, { 'content-type': 'application/json' }); * res.end('{"error":"no such route"}'); * }, * }); */ readonly onUnhandled?: (req: IncomingMessage, res: ServerResponse) => void; /** * Ceiling on a request body, in bytes. Default: **none**. * * ── Why the default is no ceiling ──────────────────────────────────────── * Not because unbounded is right — it is not. A body is memory this process * pays for while somebody else fills it, and a host without a ceiling can be * stopped by one caller with a large POST. The default is absent because * every adapter built on this file inherited unbounded reads before this * option existed, and a number chosen here would silently start refusing * requests that deployments are serving today. * * **Set it.** A deployment that knows the largest body it legitimately * carries should say so; a request over the line is refused with * `ERR_REQUEST_TOO_LARGE` (413) and the read is abandoned at the byte that * crossed it, rather than buffered to the end and then judged. */ readonly maxBodyBytes?: number; /** * Answer `HEAD` on the invoke path with 204, instead of the 404 an unowned * method gets. Default `false`. * * Some deployment contracts probe a door before using it — "is the agent * here?" — and a probe is not a turn: nothing is read, nothing is run, and * the body is empty by definition. Opt-in, because a host that answered it * unasked would change what every existing adapter says to a method it has * always declined. */ readonly invokeHeadProbe?: boolean; } /** A {@link HostHandle} that also says where it landed. */ export interface HttpHostHandle extends HostHandle { /** * Where it is actually listening, e.g. `http://127.0.0.1:53211`. With a * caller-owned {@link HttpHostOptions.server} this is that server's real * address — the host reports where it is answering, never where it bound, * because with your server it bound nothing. */ readonly url: string; /** The port it actually bound — the real one, when you asked for `0`. */ readonly port: number; } /** * {@link AgentHost} and {@link ConversationHost} narrowed to an HTTP handle. * * One object with two doors, because they share one socket. Whether the * conversation door is USABLE is `capabilities`' answer, not this type's: a * host built without a `conversationPath` still has the method and refuses by * name, which is a better error than a method that is missing at runtime on * some adapters and present on others. */ export interface HttpHost extends AgentHost, ConversationHost { serve(handler: HostHandler): Promise; serveConversations(handler: ConversationHandler): Promise; } /** * 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 declare function httpHost(options: HttpHostOptions): HttpHost; /** * 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 declare function headerValue(facts: { readonly headers: Readonly>; }, name: string, ...fallbacks: string[]): string | undefined; //# sourceMappingURL=httpHost.d.ts.map