feat(frontend): NDJSON streamTail parser for /api/{resource}/stream
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user