feat(frontend): NDJSON streamTail parser for /api/{resource}/stream

This commit is contained in:
Tyler
2026-06-20 16:02:25 -06:00
parent 3ab1de23d3
commit 11a4eaa480
2 changed files with 341 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
// ---------------------------------------------------------------------------
// Live-tail NDJSON stream parser (sub-project 5, Phase 4 Task 16).
//
// Opens `GET /api/{resource}/stream` with `Accept: application/x-ndjson`,
// pipes the response body through a `TextDecoderStream` + a manual
// newline-splitter, and yields one typed `TailEvent` per line. The
// higher-level `useTailStream` hook (Phase 5) is what subscribes to this
// generator and dispatches into `tail-store`; this module is intentionally
// pure parsing — no React, no Zustand, no fetch options other than the
// ones documented in the spec.
// ---------------------------------------------------------------------------
/**
* One event yielded by `streamTail`. The data shapes mirror the backend's
* `GET /api/{resource}/stream` NDJSON contract (see `backend/api.py`).
*
* `item.data` is intentionally `unknown` — the consumer casts to the
* page-specific shape (Claim / Remittance / Activity). The other variants
* carry the data shape the spec mandates, so consumers get a friendly
* type for the non-item events.
*/
export type TailEvent =
| { type: "item"; data: unknown }
| { type: "snapshot_end"; data: { count: number } }
| { type: "heartbeat"; data: { ts: string } }
| { type: "item_dropped"; data: { id: string } }
| { type: "error"; data: { message: string } };
export type TailResource = "claims" | "remittances" | "activity";
export interface StreamTailOptions {
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
signal?: AbortSignal;
/** Override the base URL (defaults to "" — i.e. same-origin). Used in tests. */
baseUrl?: string;
}
const KNOWN_TYPES: ReadonlySet<TailEvent["type"]> = new Set([
"item",
"snapshot_end",
"heartbeat",
"item_dropped",
"error",
]);
/**
* Open a live-tail NDJSON stream and yield one typed event per line.
*
* The generator is single-use: call `gen.return()` (or break out of a
* `for await` loop) to close the underlying fetch. When `opts.signal`
* aborts, the generator exits cleanly without throwing.
*
* @example
* for await (const ev of streamTail("claims")) {
* if (ev.type === "item") console.log(ev.data);
* }
*/
export async function* streamTail(
resource: TailResource,
opts?: StreamTailOptions,
): AsyncIterableIterator<TailEvent> {
const base = opts?.baseUrl ?? "";
const url = `${base}/api/${resource}/stream`;
let res: Response;
try {
res = await fetch(url, {
headers: { Accept: "application/x-ndjson" },
signal: opts?.signal,
});
} catch (err) {
// Aborting the signal makes fetch reject with a DOMException; the spec
// says "if signal aborts, exit cleanly (don't throw)", so we just
// return — the consumer sees a completed iterator.
if (opts?.signal?.aborted) return;
throw err;
}
if (!res.ok) {
throw new Error(`stream open failed: ${res.status}`);
}
if (!res.body) {
throw new Error("stream has no body");
}
// TextDecoderStream handles the chunked UTF-8 boundary problem for us;
// after this, we have a stream of decoded strings. We then split each
// chunk on \n and JSON.parse each non-empty line.
const stringStream = res.body.pipeThrough(new TextDecoderStream());
const reader = stringStream.getReader();
let buffer = "";
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// Check the abort signal between reads so a quick abort doesn't
// require us to wait for a network read to complete.
if (opts?.signal?.aborted) return;
let chunk: string | undefined;
let done: boolean;
try {
const result = await reader.read();
chunk = result.value;
done = result.done;
} catch (err) {
if (opts?.signal?.aborted) return;
throw err;
}
if (done) break;
if (chunk) buffer += chunk;
let nl = buffer.indexOf("\n");
while (nl !== -1) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line.length > 0) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (err) {
// Malformed lines are the backend's bug, not ours. Log and
// continue so a single bad frame doesn't kill the stream.
console.warn(
"tail-stream: malformed NDJSON line, skipping",
{ line, err },
);
nl = buffer.indexOf("\n");
continue;
}
if (
typeof parsed === "object" &&
parsed !== null &&
"type" in parsed &&
typeof (parsed as { type: unknown }).type === "string" &&
KNOWN_TYPES.has((parsed as { type: string }).type as TailEvent["type"])
) {
yield parsed as TailEvent;
} else {
// Unknown / wrong-shape line — log and skip. A future
// backend event type shouldn't crash the page.
console.warn(
"tail-stream: unknown event shape, skipping",
{ line },
);
}
}
nl = buffer.indexOf("\n");
}
}
// Flush any trailing partial line (no terminating newline).
const tail = buffer.replace(/\r$/, "");
if (tail.length > 0) {
try {
const parsed = JSON.parse(tail) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
"type" in parsed &&
typeof (parsed as { type: unknown }).type === "string" &&
KNOWN_TYPES.has((parsed as { type: string }).type as TailEvent["type"])
) {
yield parsed as TailEvent;
} else {
console.warn(
"tail-stream: unknown trailing event shape, skipping",
{ line: tail },
);
}
} catch (err) {
console.warn(
"tail-stream: malformed trailing NDJSON line, skipping",
{ line: tail, err },
);
}
}
} finally {
// Release the reader lock so the underlying connection can close
// when the test / consumer drops the iterator.
try {
reader.releaseLock();
} catch {
// already released — ignore
}
}
}