All files / core/semantic-log/src/service start.ts

100% Statements 90/90
100% Branches 9/9
100% Functions 2/2
100% Lines 90/90

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 911x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 31x 31x 31x 31x 31x 31x 31x 31x 2x 2x 2x 2x 29x 29x 29x 29x 29x 29x 29x 29x 29x 29x 29x  
/**
 * Running the cluster service inside another process (integration plan, stage D).
 *
 * The service is the central half of the design: emitters write records beside
 * stdout and the service turns them into templates, flows, diagrams and incidents.
 * It is one process in development and in tests, and a deployed service in
 * production — and the *same* configuration shape describes both, because the sink
 * reaches it by URL wherever it runs. That is what keeps the two cases one path
 * instead of two.
 *
 * A runtime starts it in-process because the thing the service needs is the thing a
 * development run already has: the records. A service that is not running costs the
 * sink a failed delivery per batch (counted, never thrown, see `transport.ts`), so
 * nothing about a process may depend on this having been started — which is why
 * every failure here is reported and swallowed rather than raised.
 *
 * The bound port is read back from the socket rather than assumed, so a caller that
 * asks for port `0` — every test does, because two services on one machine must not
 * collide — still learns the URL it has to point the sink at.
 */
 
import type {FastifyInstance} from 'fastify';
import type {AddressInfo} from 'node:net';
import {createApp} from './app.ts';
 
/** The port an in-process service binds when none is configured (PRD §4's example). */
export const DEFAULT_SERVICE_PORT = 9455;
 
export interface StartServiceOptions {
    /**
     * The port to bind. `0` asks the operating system for a free one, which is what
     * a test wants: the URL is read back from the socket either way.
     */
    port?: number;
    /** The interface to bind. Loopback by default: an aid, not a public service. */
    host?: string;
    /**
     * The snapshot file the template registry and the observed unions are saved to
     * (PRD R4). Omitted, the service is entirely in-memory, like the digest, the
     * lineage and the incidents, which are process-lifetime by design.
     */
    persistTo?: string;
    /**
     * Called once, with the reason, when the service could not be started — a port
     * already in use being the ordinary case, since a second process in the same
     * workspace is not an error.
     */
    onError?: (error: unknown) => void;
}
 
export interface RunningService {
    /** The service itself, for a caller that wants to query it in-process. */
    app: FastifyInstance;
    /** Where the sink should send records, e.g. `http://127.0.0.1:9455`. */
    url: string;
    /** Close the service. */
    stop: () => Promise<void>;
}
 
/**
 * Start the service and hand back the URL beside it.
 *
 * `undefined` means "not started", never "throw": a process whose telemetry aid
 * could not open a socket still has its records on stdout and in its cache, and
 * refusing to run over that would make the aid more important than the work.
 */
export async function startService(
    options: StartServiceOptions = {},
): Promise<RunningService | undefined> {
    const port = options.port ?? DEFAULT_SERVICE_PORT;
    const host = options.host ?? '127.0.0.1';
    const app = createApp(options.persistTo === undefined ? {} : {persistTo: options.persistTo});
    try {
        await app.listen({port, host});
    } catch (error) {
        await app.close();
        options.onError?.(error);
        return undefined;
    }
    const address = app.server.address() as AddressInfo;
    // `listen` resolved, so the socket has an address: there is nothing to fall back
    // to, and the bound port is the operating system's answer for `0`.
    return {
        app,
        url: `http://${host}:${address.port}`,
        stop: async (): Promise<void> => {
            await app.close();
        },
    };
}