/**
* 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;