core/coordinators/route.coordinator.ts

core/coordinators/route.coordinator.ts is a file in Project Scripts. 90 lines of code and 22 definitions.

import type { CaptureOptions, StartedServer } from "#types/route.types";
import { LISTEN_POLL_MS, LISTEN_TIMEOUT_MS } from "#configuration/configs/route.config";
import { awaitRelease, holdersOf, terminateTree } from "@banes-lab/build-scripts/core/adapters/server.adapter.ts";
import { listeningAfter, neverListened, portHeld, portStillHeld } from "#configuration/strings/route.strings";
import { absolutePath } from "@ssot/paths";
import { capturePage } from "#core/coordinators/snapshot.coordinator";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import { portOf } from "@ssot/secrets";
import process from "node:process";
import { snapshotFor } from "#core/converters/route.converter";
import spawn from "cross-spawn";
import { wait } from "@banes-lab/build-scripts/core/timers/base.timer.ts";
import { writeVerbatim } from "@govlab/canonical-write";

const WINDOWS = "win32";
const VITE_CONFIG = "vite.config.ts";
const SERVER_LOG = "dev-server.log";

const startServer = function startServer(): StartedServer {
    const output: string[] = [];
    const server = spawn("npx", ["vite", "--config", absolutePath("app.member", VITE_CONFIG), "--host"], {
        detached: process.platform !== WINDOWS,
        stdio: ["ignore", "pipe", "pipe"],
    });
    const collect = (chunk: unknown): void => {
        output.push(String(chunk));
    };
    server.stdout?.on("data", collect);
    server.stderr?.on("data", collect);
    return { output, process: server };
};

const awaitListening = async function awaitListening(port: number, startedAt: number): Promise<number | null> {
    const elapsed = performance.now() - startedAt;
    if (holdersOf(port).length > 0) {
        return Math.round(elapsed);
    }
    if (elapsed >= LISTEN_TIMEOUT_MS) {
        return null;
    }
    await wait(LISTEN_POLL_MS);
    return awaitListening(port, startedAt);
};

const captureEach = async function captureEach(binary: string, options: CaptureOptions): Promise<boolean> {
    return options.routes.reduce<Promise<boolean>>(async (prior, route) => {
        const earlier = await prior;
        const written = await capturePage(binary, snapshotFor(options, route));
        return earlier && written;
    }, Promise.resolve(true));
};

const captureWhenListening = async function captureWhenListening(
    binary: string,
    options: CaptureOptions,
    started: { readonly at: number; readonly port: number },
): Promise<boolean> {
    const { at, port } = started;
    const listening = await awaitListening(port, at);
    if (listening === null) {
        process.stderr.write(neverListened(port));
        return false;
    }
    process.stdout.write(listeningAfter(listening));
    return captureEach(binary, options);
};

const stopServer = async function stopServer(server: StartedServer, outDir: string, port: number): Promise<boolean> {
    if (server.process.pid !== undefined) {
        terminateTree(server.process.pid);
    }
    const released = await awaitRelease(port);
    writeVerbatim(join(outDir, SERVER_LOG), server.output.join(""));
    if (!released) {
        process.stderr.write(portStillHeld(port));
    }
    return released;
};

export const captureRoutes = async function captureRoutes(binary: string, options: CaptureOptions): Promise<boolean> {
    const port = portOf("SITE_DEV_PORT");
    const held = holdersOf(port);
    if (held.length > 0) {
        process.stderr.write(portHeld(port, held));
        return false;
    }
    mkdirSync(options.outDir, { recursive: true });
    const at = performance.now();
    const server = startServer();
    const [outcome] = await Promise.allSettled([captureWhenListening(binary, options, { at, port })]);
    const released = await stopServer(server, options.outDir, port);
    if (outcome.status === "rejected") {
        throw outcome.reason instanceof Error ? outcome.reason : new Error(String(outcome.reason));
    }
    return outcome.value && released;
};