import { EventEmitter } from "events";
import http, {} from "http";
import net from "net";
import { isWebSocketUpgradeRequestHeaders, MAX_HTTP_HEADER_BYTES, parseContentLength, parseHeaderLines, sendHttpResponse, sendHttpResponseHead, stripHopByHopHeaders, stripHopByHopHeadersForWebSocket, } from "./http/utils.js";
const MAX_LISTENERS_FILE_BYTES = 64 * 1024;
class ListenersFileTooLargeError extends Error {
    /** stable identifier for listeners size-cap violations */
    code = "LISTENERS_FILE_TOO_LARGE";
    constructor() {
        super("listeners file too large");
        this.name = "ListenersFileTooLargeError";
    }
}
export function parseListenersFile(text) {
    const routes = [];
    // Be tolerant of NUL padding (can happen with some file update patterns)
    text = text.replace(/\0/g, "");
    const lines = text.split(/\r?\n/);
    for (let lineNo = 0; lineNo < lines.length; lineNo++) {
        let line = lines[lineNo];
        const hash = line.indexOf("#");
        if (hash !== -1)
            line = line.slice(0, hash);
        line = line.trim();
        if (!line)
            continue;
        const parts = line.split(/\s+/g);
        if (parts.length < 2) {
            throw new Error(`invalid listeners file line ${lineNo + 1}: expected '<prefix> <backend>'`);
        }
        const prefix = parts[0];
        const backend = parts[1];
        if (!prefix.startsWith("/")) {
            throw new Error(`invalid listeners file line ${lineNo + 1}: prefix must start with '/'`);
        }
        if (!backend.startsWith(":")) {
            throw new Error(`invalid listeners file line ${lineNo + 1}: backend must be ':<port>'`);
        }
        const portText = backend.slice(1);
        if (!/^\d+$/.test(portText)) {
            throw new Error(`invalid listeners file line ${lineNo + 1}: invalid port`);
        }
        const port = Number.parseInt(portText, 10);
        if (!Number.isInteger(port) || port <= 0 || port > 65535) {
            throw new Error(`invalid listeners file line ${lineNo + 1}: invalid port`);
        }
        let stripPrefix = true;
        for (const opt of parts.slice(2)) {
            const eq = opt.indexOf("=");
            if (eq === -1) {
                throw new Error(`invalid listeners file line ${lineNo + 1}: invalid option '${opt}'`);
            }
            const key = opt.slice(0, eq);
            const value = opt.slice(eq + 1);
            if (key === "strip_prefix") {
                if (value === "true")
                    stripPrefix = true;
                else if (value === "false")
                    stripPrefix = false;
                else
                    throw new Error(`invalid listeners file line ${lineNo + 1}: strip_prefix must be true|false`);
            }
            else {
                // forward-compatible: ignore unknown keys
            }
        }
        routes.push({ prefix: normalizePrefix(prefix), port, stripPrefix });
    }
    return { routes };
}
export function serializeListenersFile(data) {
    const lines = [];
    for (const route of data.routes) {
        const opts = [];
        if (!route.stripPrefix) {
            opts.push("strip_prefix=false");
        }
        lines.push([route.prefix, `:${route.port}`, ...opts].join("\t"));
    }
    return lines.join("\n") + "\n";
}
function normalizePrefix(prefix) {
    let p = prefix;
    if (!p.startsWith("/"))
        p = "/" + p;
    if (p.length > 1 && p.endsWith("/"))
        p = p.slice(0, -1);
    return p;
}
function pathMatchesPrefix(pathname, prefix) {
    if (prefix === "/")
        return true;
    return pathname === prefix || pathname.startsWith(prefix + "/");
}
function stripPrefix(pathname, prefix) {
    if (prefix === "/")
        return pathname;
    if (pathname === prefix)
        return "/";
    if (pathname.startsWith(prefix + "/")) {
        const rest = pathname.slice(prefix.length);
        return rest.length === 0 ? "/" : rest;
    }
    return pathname;
}
function normalizeIncomingHeaders(headers) {
    const out = {};
    for (const [k, v] of Object.entries(headers)) {
        if (v === undefined)
            continue;
        out[k.toLowerCase()] = v;
    }
    return out;
}
function joinHeaderValue(raw) {
    if (typeof raw === "string")
        return raw;
    if (Array.isArray(raw))
        return raw.join(",");
    return "";
}
function parseTransferEncoding(raw) {
    const joined = joinHeaderValue(raw);
    if (!joined)
        return [];
    return joined
        .split(",")
        .map((x) => x.trim().toLowerCase())
        .filter(Boolean);
}
function parseConnectionTokens(raw) {
    const joined = joinHeaderValue(raw);
    if (!joined)
        return [];
    return joined
        .split(",")
        .map((x) => x.trim().toLowerCase())
        .filter(Boolean);
}
function hasUpgradeIntentHeaders(headers) {
    if (joinHeaderValue(headers["upgrade"]).trim().length > 0) {
        return true;
    }
    if (parseConnectionTokens(headers["connection"]).includes("upgrade")) {
        return true;
    }
    if (joinHeaderValue(headers["sec-websocket-key"]).trim().length > 0) {
        return true;
    }
    return false;
}
async function withTimeout(promise, timeoutMs, onTimeout, timeoutMessage) {
    if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
        return promise;
    }
    let timer = null;
    try {
        return await Promise.race([
            promise,
            new Promise((_, reject) => {
                timer = setTimeout(() => {
                    try {
                        onTimeout();
                    }
                    catch {
                        // ignore
                    }
                    reject(new Error(timeoutMessage));
                }, timeoutMs);
            }),
        ]);
    }
    finally {
        if (timer) {
            clearTimeout(timer);
        }
    }
}
async function* withIterableReadTimeout(iterable, timeoutMs, onTimeout, timeoutMessage) {
    if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
        yield* iterable;
        return;
    }
    const iterator = iterable[Symbol.asyncIterator]();
    try {
        while (true) {
            const next = await withTimeout(iterator.next(), timeoutMs, onTimeout, timeoutMessage);
            if (next.done)
                return;
            yield next.value;
        }
    }
    finally {
        if (typeof iterator.return === "function") {
            await iterator.return();
        }
    }
}
function normalizeHeaderRecord(headers) {
    const out = {};
    for (const [k, v] of Object.entries(headers)) {
        out[k.toLowerCase()] = v;
    }
    return out;
}
function applyHeaderPatch(base, patch) {
    const out = { ...base };
    for (const [k, v] of Object.entries(patch)) {
        const key = k.toLowerCase();
        if (v === null || v === undefined) {
            delete out[key];
        }
        else {
            out[key] = v;
        }
    }
    return out;
}
function coerceError(err) {
    if (err instanceof Error)
        return err;
    return new Error(String(err));
}
async function waitForReadableOrThrow(stream, closedMessage) {
    const s = stream;
    if (s.destroyed || s.readableEnded) {
        throw new Error(closedMessage);
    }
    await new Promise((resolve, reject) => {
        const onReadable = () => cleanup(() => resolve());
        const onEnd = () => cleanup(() => reject(new Error(closedMessage)));
        const onClose = () => cleanup(() => reject(new Error(closedMessage)));
        const onError = (err) => cleanup(() => reject(coerceError(err)));
        const cleanup = (fn) => {
            stream.off("readable", onReadable);
            stream.off("end", onEnd);
            stream.off("close", onClose);
            stream.off("error", onError);
            fn();
        };
        stream.once("readable", onReadable);
        stream.once("end", onEnd);
        stream.once("close", onClose);
        stream.once("error", onError);
    });
}
async function writeStream(stream, chunk) {
    const s = stream;
    if (s.destroyed || s.writableEnded || s.writableFinished) {
        throw new Error("stream closed");
    }
    await new Promise((resolve, reject) => {
        let needDrain = false;
        let wrote = false;
        let drained = false;
        const onError = (err) => cleanup(() => reject(coerceError(err)));
        const onClose = () => cleanup(() => reject(new Error("stream closed")));
        const onFinish = () => cleanup(() => reject(new Error("stream finished")));
        const onDrain = () => {
            drained = true;
            if (wrote)
                cleanup(() => resolve());
        };
        const onWrite = (err) => {
            if (err)
                return cleanup(() => reject(err));
            wrote = true;
            if (!needDrain || drained)
                cleanup(() => resolve());
        };
        const cleanup = (fn) => {
            stream.off("error", onError);
            stream.off("close", onClose);
            stream.off("finish", onFinish);
            stream.off("drain", onDrain);
            fn();
        };
        // Always attach an error handler while a write is in flight so we don't crash
        // on an unhandled "error" event.
        stream.on("error", onError);
        stream.once("close", onClose);
        stream.once("finish", onFinish);
        try {
            needDrain = !stream.write(chunk, onWrite);
        }
        catch (err) {
            cleanup(() => reject(coerceError(err)));
            return;
        }
        if (needDrain) {
            stream.once("drain", onDrain);
        }
    });
}
function parseStatusLine(line) {
    // HTTP/1.1 200 OK
    const m = /^HTTP\/\d+\.\d+\s+(\d{3})\s*(.*)$/.exec(line);
    if (!m)
        throw new Error(`invalid http status line: ${JSON.stringify(line)}`);
    const statusCode = Number.parseInt(m[1], 10);
    const statusMessage = m[2] ?? "";
    return { statusCode, statusMessage };
}
async function readHttpHead(stream) {
    let buf = Buffer.alloc(0);
    while (true) {
        const idx = buf.indexOf("\r\n\r\n");
        if (idx !== -1) {
            const head = buf.subarray(0, idx).toString("utf8");
            const rest = buf.subarray(idx + 4);
            const [statusLine, ...headerLines] = head.split("\r\n");
            if (!statusLine)
                throw new Error("missing status line");
            const { statusCode, statusMessage } = parseStatusLine(statusLine);
            const headers = parseHeaderLines(headerLines);
            return { statusCode, statusMessage, headers, rest };
        }
        if (buf.length > MAX_HTTP_HEADER_BYTES) {
            throw new Error("upstream headers too large");
        }
        const chunk = stream.read();
        if (chunk) {
            buf = Buffer.concat([buf, chunk]);
            continue;
        }
        // Note: Duplex streams can emit "close" without "end" when destroyed.
        // Treat that as an upstream close so we don't hang waiting for headers.
        await waitForReadableOrThrow(stream, "upstream closed before sending headers");
    }
}
async function* decodeChunkedBody(stream, initial) {
    let buf = initial;
    const readMore = async () => {
        while (true) {
            const chunk = stream.read();
            if (chunk) {
                buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
                return;
            }
            await waitForReadableOrThrow(stream, "upstream closed during chunked body");
        }
    };
    while (true) {
        // Need a full line for chunk size
        while (buf.indexOf("\r\n") === -1) {
            await readMore();
        }
        const lineEnd = buf.indexOf("\r\n");
        const line = buf.subarray(0, lineEnd).toString("ascii");
        buf = buf.subarray(lineEnd + 2);
        const semi = line.indexOf(";");
        const sizeStr = (semi === -1 ? line : line.slice(0, semi)).trim();
        const size = Number.parseInt(sizeStr, 16);
        if (!Number.isFinite(size) || size < 0)
            throw new Error("invalid chunk size");
        if (size === 0) {
            // Consume trailer part (0 or more header lines), terminated by an empty line.
            // If there are no trailers, the buffer starts with "\r\n".
            if (buf.length < 2) {
                await readMore();
            }
            if (buf.subarray(0, 2).toString("ascii") === "\r\n") {
                buf = buf.subarray(2);
                return;
            }
            while (buf.indexOf("\r\n\r\n") === -1) {
                if (buf.length > MAX_HTTP_HEADER_BYTES) {
                    throw new Error("upstream trailers too large");
                }
                await readMore();
            }
            const trailerEnd = buf.indexOf("\r\n\r\n");
            buf = buf.subarray(trailerEnd + 4);
            return;
        }
        // Stream chunk payload without buffering the entire chunk into memory.
        let remaining = size;
        while (remaining > 0) {
            if (buf.length === 0) {
                await readMore();
                continue;
            }
            const take = Math.min(remaining, buf.length);
            yield buf.subarray(0, take);
            buf = buf.subarray(take);
            remaining -= take;
        }
        // Consume trailing CRLF
        while (buf.length < 2) {
            await readMore();
        }
        if (buf[0] !== 13 || buf[1] !== 10)
            throw new Error("invalid chunk terminator");
        buf = buf.subarray(2);
    }
}
async function* readFixedBody(stream, initial, length) {
    let remaining = length;
    let buf = initial;
    while (remaining > 0) {
        if (buf.length === 0) {
            const chunk = stream.read();
            if (chunk) {
                buf = chunk;
            }
            else {
                await waitForReadableOrThrow(stream, "upstream closed before content-length satisfied");
                continue;
            }
        }
        const take = Math.min(remaining, buf.length);
        yield buf.subarray(0, take);
        buf = buf.subarray(take);
        remaining -= take;
    }
}
async function* readToEnd(stream, initial) {
    if (initial.length > 0)
        yield initial;
    for await (const chunk of stream) {
        yield Buffer.from(chunk);
    }
}
async function bufferAsyncIterable(iter, maxBytes) {
    const chunks = [];
    let total = 0;
    for await (const chunk of iter) {
        const b = Buffer.from(chunk);
        total += b.length;
        if (total > maxBytes) {
            throw new Error(`buffered body too large (>${maxBytes} bytes)`);
        }
        chunks.push(b);
    }
    return Buffer.concat(chunks, total);
}
export class GondolinListeners extends EventEmitter {
    routes = [];
    reloadTimer = null;
    lastReloadError = null;
    etcProvider;
    constructor(etcProvider) {
        super();
        this.etcProvider = etcProvider;
    }
    getLastReloadError() {
        return this.lastReloadError;
    }
    getRoutes() {
        return [...this.routes];
    }
    /** Called by VFS hooks when /etc/gondolin/listeners may have changed */
    notifyDirty() {
        if (this.reloadTimer)
            return;
        this.reloadTimer = setTimeout(() => {
            this.reloadTimer = null;
            try {
                this.reloadNow();
            }
            catch (err) {
                // Keep old routes on parse failure.
                this.lastReloadError = err;
                // Do NOT emit the special "error" event (would crash the process if unhandled).
                this.emit("reloadError", err);
            }
        }, 25);
    }
    /** Replace the routing table and write canonical listeners file back into the guest */
    setRoutes(routes) {
        const normalized = [];
        for (const r of routes) {
            if (typeof r.prefix !== "string" || r.prefix.trim() === "") {
                throw new Error("invalid ingress route prefix");
            }
            if (/\s/.test(r.prefix)) {
                throw new Error("invalid ingress route prefix");
            }
            const port = r.port;
            if (!Number.isInteger(port) || port <= 0 || port > 65535) {
                throw new Error(`invalid ingress route port: ${port}`);
            }
            const stripPrefix = r.stripPrefix ?? true;
            if (typeof stripPrefix !== "boolean") {
                throw new Error("invalid ingress route stripPrefix");
            }
            normalized.push({
                prefix: normalizePrefix(r.prefix),
                port,
                stripPrefix,
            });
        }
        this.routes = normalized;
        this.lastReloadError = null;
        this.writeCanonical();
        this.emit("changed", this.getRoutes());
    }
    /** Parse listeners file and update routes; also canonicalize file contents */
    reloadNow() {
        const text = this.readListenersText();
        const parsed = parseListenersFile(text);
        this.routes = parsed.routes;
        this.lastReloadError = null;
        const canonical = serializeListenersFile({ routes: this.routes });
        if (canonical !== text) {
            this.writeListenersText(canonical);
        }
        this.emit("changed", this.getRoutes());
    }
    readListenersText() {
        try {
            const st = this.etcProvider.statSync("/listeners");
            if (st.size > MAX_LISTENERS_FILE_BYTES) {
                throw new ListenersFileTooLargeError();
            }
            // Prefer provider-level readFileSync if present.
            const p = this.etcProvider;
            if (typeof p.readFileSync === "function") {
                const buf = p.readFileSync("/listeners", "utf8");
                return typeof buf === "string" ? buf : buf.toString("utf8");
            }
            const handle = this.etcProvider.openSync("/listeners", "r");
            try {
                const result = handle.readFileSync("utf8");
                return typeof result === "string" ? result : result.toString("utf8");
            }
            finally {
                handle.closeSync();
            }
        }
        catch (err) {
            // If the file exists but violates our size cap, propagate so reloadNow keeps the previous routes.
            if (err instanceof ListenersFileTooLargeError) {
                throw err;
            }
            // Missing file means "no routes".
            const code = err?.code;
            if (code === "ENOENT" || code === "ENOTDIR") {
                return "";
            }
            // Any other provider error should keep the last-known-good routes.
            throw err;
        }
    }
    writeCanonical() {
        const text = serializeListenersFile({ routes: this.routes });
        this.writeListenersText(text);
    }
    writeListenersText(text) {
        if (Buffer.byteLength(text, "utf8") > MAX_LISTENERS_FILE_BYTES) {
            throw new ListenersFileTooLargeError();
        }
        // Write through the underlying provider directly (not via VFS hooks)
        const handle = this.etcProvider.openSync("/listeners", "w");
        try {
            handle.writeFileSync(text);
        }
        finally {
            handle.closeSync();
        }
    }
}
export class IngressRequestBlockedError extends Error {
    /** http status code */
    statusCode;
    /** http status message */
    statusMessage;
    /** response body */
    body;
    constructor(message = "request blocked", statusCode = 403, statusMessage = "Forbidden", body = "forbidden\n") {
        super(message);
        this.name = "IngressRequestBlockedError";
        this.statusCode = statusCode;
        this.statusMessage = statusMessage;
        this.body = body;
    }
}
export class IngressGateway {
    server = null;
    hooks;
    allowWebSockets;
    bufferResponseBody;
    maxBufferedResponseBodyBytes;
    upstreamHeaderTimeoutMs;
    upstreamResponseTimeoutMs;
    sandbox;
    listeners;
    constructor(sandbox, listeners, options = {}) {
        this.sandbox = sandbox;
        this.listeners = listeners;
        this.hooks = options.hooks ?? null;
        this.allowWebSockets = options.allowWebSockets ?? true;
        this.bufferResponseBody = options.bufferResponseBody ?? false;
        this.maxBufferedResponseBodyBytes =
            options.maxBufferedResponseBodyBytes ?? 2 * 1024 * 1024;
        this.upstreamHeaderTimeoutMs = options.upstreamHeaderTimeoutMs ?? 2000;
        this.upstreamResponseTimeoutMs = options.upstreamResponseTimeoutMs ?? 15000;
    }
    async listen(options = {}) {
        if (this.server) {
            throw new Error("ingress gateway already running");
        }
        if (options.hooks !== undefined) {
            this.hooks = options.hooks ?? null;
        }
        if (options.allowWebSockets !== undefined) {
            this.allowWebSockets = options.allowWebSockets;
        }
        if (options.bufferResponseBody !== undefined) {
            this.bufferResponseBody = options.bufferResponseBody;
        }
        if (options.maxBufferedResponseBodyBytes !== undefined) {
            this.maxBufferedResponseBodyBytes = options.maxBufferedResponseBodyBytes;
        }
        if (options.upstreamHeaderTimeoutMs !== undefined) {
            this.upstreamHeaderTimeoutMs = options.upstreamHeaderTimeoutMs;
        }
        if (options.upstreamResponseTimeoutMs !== undefined) {
            this.upstreamResponseTimeoutMs = options.upstreamResponseTimeoutMs;
        }
        const listenHost = options.listenHost ?? "127.0.0.1";
        const listenPort = options.listenPort ?? 0;
        const server = http.createServer((req, res) => {
            void this.handleRequest(req, res);
        });
        server.on("upgrade", (req, socket, head) => {
            void this.handleUpgrade(req, socket, head);
        });
        // Ensure we don't crash on client errors.
        server.on("clientError", (_err, socket) => {
            try {
                socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
            }
            catch {
                // ignore
            }
        });
        await new Promise((resolve, reject) => {
            server.once("error", reject);
            server.listen({ host: listenHost, port: listenPort }, () => {
                server.off("error", reject);
                resolve();
            });
        });
        this.server = server;
        const addr = server.address();
        if (!addr || typeof addr === "string") {
            await new Promise((resolve) => server.close(() => resolve()));
            this.server = null;
            throw new Error("unexpected ingress listen address");
        }
        const host = listenHost;
        const port = addr.port;
        return {
            host,
            port,
            url: `http://${host.includes(":") ? `[${host}]` : host}:${port}`,
            close: async () => {
                const s = this.server;
                this.server = null;
                if (!s)
                    return;
                await new Promise((resolve) => s.close(() => resolve()));
            },
        };
    }
    pickRoute(pathname) {
        const routes = this.listeners.getRoutes();
        let best = null;
        for (const route of routes) {
            if (!pathMatchesPrefix(pathname, route.prefix))
                continue;
            if (!best || route.prefix.length > best.prefix.length) {
                best = route;
            }
        }
        return best;
    }
    async buildUpstreamHookRequest(url, route, hooks, clientIp, clientMethod, incoming, hostHeader, forUpgrade) {
        const path = url.pathname + url.search;
        if (hooks?.isAllowed) {
            const allowed = await hooks.isAllowed({
                clientIp,
                method: clientMethod,
                path,
                headers: incoming,
                route,
            });
            if (!allowed) {
                throw new IngressRequestBlockedError();
            }
        }
        const backendPathname = route.stripPrefix
            ? stripPrefix(url.pathname, route.prefix)
            : url.pathname;
        // Response buffering defaults (can be overridden per-request via onRequest)
        let bufferResponseBody = this.bufferResponseBody;
        let maxBufferedResponseBodyBytes = this.maxBufferedResponseBodyBytes;
        const filterHeaders = forUpgrade
            ? stripHopByHopHeadersForWebSocket
            : stripHopByHopHeaders;
        // Build initial upstream request config (can be patched by onRequest)
        let hookRequest = {
            clientIp,
            method: clientMethod,
            path,
            route,
            backendHost: "127.0.0.1",
            backendPort: route.port,
            backendTarget: backendPathname + url.search,
            headers: normalizeHeaderRecord(filterHeaders(incoming)),
        };
        // Remove body framing headers; we choose framing ourselves.
        delete hookRequest.headers["content-length"];
        delete hookRequest.headers["transfer-encoding"];
        delete hookRequest.headers["expect"];
        hookRequest.headers["host"] = hostHeader;
        if (!forUpgrade)
            hookRequest.headers["connection"] = "close";
        // Forwarded headers
        if (clientIp) {
            const prev = hookRequest.headers["x-forwarded-for"];
            if (typeof prev === "string" && prev.length > 0) {
                hookRequest.headers["x-forwarded-for"] = `${prev}, ${clientIp}`;
            }
            else {
                hookRequest.headers["x-forwarded-for"] = clientIp;
            }
        }
        hookRequest.headers["x-forwarded-host"] = hostHeader;
        hookRequest.headers["x-forwarded-proto"] = "http";
        if (route.stripPrefix && route.prefix !== "/") {
            hookRequest.headers["x-forwarded-prefix"] = route.prefix;
        }
        if (hooks?.onRequest) {
            const patch = await hooks.onRequest(hookRequest);
            if (patch) {
                if (patch.method !== undefined)
                    hookRequest.method = patch.method;
                if (patch.backendHost !== undefined)
                    hookRequest.backendHost = patch.backendHost;
                if (patch.backendPort !== undefined)
                    hookRequest.backendPort = patch.backendPort;
                if (patch.backendTarget !== undefined)
                    hookRequest.backendTarget = patch.backendTarget;
                if (patch.headers) {
                    hookRequest.headers = applyHeaderPatch(hookRequest.headers, patch.headers);
                }
                if (patch.bufferResponseBody !== undefined)
                    bufferResponseBody = patch.bufferResponseBody;
                if (patch.maxBufferedResponseBodyBytes !== undefined) {
                    maxBufferedResponseBodyBytes = patch.maxBufferedResponseBodyBytes;
                }
            }
        }
        hookRequest.method = (hookRequest.method ?? "GET").toUpperCase();
        if (forUpgrade && hookRequest.method !== "GET") {
            return {
                hookRequest,
                bufferResponseBody,
                maxBufferedResponseBodyBytes,
            };
        }
        if (hookRequest.backendHost !== "127.0.0.1" &&
            hookRequest.backendHost !== "localhost") {
            throw new Error(`invalid ingress backend host: ${hookRequest.backendHost}`);
        }
        if (!Number.isInteger(hookRequest.backendPort) ||
            hookRequest.backendPort <= 0 ||
            hookRequest.backendPort > 65535) {
            throw new Error(`invalid ingress backend port: ${hookRequest.backendPort}`);
        }
        if (!hookRequest.backendTarget.startsWith("/")) {
            throw new Error(`invalid ingress backend target: ${hookRequest.backendTarget}`);
        }
        // Enforce hop-by-hop filtering after the user hook
        hookRequest.headers = normalizeHeaderRecord(filterHeaders(hookRequest.headers));
        // Remove body framing headers; we choose framing ourselves.
        delete hookRequest.headers["content-length"];
        delete hookRequest.headers["transfer-encoding"];
        delete hookRequest.headers["expect"];
        if (!forUpgrade)
            hookRequest.headers["connection"] = "close";
        return {
            hookRequest,
            bufferResponseBody,
            maxBufferedResponseBodyBytes,
        };
    }
    async openUpstreamAndWriteHead(hookRequest, options = {}) {
        const upstream = await this.sandbox.openIngressStream({
            host: hookRequest.backendHost,
            port: hookRequest.backendPort,
            timeoutMs: 2000,
        });
        if (options.onError) {
            upstream.on("error", options.onError);
        }
        // Send request line + headers
        const headerLines = [];
        headerLines.push(`${hookRequest.method} ${hookRequest.backendTarget} HTTP/1.1`);
        for (const [k, v] of Object.entries(hookRequest.headers)) {
            if (v === undefined)
                continue;
            if (Array.isArray(v)) {
                for (const vv of v)
                    headerLines.push(`${k}: ${vv}`);
            }
            else {
                headerLines.push(`${k}: ${v}`);
            }
        }
        const headerBlob = headerLines.join("\r\n") + "\r\n\r\n";
        await writeStream(upstream, headerBlob);
        if (options.extra && options.extra.length > 0) {
            await writeStream(upstream, options.extra);
        }
        return upstream;
    }
    rejectUpgrade(socket, statusCode, statusMessage, bodyText) {
        const body = Buffer.from(bodyText, "utf8");
        const safeWrite = (chunk) => {
            try {
                socket.write(chunk);
            }
            catch {
                // ignore
            }
        };
        sendHttpResponse(safeWrite, {
            status: statusCode,
            statusText: statusMessage,
            headers: {
                "content-type": "text/plain",
                "content-length": String(body.length),
                connection: "close",
            },
            body,
        }, "HTTP/1.1", "latin1");
        socket.destroy();
    }
    async handleRequest(req, res) {
        let upstream = null;
        let upstreamError = null;
        let clientDisconnected = false;
        let responseFinished = false;
        const onUpstreamError = (err) => {
            upstreamError = err;
            // Prevent an unhandled "error" event from crashing the process.
            // The request handler will surface failures via awaited reads/writes.
        };
        const onResponseFinished = () => {
            responseFinished = true;
        };
        const onClientDisconnected = () => {
            if (responseFinished)
                return;
            clientDisconnected = true;
            if (upstream) {
                try {
                    upstream.destroy(new Error("client disconnected"));
                }
                catch {
                    // ignore
                }
            }
        };
        req.once("aborted", onClientDisconnected);
        res.once("finish", onResponseFinished);
        res.once("close", onClientDisconnected);
        try {
            if (hasUpgradeIntentHeaders(req.headers)) {
                res.writeHead(426, "Upgrade Required", {
                    "content-type": "text/plain",
                    connection: "close",
                    upgrade: "websocket",
                });
                res.end("use websocket upgrade\n");
                return;
            }
            const url = new URL(req.url ?? "/", "http://gondolin.local");
            const route = this.pickRoute(url.pathname);
            if (!route) {
                res.statusCode = 404;
                res.setHeader("content-type", "text/plain");
                res.end("no route\n");
                return;
            }
            const hooks = this.hooks;
            const clientIp = req.socket.remoteAddress ?? "";
            const clientMethod = (req.method ?? "GET").toUpperCase();
            const incoming = normalizeIncomingHeaders(req.headers);
            const hostHeader = typeof incoming["host"] === "string" ? incoming["host"] : "localhost";
            const { hookRequest, bufferResponseBody, maxBufferedResponseBodyBytes } = await this.buildUpstreamHookRequest(url, route, hooks, clientIp, clientMethod, incoming, hostHeader, false);
            // Decide request body encoding (based on what we received from the client)
            const hasBody = incoming["content-length"] !== undefined ||
                incoming["transfer-encoding"] !== undefined ||
                clientMethod === "POST" ||
                clientMethod === "PUT" ||
                clientMethod === "PATCH";
            const incomingTeTokens = parseTransferEncoding(incoming["transfer-encoding"]);
            const hasTransferEncoding = incomingTeTokens.length > 0;
            const incomingContentLength = parseContentLength(incoming["content-length"]);
            let useChunked = false;
            // If the client used transfer-encoding, never forward its content-length.
            // Node already de-chunks/frames the inbound body; forwarding a conflicting
            // content-length to the guest backend can cause mis-framing.
            if (!hasTransferEncoding && incomingContentLength !== null) {
                hookRequest.headers["content-length"] = String(incomingContentLength);
            }
            else if (hasBody) {
                useChunked = true;
                hookRequest.headers["transfer-encoding"] = "chunked";
            }
            upstream = await this.openUpstreamAndWriteHead(hookRequest, {
                onError: onUpstreamError,
            });
            // Stream body.
            // Do NOT call upstream.end() after writing. Ending the writable side
            // sends tcp_eof to the guest, which translates to shutdown(SHUT_WR) /
            // TCP FIN on the loopback socket. Some HTTP servers (Kestrel, Vite)
            // interpret an incoming FIN during request processing as a client
            // disconnect and close without responding, causing a 502. HTTP request
            // completion is already signaled at the application layer (end of
            // headers for bodyless requests, Content-Length, chunked terminator),
            // so the TCP-level half-close is redundant and harmful. The stream is
            // destroyed in the finally block after the response is fully read.
            if (hasBody) {
                if (useChunked) {
                    for await (const chunk of req) {
                        const b = Buffer.from(chunk);
                        if (b.length === 0)
                            continue;
                        await writeStream(upstream, Buffer.from(b.length.toString(16) + "\r\n", "ascii"));
                        await writeStream(upstream, b);
                        await writeStream(upstream, "\r\n");
                    }
                    await writeStream(upstream, "0\r\n\r\n");
                }
                else {
                    for await (const chunk of req) {
                        await writeStream(upstream, Buffer.from(chunk));
                    }
                }
            }
            // Read response head
            const head = await withTimeout(readHttpHead(upstream), this.upstreamHeaderTimeoutMs, () => {
                try {
                    upstream?.destroy(new Error("upstream response header timeout"));
                }
                catch {
                    // ignore
                }
            }, "upstream response header timeout");
            let respHeaders = normalizeHeaderRecord(stripHopByHopHeaders(head.headers));
            delete respHeaders["content-length"]; // let node decide unless we keep fixed-length
            delete respHeaders["transfer-encoding"];
            // Body mode
            const upstreamTeTokens = parseTransferEncoding(head.headers["transfer-encoding"]);
            const isChunked = upstreamTeTokens.includes("chunked");
            const contentLength = parseContentLength(head.headers["content-length"]);
            // If we have a fixed content-length and the upstream isn't chunked, preserve it.
            if (!isChunked && contentLength !== null) {
                respHeaders["content-length"] = String(contentLength);
            }
            const responseForHook = {
                statusCode: head.statusCode,
                statusMessage: head.statusMessage,
                headers: respHeaders,
            };
            let bodyIter = isChunked
                ? decodeChunkedBody(upstream, head.rest)
                : contentLength !== null
                    ? readFixedBody(upstream, head.rest, contentLength)
                    : readToEnd(upstream, head.rest);
            bodyIter = withIterableReadTimeout(bodyIter, this.upstreamResponseTimeoutMs, () => {
                try {
                    upstream?.destroy(new Error("upstream response timeout"));
                }
                catch {
                    // ignore
                }
            }, "upstream response timeout");
            const shouldBufferResponseBody = bufferResponseBody && !!hooks?.onResponse;
            if (shouldBufferResponseBody) {
                const max = maxBufferedResponseBodyBytes;
                if (!Number.isInteger(max) || max <= 0) {
                    throw new Error("invalid maxBufferedResponseBodyBytes");
                }
                responseForHook.body = await bufferAsyncIterable(bodyIter, max);
            }
            if (hooks?.onResponse) {
                const patch = await hooks.onResponse(responseForHook, hookRequest);
                if (patch) {
                    if (patch.statusCode !== undefined)
                        responseForHook.statusCode = patch.statusCode;
                    if (patch.statusMessage !== undefined)
                        responseForHook.statusMessage = patch.statusMessage;
                    if (patch.headers)
                        responseForHook.headers = applyHeaderPatch(responseForHook.headers, patch.headers);
                    if (patch.body !== undefined)
                        responseForHook.body = patch.body;
                }
            }
            const finalHeaders = normalizeHeaderRecord(stripHopByHopHeaders(responseForHook.headers));
            // If we buffered the response (or a hook provided an explicit replacement body), send it with a fixed content-length.
            if (responseForHook.body !== undefined) {
                delete finalHeaders["transfer-encoding"];
                finalHeaders["content-length"] = String(responseForHook.body.length);
                res.writeHead(responseForHook.statusCode, responseForHook.statusMessage, finalHeaders);
                if (responseForHook.body.length > 0) {
                    await writeStream(res, responseForHook.body);
                }
                res.end();
                return;
            }
            res.writeHead(responseForHook.statusCode, responseForHook.statusMessage, finalHeaders);
            for await (const chunk of bodyIter) {
                await writeStream(res, chunk);
            }
            res.end();
        }
        catch (err) {
            if (clientDisconnected) {
                return;
            }
            if (err instanceof IngressRequestBlockedError) {
                try {
                    if (res.headersSent) {
                        res.destroy?.();
                        res.socket?.destroy?.();
                        return;
                    }
                    res.writeHead(err.statusCode, err.statusMessage, {
                        "content-type": "text/plain",
                    });
                    res.end(err.body);
                }
                catch {
                    // ignore
                }
                return;
            }
            if (process.env.GONDOLIN_DEBUG) {
                // Avoid leaking internal error details to network clients, but keep them
                // available for debugging when explicitly enabled.
                console.error("ingress bad gateway:", err);
            }
            try {
                if (res.headersSent) {
                    // We already forwarded upstream headers; writing a gateway body here can
                    // corrupt fixed-length responses.
                    res.destroy?.();
                    res.socket?.destroy?.();
                    return;
                }
                res.statusCode = 502;
                res.setHeader("content-type", "text/plain");
                res.end("bad gateway\n");
            }
            catch {
                // If the client disconnected, res.end()/destroy() can throw synchronously.
                // Swallow it to avoid turning this into an unhandled rejection.
            }
        }
        finally {
            req.off("aborted", onClientDisconnected);
            res.off("finish", onResponseFinished);
            res.off("close", onClientDisconnected);
            if (upstream) {
                try {
                    upstream.destroy(upstreamError instanceof Error ? upstreamError : undefined);
                }
                catch {
                    // ignore
                }
            }
        }
    }
    async handleUpgrade(req, socket, head) {
        let upstream = null;
        try {
            if (!this.allowWebSockets) {
                return this.rejectUpgrade(socket, 501, "Not Implemented", "websocket upgrades disabled\n");
            }
            let url;
            try {
                url = new URL(req.url ?? "/", "http://gondolin.local");
            }
            catch {
                return this.rejectUpgrade(socket, 400, "Bad Request", "bad request\n");
            }
            const route = this.pickRoute(url.pathname);
            if (!route) {
                return this.rejectUpgrade(socket, 404, "Not Found", "no route\n");
            }
            const hooks = this.hooks;
            const clientIp = socket.remoteAddress ?? "";
            const clientMethod = (req.method ?? "GET").toUpperCase();
            const incoming = normalizeIncomingHeaders(req.headers);
            if (clientMethod !== "GET") {
                return this.rejectUpgrade(socket, 400, "Bad Request", "websocket upgrade requires GET\n");
            }
            if (!isWebSocketUpgradeRequestHeaders(incoming)) {
                return this.rejectUpgrade(socket, 400, "Bad Request", "expected websocket upgrade\n");
            }
            const hostHeader = joinHeaderValue(incoming["host"]) || "localhost";
            const { hookRequest } = await this.buildUpstreamHookRequest(url, route, hooks, clientIp, clientMethod, incoming, hostHeader, true);
            if (hookRequest.method !== "GET") {
                return this.rejectUpgrade(socket, 400, "Bad Request", "websocket upgrade requires GET\n");
            }
            upstream = await this.openUpstreamAndWriteHead(hookRequest, {
                extra: head,
            });
            // Read response head
            const respHead = await withTimeout(readHttpHead(upstream), this.upstreamHeaderTimeoutMs, () => {
                try {
                    upstream?.destroy(new Error("upstream response header timeout"));
                }
                catch {
                    // ignore
                }
            }, "upstream response header timeout");
            const upstreamStatusCode = respHead.statusCode;
            let responseForHook = {
                statusCode: respHead.statusCode,
                statusMessage: respHead.statusMessage,
                headers: normalizeHeaderRecord(respHead.headers),
                body: Buffer.alloc(0),
            };
            if (hooks?.onResponse) {
                const patch = await hooks.onResponse(responseForHook, hookRequest);
                if (patch) {
                    if (patch.statusCode !== undefined)
                        responseForHook.statusCode = patch.statusCode;
                    if (patch.statusMessage !== undefined)
                        responseForHook.statusMessage = patch.statusMessage;
                    if (patch.headers)
                        responseForHook.headers = applyHeaderPatch(responseForHook.headers, patch.headers);
                    if (patch.body !== undefined)
                        responseForHook.body = patch.body;
                }
            }
            const finalHeaders = normalizeHeaderRecord(responseForHook.headers);
            // If hooks provided an explicit body, treat it as a regular HTTP response (no upgrade).
            if (responseForHook.body && responseForHook.body.length > 0) {
                delete finalHeaders["transfer-encoding"];
                finalHeaders["content-length"] = String(responseForHook.body.length);
            }
            // Write response head
            sendHttpResponseHead((chunk) => socket.write(chunk), {
                status: responseForHook.statusCode,
                statusText: responseForHook.statusMessage,
                headers: finalHeaders,
            }, "HTTP/1.1", "latin1");
            // Body replacement (if any)
            if (responseForHook.body && responseForHook.body.length > 0) {
                socket.write(responseForHook.body);
                socket.destroy();
                upstream.destroy();
                return;
            }
            if (respHead.rest.length > 0) {
                socket.write(respHead.rest);
            }
            if (upstreamStatusCode !== 101 || responseForHook.statusCode !== 101) {
                socket.destroy();
                upstream.destroy();
                return;
            }
            // Start tunnel
            socket.pipe(upstream);
            upstream.pipe(socket);
            const destroyBoth = (err) => {
                try {
                    socket.destroy();
                }
                catch {
                    // ignore
                }
                try {
                    upstream?.destroy(err);
                }
                catch {
                    // ignore
                }
            };
            socket.on("error", (err) => destroyBoth(err));
            upstream.on("error", (err) => destroyBoth(err));
            socket.on("close", () => destroyBoth());
            upstream.on("close", () => destroyBoth());
        }
        catch (err) {
            if (err instanceof IngressRequestBlockedError) {
                return this.rejectUpgrade(socket, err.statusCode, err.statusMessage, err.body);
            }
            if (process.env.GONDOLIN_DEBUG) {
                console.error("ingress websocket bad gateway:", err);
            }
            this.rejectUpgrade(socket, 502, "Bad Gateway", "bad gateway\n");
        }
        finally {
            // `upstream` is intentionally not destroyed here when the tunnel is active;
            // it is owned by the piping/error handlers.
        }
    }
}
export function createGondolinEtcMount(provider) {
    const listeners = new GondolinListeners(provider);
    // Ensure file exists with canonical empty content.
    try {
        listeners.setRoutes([]);
    }
    catch {
        // ignore
    }
    return { provider, listeners };
}
function isGondolinListenersRelevantPath(path) {
    return path === "/etc/gondolin/listeners";
}
const LISTENERS_RELOAD_OPS = new Set([
    "writeFile",
    "truncate",
    "rename",
    "unlink",
    "release",
]);
export function createGondolinEtcHooks(listeners, etcProvider) {
    return {
        after: (ctx) => {
            // Only reload when the file is in a stable state (close/rename/writeFile).
            if (!LISTENERS_RELOAD_OPS.has(ctx.op)) {
                return;
            }
            if (isGondolinListenersRelevantPath(ctx.path) ||
                isGondolinListenersRelevantPath(ctx.oldPath) ||
                isGondolinListenersRelevantPath(ctx.newPath)) {
                listeners.notifyDirty();
            }
        },
        before: (ctx) => {
            if (!isGondolinListenersRelevantPath(ctx.path))
                return;
            if (ctx.op === "writeFile" &&
                ctx.data &&
                ctx.data.length > MAX_LISTENERS_FILE_BYTES) {
                throw new ListenersFileTooLargeError();
            }
            // Guard open+write growth, not just writeFile/truncate.
            if (ctx.op === "write") {
                if (typeof ctx.length !== "number") {
                    throw new ListenersFileTooLargeError();
                }
                // In append mode (O_APPEND), the RPC layer writes with position=null.
                // That maps to an undefined offset in the hook context.
                let offset = ctx.offset;
                if (typeof offset !== "number") {
                    if (!etcProvider) {
                        throw new Error("/etc/gondolin/listeners append writes not supported");
                    }
                    try {
                        offset = etcProvider.statSync("/listeners").size;
                    }
                    catch (err) {
                        if (err?.code === "ENOENT") {
                            offset = 0;
                        }
                        else {
                            throw err;
                        }
                    }
                }
                if (offset + ctx.length > MAX_LISTENERS_FILE_BYTES) {
                    throw new ListenersFileTooLargeError();
                }
            }
            if (ctx.op === "truncate" &&
                typeof ctx.size === "number" &&
                ctx.size > MAX_LISTENERS_FILE_BYTES) {
                throw new ListenersFileTooLargeError();
            }
        },
    };
}
//# sourceMappingURL=ingress.js.map