Purrx

Stream progress to the browser

Send pipeline progress to the browser as NDJSON: stage changes, live slide previews, retries and the finished deck, from one handler that also serves plain JSON.

13 min+40 XPHands-on

The pipeline already emits events: stages starting, slides being written, retries, fallbacks. Right now they stop at the server. This lesson sends them to the browser, so the page can say "Writing slides…" and draw each slide as it arrives.

  1. 0.1s{"type":"stage","stage":"outline","status":"start"}
  2. 6.3s{"type":"stage","stage":"deck","status":"start"}
  3. 11s{"type":"slide","number":1,"slide":{…}}
  4. 14s{"type":"retry","stage":"deck","delayMs":2100}
  5. 18s{"type":"slide","number":2,"slide":{…}}
  6. 31s{"type":"done","deck":{…}}
One response, many lines. The server writes each event the moment it happens; the browser handles each line as soon as its newline arrives.

NDJSON

is JSON objects separated by newlines. The server writes a line whenever something happens, and the response stays open until the last one. The browser reads the body as it arrives and handles each complete line.

server.js (the streaming route)
response.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8" });
await streamDeck({ body, userId }, (event) => response.write(JSON.stringify(event) + "\n"));
response.end();

streamDeck(input, send, deps)

  • The same steps as handleDeckRequest: validate, cache, quota, generate, refund on failure.
  • Every early exit is an event instead of a return value: { type: "error", status: 400, error } or { type: "done", deck }.
  • Pipeline events are forwarded through toProgressEvent (written for you). A streamed slide is compiled right away, so the event carries a render plan the page can draw.
  • The last event is always done or error, so the browser knows when to stop waiting.
Under the hood — Why not WebSockets or server-sent events?

The browser sends one request and gets one stream back, which is exactly what a normal fetch with a streamed body does. WebSockets add a two-way protocol we don't need. Server-sent events would work too, but their built-in client (EventSource) can only make GET requests, and our request has a body. NDJSON over fetch is the simplest thing that fits.

Your project so far

32 files · 2 new or changed in this lesson

src/handler.js

// The request handler: everything the server does for "make me a deck", as plain
// functions with no HTTP in them. They can be tested without a server, and moved
// to Express, Next.js or a serverless function without changing a line.
//
// streamDeck() reports progress as it happens; handleDeckRequest() is the same work
// for callers that just want the final answer. One code path, two ways to consume it.

import { validateRequest } from "./limits.js";
import { generateDeck } from "./pipeline.js";
import { planToPptx } from "./pptx.js";
import { createQuota } from "./quota.js";
import { createDeckStore, requestKey } from "./cache.js";
import { classifyError, friendlyMessage } from "./errors.js";
import { compileDeck } from "./compile.js";

/** Real dependencies. Tests pass their own. */
export function createDeps() {
  return {
    generateDeck,
    planToPptx,
    quota: createQuota({ limit: 10 }),
    store: createDeckStore({ maxEntries: 200 }),
  };
}

const sharedDeps = createDeps();

/**
 * Generate a deck, calling `send(event)` as things happen:
 *   { type: "stage", stage, status }          a pipeline stage started or finished
 *   { type: "slide", number, slide }          a slide was written (with its render plan, for a live preview)
 *   { type: "retry" | "fallback", … }         something went wrong and we're recovering
 *   { type: "done", deck }                    the finished deck
 *   { type: "error", status, error }          it failed; `error` is safe to show a person
 * The last event is always "done" or "error".
 */
export async function streamDeck({ body, userId = "anonymous" }, send, deps = sharedDeps) {
  const request = validateRequest(body);
  if (!request.ok) return send({ type: "error", status: 400, error: request.error });

  const key = requestKey(request.value);
  const saved = deps.store.get(key);
  if (saved) return send({ type: "done", deck: { ...publicDeck(saved), cached: true } });

  const allowance = deps.quota.consume(userId);
  if (!allowance.ok) return send({ type: "error", status: 429, error: "You've reached today's deck limit. Try again tomorrow." });

  try {
    const deck = await deps.generateDeck(request.value.topic, {
      ...request.value,
      onEvent: (event) => send(toProgressEvent(event, request.value.theme)),
    });
    const file = await deps.planToPptx({ slides: deck.slides });
    const entry = { id: key, ...deck, file };
    deps.store.set(key, entry);
    send({ type: "done", deck: { ...publicDeck(entry), remaining: allowance.remaining } });
  } catch (error) {
    deps.quota.refund(userId);
    const info = classifyError(error);
    const status = info.kind === "rate_limit" || info.kind === "quota_exhausted" ? 429 : 502;
    send({ type: "error", status, error: friendlyMessage(info) });
  }
}

/** The same work, for callers that only want the final result: { status, json }. */
export async function handleDeckRequest(input, deps = sharedDeps) {
  let last;
  await streamDeck(input, (event) => {
    if (event.type === "done" || event.type === "error") last = event;
  }, deps);
  return last.type === "done" ? { status: 200, json: last.deck } : { status: last.status, json: { error: last.error } };
}

/** Pipeline events → what the browser needs. A streamed slide is compiled so it can be previewed right away. */
function toProgressEvent(event, theme) {
  if (event.type !== "slide") return event;
  const { slides } = compileDeck(`<deck>${event.xml}</deck>`, theme);
  return { type: "slide", number: event.number, slide: slides[0] };
}

/** What the browser gets: never the raw model output or internal errors. */
function publicDeck(entry) {
  return {
    id: entry.id,
    title: entry.outline?.title ?? "Untitled deck",
    slides: entry.slides,
    problems: entry.problems,
    sources: entry.sources ?? [],
    usage: entry.usage,
    file: entry.file,
  };
}

Key takeaways

  • NDJSON is one JSON object per line: easy to write as things happen, and easy to read line by line.
  • Every early exit becomes an event, and the last event is always done or error.
  • Write the streaming version once and build the plain JSON version on top of it.

Sign in to run the exercise

Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +40 XP you are about to earn.