import * as miniflare from "miniflare";
import assert from "node:assert";
import { once } from "node:events";
import http from "node:http";
import { Readable } from "node:stream";
import { WebSocket, WebSocketServer } from "ws";
import { createUpgradeHandler } from "../../util/http.js";
import { logger } from "../../util/logger.js";
export async function createMiniflareWorkerProxy(options) {
    const server = http.createServer();
    const wss = new WebSocketServer({ noServer: true });
    server.on("upgrade", createUpgradeHandler({
        wss,
        createServerWebSocket: (req) => createServerWebSocket(req),
    }));
    server.on("request", async (req, res) => {
        try {
            const response = await handleFetch(req, res);
            writeMiniflareResponseToNode(response, res);
        }
        catch (error) {
            if (!(error instanceof DOMException && error.name === "AbortError")) {
                console.error(error);
            }
            const response = MiniflareWorkerProxyError.fromUnknown(error).toResponse(options.mode);
            writeMiniflareResponseToNode(response, res);
        }
    });
    const handleFetch = async (req, res) => {
        const info = parseIncomingMessage(req);
        options.transformRequest?.(info);
        const name = options.getWorkerName(info);
        info.headers.set("MF-Route-Override", name);
        // Handle scheduled events.
        // Wrangler exposes this as /__scheduled, but Miniflare exposes it as /cdn-cgi/handler/scheduled.
        // https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/#background
        // https://github.com/cloudflare/workers-sdk/blob/7d53b9ab6b370944b7934ad51ebef43160c3c775/packages/wrangler/templates/middleware/middleware-scheduled.ts#L6
        if (info.url.pathname === "/__scheduled") {
            info.url.pathname = "/cdn-cgi/handler/scheduled";
        }
        const signal = getDisconnectSignal(req, res);
        const request = new miniflare.Request(info.url, {
            method: info.method,
            headers: info.headers,
            body: info.body,
            redirect: info.redirect,
            duplex: info.duplex,
            signal,
        });
        const mf = await options.getMiniflare();
        return await mf.dispatchFetch(request);
    };
    const createServerWebSocket = async (req) => {
        // Rewrite to Miniflare entry URL
        const mf = await options.getMiniflare();
        const target = await mf.ready;
        const url = new URL(req.url ?? "/", target);
        url.protocol = url.protocol.replace("http", "ws");
        const protocols = req.headers["sec-websocket-protocol"]
            ?.split(",")
            .map((p) => p.trim());
        const userHeaders = Object.fromEntries(
        // filter headers that are set automatically by `ws` (if we set them manually, this causes errors)
        Object.entries(req.headers).filter(([key]) => !key.startsWith("sec-websocket-") &&
            key !== "host" &&
            key !== "connection" &&
            key !== "upgrade"));
        // Get worker name to set MF-Route-Override header
        const info = parseIncomingMessage(req);
        options.transformRequest?.(info);
        const name = options.getWorkerName(info);
        return new WebSocket(url, protocols, {
            headers: {
                "MF-Route-Override": name,
                ...userHeaders,
            },
        });
    };
    await listen(server, options.port);
    return {
        url: getAddress(server),
        close: async () => {
            // If we await this while the `wss` server is open, the server will hang.
            server.close();
        },
    };
}
/**
 * Creates an abort signal that is triggered when the downstream client
 * disconnects before the response is fully written.
 *
 * This signal should be forwarded to Miniflare dispatch so Worker `req.signal`
 * listeners can observe local client cancellation.
 */
function getDisconnectSignal(req, res) {
    const controller = new AbortController();
    const onAbort = () => {
        if (!controller.signal.aborted) {
            controller.abort();
        }
        cleanup();
    };
    const onClose = () => {
        if (!res.writableEnded) {
            onAbort();
            return;
        }
        cleanup();
    };
    const cleanup = () => {
        req.off("aborted", onAbort);
        req.socket?.off("close", onClose);
        res.off("close", onClose);
        res.off("finish", cleanup);
    };
    req.on("aborted", onAbort);
    req.socket?.on("close", onClose);
    res.on("close", onClose);
    res.on("finish", cleanup);
    return controller.signal;
}
/**
 * Listens on the given port, retrying on the next port if the port is already in use.
 */
async function listen(server, port) {
    try {
        server.listen(port);
        await once(server, "listening");
    }
    catch (error) {
        if (typeof error === "object" &&
            error !== null &&
            "code" in error &&
            error.code === "EADDRINUSE") {
            logger.warn(`Port ${port} is already in use, trying ${port + 1}...`);
            await listen(server, port + 1);
        }
        else {
            throw error;
        }
    }
}
function getAddress(server) {
    const address = server.address();
    assert(address &&
        typeof address === "object" &&
        "address" in address &&
        "port" in address, "Server is not listening");
    const hostname = address.address === "::" ? "localhost" : address.address;
    return new URL(`http://${hostname}:${address.port}`);
}
const parseIncomingMessage = (req) => {
    const method = req.method ?? "GET";
    const protocol = req.headers["x-forwarded-proto"] ?? "http";
    const url = new URL(req.url ?? "/", `${protocol}://${req.headers.host}`);
    const headers = new miniflare.Headers();
    for (const [key, value] of Object.entries(req.headers)) {
        if (Array.isArray(value)) {
            for (const v of value) {
                headers.append(key, v);
            }
        }
        else if (value) {
            headers.set(key, value);
        }
    }
    const body = ["GET", "HEAD", "OPTIONS"].includes(method) || !req.readable
        ? undefined
        : Readable.toWeb(req);
    return {
        method,
        url,
        headers,
        body,
        redirect: "manual",
        duplex: body ? "half" : undefined,
    };
};
const writeMiniflareResponseToNode = (res, out) => {
    out.statusCode = res.status;
    res.headers.forEach((value, key) => {
        // The `transfer-encoding` header prevents Cloudflare Tunnels from working because of the following error:
        // Request failed error="Unable to reach the origin service. The service may be down or it may not be
        // responding to traffic from cloudflared: net/http: HTTP/1.x transport connection broken: too many
        // transfer encodings: [\"chunked\" \"chunked\"]"
        if (key !== "transfer-encoding") {
            out.appendHeader(key, value);
        }
    });
    if (res.body) {
        Readable.fromWeb(res.body).pipe(out, { end: true });
    }
    else {
        out.end();
    }
};
export class MiniflareWorkerProxyError extends Error {
    status;
    constructor(message, status, options) {
        super(message, options);
        this.status = status;
    }
    toResponse(mode) {
        let text = `[Alchemy] ${this.message}`;
        if (mode === "local" && this.cause) {
            const cause = this.cause instanceof Error
                ? (this.cause.stack ?? this.cause.message)
                : String(this.cause);
            text += `\n\n${cause}`;
        }
        return new miniflare.Response(text, {
            status: this.status,
            headers: {
                "Content-Type": "text/plain",
                "Alchemy-Error": this.message,
            },
        });
    }
    static fromUnknown(error) {
        if (error instanceof MiniflareWorkerProxyError) {
            return error;
        }
        return new MiniflareWorkerProxyError("An unknown error occurred.", 500, {
            cause: error,
        });
    }
}
//# sourceMappingURL=miniflare-worker-proxy.js.map