UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

1,107 lines • 54 kB
"use strict";
/**
 * 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.
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.headerValue = exports.httpHost = void 0;
const stream_js_1 = require("./stream.js");
const doorGuard_js_1 = require("./doorGuard.js");
const errors_js_1 = require("./errors.js");
const refusalStatus_js_1 = require("./refusalStatus.js");
const sessionWire_js_1 = require("./sessionWire.js");
const headers_js_1 = require("./headers.js");
const identityVerification_js_1 = require("./identityVerification.js");
const cookie_js_1 = require("./signin/cookie.js");
const hostSignIn_js_1 = require("./signin/hostSignIn.js");
const webSocketConversation_js_1 = require("./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' });
 */
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 = (0, hostSignIn_js_1.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 = (0, doorGuard_js_1.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)
            (0, doorGuard_js_1.warnAllowedHostsUnset)(name);
        if (guard.browserRulesOff)
            (0, doorGuard_js_1.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 Promise.resolve().then(() => __importStar(require('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 = (0, webSocketConversation_js_1.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: (0, identityVerification_js_1.bearerToken)((0, headers_js_1.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 errors_js_1.HostClosedError(name);
                    sendJson(res, refusalStatus_js_1.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;
                },
            };
        },
    };
}
exports.httpHost = httpHost;
/**
 * 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 errors_js_1.RequestTooLargeError) {
            sendJson(res, refusalStatus_js_1.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: (0, headers_js_1.lowerCasedHeaders)(req.headers), key: undefined }
        : (0, cookie_js_1.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 errors_js_1.InvalidWireOpError) {
            sendJson(res, refusalStatus_js_1.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 errors_js_1.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 = (0, doorGuard_js_1.checkSessionId)(read.sessionId, hostName) ?? (0, doorGuard_js_1.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: (0, identityVerification_js_1.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 errors_js_1.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 errors_js_1.SessionsNotCarriedError(result.op === 'list'
                    ? sessionWire_js_1.SESSION_LIST_OP
                    : result.op === 'pending'
                        ? sessionWire_js_1.SESSION_PENDING_OP
                        : sessionWire_js_1.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 ? refusalStatus_js_1.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((0, stream_js_1.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 errors_js_1.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.
 */
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;
}
exports.headerValue = headerValue;
//# sourceMappingURL=httpHost.js.map