The existing 32 checks prove the deployment answers -- TLS, auth, quorum commit,
convergence, isolation, dashboards, backups. Not one wrote a signal and observed
an order change, so VISION.md:17 "Ranking is not a feature. It is a primitive."
was unverified. This adds a 60-item content-feed app and five assertions that
verify the product's semantics, on a hermetic standalone node.
Added
- tests/e2e/app/: fixture contract (60 items, 4 categories, each owning one
unoccupied 100-id embedding cluster), a deep-module harness owning the whole
lifecycle behind startApp(), the product page, and an app:dev entry point.
- tidal-stress/src/bin/feed-fixture.rs: seeds the catalog and emits brute-force
ground truth, reusing recall::embedding_for rather than adding a third copy of
the corpus generator (tidal/src/db/items.rs already holds a second).
- GroundTruth::from_ids: the oracle now serves sparse id sets. build() delegates,
so there is no transient copy even at 1M, and top_k indexes positionally.
- 10-ranking-semantics.spec.ts (5 hermetic checks) and
11-ranking-integrity.spec.ts (2 cluster tripwires).
- playwright.semantics.config.ts + CAP-016 demo beat (walkthrough 82s -> 90s).
Measured, not merely green
- like: index 59 -> 0, like_boost 2.0, with no sleep between write and read.
- decay: implied half-lives 7.0007 d and 14.0014 d against a schema declaring
7 d and 14 d, recovered from a 4-second window via H = t*ln2 / -ln(v2/v1) and
compared against the schema the node actually loaded, not a hardcoded copy.
- ANN: top-10 identical to brute-force cosine on all four probes; self-distance
0.0148-0.0197 against a 0.05 tolerance.
- rank: dense 1..60 on standalone vs [1,1,1,2,2,3,4,3,4,5,6,5] on the cluster.
Three product findings, pinned and routed to @tidal-engineer
- BUG-018 (High) skip is durably accepted and query-time inert. Penalty is fully
implemented (ranking/profile.rs:227 -> executor/signal_values.rs:183, labelled
{signal}_penalty at executor/mod.rs:65) but skeleton() sets penalties: vec![]
(ranking/builtins.rs:62) and none of the 27 built-ins overrides it. So
VISION.md:187 "negative signals are equal citizens" holds for no shipped
profile. Same anti-pattern as the reseed defects and scatter_merge: a guard
present on one path, absent on its sibling.
- BUG-019 (Medium) three built-ins read signals this schema does not declare --
trending/share_velocity, hidden_gems/completion, controversial/dislike -- so
those terms are permanently 0 and trending ranks on view_velocity alone.
- BUG-020 (Low) for_you declares Scan{sort_field:"created_at"} but ignores a
created_at metadata value; an order matching neither id-asc nor
created_at-desc came back strictly id-ascending.
Two assertions therefore report a gap rather than a success, written as tripwires
whose failure message says what to do when the gap closes. The rank defect is
localised, not fixed: scatter_merge (cluster/node.rs:7542) returns a merged slice
without re-stamping rank while scores stay correctly ordered, so the fault is the
missing stamp and not the merge's sort.
Notes
- Hermetic by construction: its own config, because FullConfig.projects is not
filtered by --project and globalSetup publishes credentials into the main
process that forked workers inherit -- so a setup project cannot replace it,
and weakening globalSetup would destroy the fail-loud behaviour that is its
purpose. Verified with KUBECONFIG=/nonexistent and all E2E_* unset.
- Never touches the deployed corpus: skip is permanent: true, so seeding it into
production would be irreversible.
- The page contains no sort, no hostname and no credential; the harness proxy
injects auth server-side so no bearer reaches a browser or a capture.
- Schema comes from k8s/cluster/schema-configmap.yaml, asserted at 1536 dims;
tidal-server/config/default-schema.yaml declares 128 and would 422 every write.
Verification: 5 semantics + 34 regression + 10 demo captures green; tsc clean;
tidal-stress clippy clean under clippy::all=deny with unwrap_used=deny; 2101
tidaldb lib tests; preflight 10/10 perfect; render 90.05s/2700 frames with zero
empty boundary frames; zero orphan processes or temp dirs after teardown.
517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
/**
|
|
* The content-feed app's lifecycle, in one place.
|
|
*
|
|
* `startApp()` boots a throwaway standalone tidalDB, seeds the fixture catalog,
|
|
* serves the page plus a token-injecting `/api` proxy, and hands back a handle
|
|
* whose `close()` unwinds all of it. The semantic spec, the demo capture and
|
|
* `npm run app:dev` all drive the identical lifecycle, so what a human sees by
|
|
* hand is what the assertions ran against.
|
|
*
|
|
* Two rules shape the design:
|
|
*
|
|
* - **Relative `/api` only.** `.sdlc/guidance.md:165` forbids a hardcoded
|
|
* `http://localhost:PORT` in frontend code and prescribes a dev-server proxy.
|
|
* Independently, a bearer must never reach a browser — a page holding the key
|
|
* leaks it to anyone who opens devtools, and the demo capture would photograph
|
|
* it. The proxy satisfies both: the page calls `/api/feed`, the server adds
|
|
* `Authorization`, the key stays server-side.
|
|
* - **Poll, never sleep.** A fixed sleep either wastes time or produces the
|
|
* empty-body false negative that reads exactly like a dead node. This is the
|
|
* same discipline as `tests/e2e/support/cluster.ts:154` `portForward`.
|
|
*
|
|
* It targets a LOCAL standalone node, never the deployed cluster: `skip` is
|
|
* declared `permanent: true`, so seeding signals into production would be an
|
|
* irreversible mutation of the live corpus.
|
|
*/
|
|
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
import { createConnection } from 'node:net';
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { extname, join, resolve, sep } from 'node:path';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
|
import { run } from '../support/cluster.ts';
|
|
import { redact } from '../support/env.ts';
|
|
import {
|
|
CATALOG,
|
|
EMBEDDING_DIM,
|
|
PROBE_IDS,
|
|
type FixtureGroundTruth,
|
|
} from './fixture-contract.ts';
|
|
|
|
const REPO_ROOT = resolve(import.meta.dirname, '../../..');
|
|
const PUBLIC_DIR = join(import.meta.dirname, 'public');
|
|
const SCHEMA_CONFIGMAP = join(REPO_ROOT, 'k8s/cluster/schema-configmap.yaml');
|
|
const SERVER_BIN = join(REPO_ROOT, 'target/debug/tidal-server');
|
|
const FIXTURE_BIN = join(REPO_ROOT, 'target/debug/feed-fixture');
|
|
|
|
const BOOT_TIMEOUT_MS = 60_000;
|
|
const BUILD_TIMEOUT_MS = 900_000;
|
|
const SEED_TIMEOUT_MS = 120_000;
|
|
const KILL_GRACE_MS = 3_000;
|
|
|
|
/**
|
|
* Ask the OS for a free port rather than computing one from a base.
|
|
*
|
|
* A fixed base collides with whatever is already holding it — a leftover node, a
|
|
* second checkout, or `npm run app:dev` running in another terminal while the
|
|
* suite runs. Letting the kernel choose removes that whole class of failure. The
|
|
* handover window between close and the child's bind is microseconds, and a lost
|
|
* race surfaces as the child's own "address in use" stderr rather than a hang.
|
|
*/
|
|
async function freePort(): Promise<number> {
|
|
const probe = createServer();
|
|
const { promise, resolve: settle, reject } = Promise.withResolvers<number>();
|
|
probe.once('error', reject);
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const address = probe.address();
|
|
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
probe.close(() => settle(port));
|
|
});
|
|
return promise;
|
|
}
|
|
|
|
export type RunningApp = {
|
|
/** The app origin: serves the page and proxies `/api/*`. */
|
|
url: string;
|
|
/** The tidalDB node itself, for probes that bypass the app. */
|
|
nodeUrl: string;
|
|
/** The oracle the fixture binary emitted for this exact corpus. */
|
|
groundTruth: FixtureGroundTruth;
|
|
/** Decay as declared by the schema THIS node loaded, not a copy of it. */
|
|
declaredDecay: DeclaredDecay;
|
|
/**
|
|
* The throwaway data dir. Exposed so a caller can say where a run's state went,
|
|
* and so a long-lived host can install a synchronous last-resort cleanup — an
|
|
* async `close()` that throws would otherwise leave the dir behind.
|
|
*/
|
|
dataDir: string;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
export type StartAppOptions = {
|
|
/** Bearer to inject into proxied requests. A local standalone needs none. */
|
|
apiKey?: string;
|
|
/** Log lifecycle progress to stdout — for `npm run app:dev`, quiet in tests. */
|
|
verbose?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Extract the schema the DEPLOYED cluster loads, from its ConfigMap wrapper.
|
|
*
|
|
* Not interchangeable with `tidal-server/config/default-schema.yaml`: that file is
|
|
* otherwise identical but declares `dimensions: 128`, while the cluster declares
|
|
* 1536. Seeding 1536-wide vectors against a 128-wide slot is rejected with a 422
|
|
* that reads like a malformed body, so the dimension is asserted here rather than
|
|
* discovered three layers downstream.
|
|
*/
|
|
async function extractSchema(): Promise<string> {
|
|
const raw = await readFile(SCHEMA_CONFIGMAP, 'utf8');
|
|
const marker = ' schema.yaml: |\n';
|
|
const start = raw.indexOf(marker);
|
|
if (start < 0) {
|
|
throw new Error(`${SCHEMA_CONFIGMAP} has no ' schema.yaml: |' block scalar to extract`);
|
|
}
|
|
const body = raw.slice(start + marker.length);
|
|
const schema = body
|
|
.split('\n')
|
|
.map((line) => (line.startsWith(' ') ? line.slice(4) : line))
|
|
.join('\n');
|
|
|
|
if (!schema.startsWith('signals:')) {
|
|
throw new Error(`extracted schema does not start with 'signals:'; got: ${schema.slice(0, 80)}`);
|
|
}
|
|
if (!new RegExp(`dimensions:\\s*${EMBEDDING_DIM}\\b`).test(schema)) {
|
|
throw new Error(
|
|
`extracted schema does not declare dimensions: ${EMBEDDING_DIM}. The fixture ` +
|
|
`contract and the schema must agree or every embedding write is rejected as 422.`,
|
|
);
|
|
}
|
|
return schema;
|
|
}
|
|
|
|
/**
|
|
* The decay each signal declares, read out of the schema the node actually
|
|
* loaded.
|
|
*
|
|
* The decay assertion compares an OBSERVED decay rate against a DECLARED
|
|
* half-life. Hardcoding the declared value in the test would make it a
|
|
* tautology the moment the schema changed, so it is parsed from the same string
|
|
* that was handed to `--schema`.
|
|
*
|
|
* A deliberately small parser rather than a YAML dependency: the shape it reads
|
|
* is four lines of a file this repo owns, and it fails loudly (returns nothing
|
|
* for a signal) rather than guessing.
|
|
*/
|
|
export type DeclaredDecay = Record<string, number | 'permanent'>;
|
|
|
|
function parseDeclaredDecay(schema: string): DeclaredDecay {
|
|
const declared: DeclaredDecay = {};
|
|
let current: string | undefined;
|
|
for (const line of schema.split('\n')) {
|
|
const name = /^\s*-\s*name:\s*(\S+)/.exec(line);
|
|
if (name?.[1]) {
|
|
current = name[1];
|
|
continue;
|
|
}
|
|
if (current === undefined) continue;
|
|
const halfLife = /^\s*half_life_seconds:\s*(\d+)/.exec(line);
|
|
if (halfLife?.[1]) {
|
|
declared[current] = Number(halfLife[1]);
|
|
continue;
|
|
}
|
|
if (/^\s*permanent:\s*true/.test(line)) declared[current] = 'permanent';
|
|
}
|
|
return declared;
|
|
}
|
|
|
|
/** True once something accepts a TCP connection on the port. */
|
|
async function portAccepts(port: number): Promise<boolean> {
|
|
const { promise, resolve: settle } = Promise.withResolvers<boolean>();
|
|
const socket = createConnection({ port, host: '127.0.0.1' });
|
|
const done = (ok: boolean) => {
|
|
socket.destroy();
|
|
settle(ok);
|
|
};
|
|
socket.setTimeout(1_000);
|
|
socket.once('connect', () => done(true));
|
|
socket.once('timeout', () => done(false));
|
|
socket.once('error', () => done(false));
|
|
return promise;
|
|
}
|
|
|
|
/** True once the node answers `/health` with 2xx. */
|
|
async function healthOk(nodeUrl: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${nodeUrl}/health`, {
|
|
signal: AbortSignal.timeout(2_000),
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const buildsInFlight = new Map<string, Promise<void>>();
|
|
|
|
/**
|
|
* Build a cargo binary if it is not already present, once per process.
|
|
*
|
|
* `feed-fixture` is new, so no existing tree has it; failing with "binary
|
|
* missing, go run cargo" would make the hermetic suite un-runnable from a clean
|
|
* checkout for no reason. Dependencies are already compiled, so this is seconds,
|
|
* not minutes — but the timeout allows for a cold cache.
|
|
*/
|
|
function ensureBinary(binPath: string, cargoArgs: string[], verbose: boolean): Promise<void> {
|
|
if (existsSync(binPath)) return Promise.resolve();
|
|
const existing = buildsInFlight.get(binPath);
|
|
if (existing) return existing;
|
|
|
|
const build = (async () => {
|
|
if (verbose) console.log(`[app] building ${binPath} (missing)`);
|
|
const result = await run('cargo', cargoArgs, { timeoutMs: BUILD_TIMEOUT_MS });
|
|
if (result.code !== 0 || !existsSync(binPath)) {
|
|
throw new Error(
|
|
`could not build ${binPath}\n$ ${result.command}\nexit ${result.code}\n${result.stderr}`,
|
|
);
|
|
}
|
|
})();
|
|
buildsInFlight.set(binPath, build);
|
|
return build;
|
|
}
|
|
|
|
/** MIME types for the handful of things the page is made of. */
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
};
|
|
|
|
/**
|
|
* Read the whole request body as text.
|
|
*
|
|
* Text rather than bytes because the only thing the page ever sends is one small
|
|
* JSON object, and `string` is unambiguously a `BodyInit` — TS's `BufferSource`
|
|
* is `ArrayBufferView<ArrayBuffer>`, which a pooled Node `Buffer` does not
|
|
* satisfy. If this ever needs to forward binary, that is a real change, not a cast.
|
|
*/
|
|
async function readBody(req: IncomingMessage): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
return Buffer.concat(chunks).toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Forward one `/api/*` request to the node, adding the bearer.
|
|
*
|
|
* A byte pipe: it must never reorder, re-score or filter a response, because the
|
|
* ordering IS the thing under test (`CODING_GUIDELINES.md:88`).
|
|
*/
|
|
async function proxy(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
nodeUrl: string,
|
|
apiKey: string | undefined,
|
|
): Promise<void> {
|
|
const path = (req.url ?? '/').slice('/api'.length);
|
|
const headers: Record<string, string> = { accept: 'application/json' };
|
|
const contentType = req.headers['content-type'];
|
|
if (contentType) headers['content-type'] = contentType;
|
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
|
|
const method = req.method ?? 'GET';
|
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
const body = hasBody ? await readBody(req) : undefined;
|
|
|
|
try {
|
|
const upstream = await fetch(`${nodeUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body,
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
const payload = Buffer.from(await upstream.arrayBuffer());
|
|
res.writeHead(upstream.status, {
|
|
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
'content-length': String(payload.byteLength),
|
|
});
|
|
res.end(payload);
|
|
} catch (error) {
|
|
// Surface the upstream fault as a real status rather than hanging the page.
|
|
const message = redact(error instanceof Error ? error.message : String(error));
|
|
const payload = Buffer.from(JSON.stringify({ error: `proxy: ${message}` }));
|
|
res.writeHead(502, {
|
|
'content-type': 'application/json',
|
|
'content-length': String(payload.byteLength),
|
|
});
|
|
res.end(payload);
|
|
}
|
|
}
|
|
|
|
/** Serve one file from the public dir, refusing anything outside it. */
|
|
async function serveStatic(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const requested = (req.url ?? '/').split('?')[0] ?? '/';
|
|
const relative = requested === '/' ? 'index.html' : requested.replace(/^\/+/, '');
|
|
const target = resolve(PUBLIC_DIR, relative);
|
|
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + sep)) {
|
|
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
res.end('forbidden');
|
|
return;
|
|
}
|
|
try {
|
|
const body = await readFile(target);
|
|
res.writeHead(200, {
|
|
'content-type': CONTENT_TYPES[extname(target)] ?? 'application/octet-stream',
|
|
'content-length': String(body.byteLength),
|
|
});
|
|
res.end(body);
|
|
} catch {
|
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
res.end('not found');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Boot a standalone node, seed the fixture catalog, and serve the app against it.
|
|
*
|
|
* Every stage that can fail reports why: an early child exit surfaces the node's
|
|
* stderr, and a non-zero seed exit surfaces the seeder's transcript, because a
|
|
* partially seeded corpus would make every downstream assertion meaningless.
|
|
*/
|
|
export async function startApp(options: StartAppOptions = {}): Promise<RunningApp> {
|
|
const verbose = options.verbose ?? false;
|
|
const log = (message: string) => {
|
|
if (verbose) console.log(`[app] ${message}`);
|
|
};
|
|
|
|
await ensureBinary(SERVER_BIN, ['build', '-p', 'tidal-server', '--bin', 'tidal-server'], verbose);
|
|
await ensureBinary(
|
|
FIXTURE_BIN,
|
|
['build', '-p', 'tidal-stress', '--bin', 'feed-fixture'],
|
|
verbose,
|
|
);
|
|
|
|
const dataDir = await mkdtemp(join(tmpdir(), 'tidaldb-feed-app-'));
|
|
const schemaPath = join(dataDir, 'schema.yaml');
|
|
const catalogPath = join(dataDir, 'catalog.json');
|
|
const truthPath = join(dataDir, 'truth.json');
|
|
const schema = await extractSchema();
|
|
const declaredDecay = parseDeclaredDecay(schema);
|
|
await writeFile(schemaPath, schema, 'utf8');
|
|
await writeFile(catalogPath, JSON.stringify(CATALOG), 'utf8');
|
|
// `--data-dir` must already exist: the server treats a missing directory as a
|
|
// config error rather than creating it (verified against its own stderr).
|
|
await mkdir(join(dataDir, 'data'), { recursive: true });
|
|
|
|
const nodePort = await freePort();
|
|
const nodeUrl = `http://127.0.0.1:${nodePort}`;
|
|
|
|
// `--listen` also reads the PORT env var (tidal-server/src/main.rs:99). An
|
|
// explicit flag wins in clap, but dropping PORT removes the ambiguity entirely.
|
|
const childEnv = { ...process.env };
|
|
delete childEnv.PORT;
|
|
delete childEnv.TIDAL_CONFIG;
|
|
|
|
log(`booting node on ${nodeUrl} (data ${dataDir})`);
|
|
const child: ChildProcess = spawn(
|
|
SERVER_BIN,
|
|
[
|
|
'standalone',
|
|
'--listen',
|
|
`127.0.0.1:${nodePort}`,
|
|
'--schema',
|
|
schemaPath,
|
|
'--data-dir',
|
|
join(dataDir, 'data'),
|
|
],
|
|
{ cwd: REPO_ROOT, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
|
|
let nodeStderr = '';
|
|
let exitInfo: string | undefined;
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
nodeStderr += chunk.toString();
|
|
});
|
|
child.stdout?.on('data', () => {
|
|
/* the node's own log; kept off the test transcript unless it fails */
|
|
});
|
|
child.once('exit', (code, signal) => {
|
|
exitInfo = `tidal-server exited early (code=${code} signal=${signal})`;
|
|
});
|
|
|
|
const stopNode = async () => {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
const { promise, resolve: settle } = Promise.withResolvers<void>();
|
|
const hardKill = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
settle();
|
|
}, KILL_GRACE_MS);
|
|
child.once('exit', () => {
|
|
clearTimeout(hardKill);
|
|
settle();
|
|
});
|
|
child.kill('SIGTERM');
|
|
await promise;
|
|
};
|
|
|
|
const cleanup = async (server?: Server) => {
|
|
if (server) {
|
|
const { promise, resolve: settle } = Promise.withResolvers<void>();
|
|
server.close(() => settle());
|
|
server.closeAllConnections?.();
|
|
await promise;
|
|
}
|
|
await stopNode();
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
};
|
|
|
|
try {
|
|
const deadline = Date.now() + BOOT_TIMEOUT_MS;
|
|
let ready = false;
|
|
while (Date.now() < deadline) {
|
|
if (exitInfo) {
|
|
throw new Error(`${exitInfo}\n--- node stderr ---\n${redact(nodeStderr.trim())}`);
|
|
}
|
|
if (await healthOk(nodeUrl)) {
|
|
ready = true;
|
|
break;
|
|
}
|
|
await sleep(150);
|
|
}
|
|
if (!ready) {
|
|
throw new Error(
|
|
`tidal-server never answered ${nodeUrl}/health within ${BOOT_TIMEOUT_MS}ms\n` +
|
|
`--- node stderr ---\n${redact(nodeStderr.trim())}`,
|
|
);
|
|
}
|
|
log(`node healthy; seeding ${CATALOG.length} items`);
|
|
|
|
const seed = await run(
|
|
FIXTURE_BIN,
|
|
[
|
|
'--base-url',
|
|
nodeUrl,
|
|
'--catalog',
|
|
catalogPath,
|
|
'--out',
|
|
truthPath,
|
|
'--dim',
|
|
String(EMBEDDING_DIM),
|
|
...PROBE_IDS.flatMap((id) => ['--probe', String(id)]),
|
|
],
|
|
{ timeoutMs: SEED_TIMEOUT_MS },
|
|
);
|
|
if (seed.code !== 0) {
|
|
throw new Error(
|
|
`fixture seeding failed — a partial corpus makes every assertion ` +
|
|
`meaningless, so this is fatal.\n$ ${seed.command}\nexit ${seed.code}\n` +
|
|
`${seed.stdout}\n${seed.stderr}`,
|
|
);
|
|
}
|
|
const groundTruth = JSON.parse(await readFile(truthPath, 'utf8')) as FixtureGroundTruth;
|
|
|
|
const route = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
if (req.url?.startsWith('/api/') === true) {
|
|
return proxy(req, res, nodeUrl, options.apiKey);
|
|
}
|
|
if ((req.url ?? '/').split('?')[0] === '/catalog.json') {
|
|
// The page joins ranked ids to titles, so it needs the catalog — served
|
|
// from the same contract the seeder used, never a second copy.
|
|
const payload = Buffer.from(JSON.stringify(CATALOG));
|
|
res.writeHead(200, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'content-length': String(payload.byteLength),
|
|
});
|
|
res.end(payload);
|
|
return;
|
|
}
|
|
return serveStatic(req, res);
|
|
};
|
|
|
|
const server = createServer((req, res) => {
|
|
route(req, res).catch((error: unknown) => {
|
|
if (res.headersSent) {
|
|
res.end();
|
|
return;
|
|
}
|
|
res.writeHead(500, { 'content-type': 'text/plain' });
|
|
res.end(redact(error instanceof Error ? error.message : String(error)));
|
|
});
|
|
});
|
|
|
|
// Bind port 0 and read back what the kernel gave us: no probe, no race.
|
|
const listening = Promise.withResolvers<number>();
|
|
server.once('error', (error) => listening.reject(error));
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address();
|
|
listening.resolve(typeof address === 'object' && address !== null ? address.port : 0);
|
|
});
|
|
const appPort = await listening.promise;
|
|
|
|
const url = `http://127.0.0.1:${appPort}`;
|
|
// Prove the app origin is actually reachable before handing it to a caller,
|
|
// so a bind that silently failed cannot look like a page-render bug later.
|
|
if (!(await portAccepts(appPort))) {
|
|
throw new Error(`app server bound ${url} but the port does not accept connections`);
|
|
}
|
|
log(`app ready at ${url}`);
|
|
|
|
return {
|
|
url,
|
|
nodeUrl,
|
|
groundTruth,
|
|
declaredDecay,
|
|
dataDir,
|
|
close: () => cleanup(server),
|
|
};
|
|
} catch (error) {
|
|
await cleanup();
|
|
throw error;
|
|
}
|
|
}
|