Architecture 06 of 14

The frontend layer: turn every silence into a failure

A thin client over a streaming backend, where an unhandled server event is a compile error and a failed load can never render as an empty list.

The security header was correct and the app behind it was dead.

curl -D- printed exactly the Content-Security-Policy I intended. The dev server worked. Typecheck passed, lint passed, the tests passed. The only place the truth showed up was in next build output, where a route was marked with a circle instead of an ƒ, and I had to know what that one character meant to know that every script on that page would be blocked.

It never reached production, and I want to be exact about why, because the reason is not vigilance. The nonce, the force-dynamic that the nonce requires, and the build guard that enforces the pairing all went in as one commit. I found it while building the thing, not after shipping it. Had the nonce landed alone, every check listed above would still have been green.

That is the frontend layer's whole problem in one incident. It is a thin client over a streaming backend, and almost every way it fails is a way it fails quietly. So the design is mostly a list of places where a silence was converted into something loud.


The layer is thin on purpose, because the interesting state is in a stream

apps/web/src/app holds 41 page.tsx files across four route groups: (marketing), (auth), (dashboard), (docs). Twenty two files under src/app are client components. There is no client state library, no data fetching library, no form library. Next 16 and React 19, Tailwind, a handful of Radix primitives, and that is the dependency list.

Four AI agents run on the server. They emit tokens and structured payloads over Server-Sent Events. The browser's job is to render state that has not finished arriving without lying about what it knows yet.

Everything below follows from that job.

SERVER TRANSPORT DESIGN CHOICE FAILURE MODE unknown event compile error passes silently never renders the server emits event: error already-200 stream from three places one SSE parser lib/sse.ts fetch + getReader { event, data } generic over payload sseEvents<T> event stays string nothing discriminates generic over the message sseEvents<CrewChatEvent> union keyed on event data narrows with it silent continue the frame is dropped typing indicator stops no reply, no error rendered as a message msg.event === "error" hooks/useCrewChat.ts the crew did not answer the load fails GET returns 500 or returns nothing the promise rejects api.get(path, token) inside a keyed effect the error is swallowed .catch(() => {}) data stays initial 21 places still do this error is a return value hooks/useResource.ts loading and error derived stale response dropped empty list looks fine, is wrong "All content is scheduled!" the error is shown in place of the data no false empty state
Figure 1: In each path the upper design fails quietly and the lower design fails loudly. Moving the type parameter onto the whole message turns an unhandled server event into a compile error, and making error a return value stops a failed load from rendering as an empty list.

Three SSE parsers disagreed about the protocol, so now there is one

The crew chat does not use EventSource. It is an authenticated POST, so the client reads the stream by hand: fetch(), response.body.getReader(), a TextDecoder.

That code existed three times. useCrewChat had a copy, useCrewPipeline had a copy, the onboarding page had a copy, and they disagreed with each other about the wire format. For an app whose entire subject is streaming multi-agent runs, that was the wrong thing to have three of.

Commit 302521d collapsed them into apps/web/src/lib/sse.ts. The payoff was less about tidiness than about arithmetic: all three copies carried the same four latent protocol bugs, so finding each one once fixed it three times.

    const lines = buffer.split(/\r?\n/);
    // On the last read there is no more data coming, so the tail IS complete.
    buffer = done ? "" : lines.pop() ?? "";

    for (const line of lines) {
      if (line === "") {
        event = "message"; // blank line ends a message; reset to the default
        continue;
      }
      if (line.startsWith(":")) continue; // comment/heartbeat
      if (line.startsWith("event:")) {
        event = line.slice(6).trim();
        continue;
      }
      if (line.startsWith("data:")) {
        // trimStart() handles both "data: {...}" and "data:{...}".
        const raw = line.slice(5).trimStart();
        try {
          yield { event, data: JSON.parse(raw) } as M;
        } catch {
          // Ignore an unparseable frame; keep the stream alive.
        }
      }
    }

The split(/\r?\n/) handles CRLF, which none of the copies did. The trimStart() accepts data:{...} with no space, which the spec allows and all three copies dropped on the floor. The buffer = done ? "" : ... line flushes the trailing buffer at end of stream, so a final event arriving without a newline is not lost. And one line earlier in the file, decoder.decode() with no argument flushes the decoder, so a multi-byte character split across a network chunk boundary survives.

The failure mode here is partial rather than loud. No crash, no error frame. A run that renders almost all the way through and then silently drops its last event, or corrupts one character in the middle of a paragraph, on a network you cannot reproduce locally.

The suite in apps/web/src/lib/sse.test.ts covers chunk boundaries mid-JSON, CRLF, missing trailing newline, malformed frames, heartbeats, and event-name reset. Before that file existed, the most bug-prone code in the frontend had zero tests.


A type parameter over the payload cannot discriminate anything

The first version of the parser was generic over the payload. sseEvents<T> where T is the shape of data. That is the obvious signature and it is useless, because no matter what T you pass, event is still string. Every consumer had to re-check the event name against a bare string and then trust itself about which payload it was holding.

The hand-rolled copy that came before it did not even do that. useCrewChat read the event name into a variable it then never used, and guessed the type from the payload's shape: data.agent && data.status meant one thing, data.agent && "content" in data meant another. Any new server event that happened to carry an agent field would be silently mis-routed or dropped. Consolidating the three parsers ended the guessing, but it did not make the event name mean anything to the compiler.

The fix in apps/web/src/lib/sse.ts moves the type parameter off the payload and onto the whole message:

export type SseMessage<T = unknown> = { event: string; data: T };

export async function* sseEvents<M extends SseMessage<unknown> = SseMessage>(
  body: ReadableStream<Uint8Array>,
): AsyncGenerator<M> {

The type parameter is the whole message now, not just its payload. Which means a caller can pass a union discriminated on the event name and have data narrow along with it. Those unions live in apps/web/src/lib/types.ts, and each one names the Python emitter it was derived from:

export type CrewChatEvent =
  | { event: "agent_thinking"; data: { agent: CrewChatAgent; status: string } }
  | {
      event: "agent_message";
      data: {
        agent: CrewChatAgent;
        content: string;
        done: boolean;
        full_text?: string;
      };
    }
  | { event: "error"; data: { message: string } }
  | { event: "done"; data: Record<string, never> };

At the call site in apps/web/src/hooks/useCrewPipeline.ts, the compose pipeline's reducer is a plain switch over msg.event, and each branch sees only the payload the backend actually sends with that name:

      for await (const msg of sseEvents<ComposeEvent>(res.body)) {
        setState((s) => {
          switch (msg.event) {
            case "status":
              return { ...s, agents: { ...s.agents, [msg.data.agent]: msg.data.state } };
            case "angles":
              return { ...s, angles: msg.data.angles || [] };
            case "variants":
              return { ...s, variants: msg.data.variants || [], selected: 0 };
            case "score":
              return { ...s, score: msg.data.brand_voice_score ?? null, notes: msg.data.notes || [] };
            case "content_created":
              return { ...s, status: "ready", contentItemId: msg.data.content_item_id };
            case "error":
              return { ...s, status: "error", error: msg.data.message || "The crew hit an error." };
            default:
              return s;
          }
        });
      }

Generic over the payload, an unhandled event is a silent continue. Generic over the whole message, it is a compile error. Same runtime, opposite failure mode.

Commit 859a077 took any from 28 occurrences to 0 across src, and it stayed at 0. Grep it today and the word only appears in prose.

The typing paid for itself immediately, because writing those unions surfaced two real bugs that nothing else had.

The first: both chat surfaces silently swallowed the backend's error frames. The backend emits event: error on an already-200 stream from three places, including the stream guard's concurrency limit and both of its timeouts. The old shape-guessing code had no branch for it, so the frame fell through to a continue. The user sent a message, the typing indicator stopped, and the transcript never changed. No reply, no error, nothing to retry. apps/web/src/hooks/useCrewChat.ts now renders it as a system message, on the reasoning that to the user this is the same event as a dropped connection: the crew did not answer.

The second: crew/page.tsx builds a "content created" card for an event the chat endpoint no longer emits. Only a stale docstring in handle_message_stream still mentions it. The branch is dead and has been for a while, and it is still there, because that page has not moved to the union yet. Had it used one, the branch would have been a type error the day the backend changed.


A failed load must never render as an empty list

Fourteen pages had hand-written the same eight-line block: declare a load(), call it from an effect keyed on [token], swallow the error, flip a loading flag.

The swallow is the part that matters. .catch(() => {}) leaves the page showing its initial value, and an empty array renders identically whether the server returned nothing or returned a 500. That produced a specific and repeated class of bug in this app. The integrations page showed "Not connected" with a Connect button after a 500, inviting a user to create a duplicate connection. The calendar painted an empty month and the cheerful message "All content is scheduled!". Analytics recommendations just vanished.

apps/web/src/hooks/useResource.ts makes error a first-class return value and adds a sequence guard:

  const key = token && path ? `${nonce} ${token} ${path}` : null;

  useEffect(() => {
    if (!token || !path || !key) return;
    const seq = ++latest.current;

    api
      .get<T>(path, token)
      .then((d) => {
        if (seq === latest.current) {
          setState({ settledFor: key, data: d, error: null });
        }
      })
      .catch((e: unknown) => {
        if (seq === latest.current) {
          const message = e instanceof Error ? e.message : "Could not load this data";
          setState((s) => ({ settledFor: key, data: s.data, error: message }));
        }
      });
  }, [key, path, token]);

The key is the whole request identity: a refresh nonce, the token, and the path. Putting the token in there means swapping identities counts as a new request rather than leaving loading false over the previous identity's data. Putting the nonce in there makes refresh() a new key even when the path has not changed.

The sequence guard exists for the content page, whose filter is part of the request path. Switch filters twice quickly and the slower first response can land last and paint rows for the wrong filter. seq === latest.current drops it.

Then there is the part I like most, which is that loading and error are never written:

  const loading = key !== null && state.settledFor !== key;

  return {
    data: state.data,
    loading,
    error: loading ? null : state.error,
    refresh,
    setData,
  };

The effect writes exactly one state cell, and only from the promise callbacks. Everything else is derived during render. No token or no path means nothing to load, so not loading. A settledFor that does not match the current key means a request is still in flight. And an error from the previous key is not this key's error, so it is masked while loading. That is the same sequence of observable states the old setLoading(true) / setError(null) / setLoading(false) effect produced, minus a render pass, and minus the setState-in-effect the React Compiler rules reject.

The failure mode this whole hook guards against is a page that looks fine and is wrong. Nothing throws, nothing logs, and the user makes a decision on data the server never sent.


The CSP nonce is minted per request, which forces every route dynamic

Access tokens live in localStorage. That is a documented demo posture, not a defensible one, and it has a direct consequence: any script that executes in this app can read them, so script execution is account takeover. Which makes script-src the only CSP directive that is really doing work here.

A static script-src 'self' from next.config.ts does not survive contact with Next, because Next bootstraps the client with an inline script that sets self.__next_r. Block it and you get Invariant: Expected a request ID to be defined for the document before anything renders.

So the CSP is built per request in apps/web/src/proxy.ts (this Next version renames middleware.ts to proxy.ts):

export function proxy(request: NextRequest) {
  const isDev = process.env.NODE_ENV === "development";
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");

  // Where the browser may reach the API. Same origin is not enough: the API is
  // a separate service, and without it every fetch is blocked.
  const api = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8001";
  const apiWs = api.replace(/^http/, "ws");

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "font-src 'self' https://fonts.gstatic.com data:",
    "img-src 'self' data: blob: https:",
    `connect-src 'self' ${api} ${apiWs}`,
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "object-src 'none'",
  ].join("; ");

Next parses the nonce out of this header during server rendering and attaches it to its own framework scripts, page bundles, and inline tags. Nothing downstream threads it through.

Two asymmetries in that list are deliberate. Styles get 'unsafe-inline' and scripts never do, because Tailwind's dev injection and React's style={{...}} both need inline styles, and a style-based attack is a far weaker primitive than script execution. And 'unsafe-eval' is development only, because React's dev build uses eval to rebuild server error stacks, and shipping it would give an injected string a route to becoming code.

The matcher matters as much as the header:

export const config = {
  matcher: [
    {
      // Static assets carry no inline scripts and are immutable, so a
      // per-request nonce on them would only defeat caching.
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};

The build guard reads Next's manifest instead of the console

Here is where the dead shell came from.

A nonce is applied during server rendering. A statically prerendered page is built when no request exists, so its scripts get none. And 'strict-dynamic' instructs browsers that honour it to ignore 'self' entirely. Put those together and every script on a prerendered page is blocked, and the app is served as HTML with no behaviour.

Nothing in the toolchain caught it. next dev renders dynamically, so the app worked perfectly locally. curl -D- showed a flawless header, because the header genuinely was correct. Typecheck, lint and unit tests are all build-mode agnostic. The only visible symptom was 39 routes marked static in next build output.

The fix is export const dynamic = "force-dynamic" in the root layout, which is a correctness requirement rather than a performance preference, and it costs the marketing and docs pages their static prerendering. The guard is apps/web/scripts/check-csp-build.mjs, and it asserts against the manifest rather than scraped console output:

const ALLOWED = new Set(["/_global-error", "/favicon.ico", "/icon.svg", "/apple-icon.png"]);

const proxy = readFileSync(join(root, "src/proxy.ts"), "utf8");
const usesNonce = proxy.includes("'nonce-") && proxy.includes("strict-dynamic");
const routes = Object.keys(JSON.parse(readFileSync(manifestPath, "utf8")).routes ?? {});
const offenders = routes.filter((r) => !ALLOWED.has(r));

Two details earn their keep. The script reads proxy.ts first and exits zero if the nonce is gone, so it is guarding a specific pairing rather than enforcing a preference for dynamic rendering. And pnpm build is next build && node scripts/check-csp-build.mjs, so the check runs where the bug lives.

The current build's manifest holds 4 prerendered entries, and all four are in ALLOWED. Comment out the dynamic export and rebuild and the guard fails with all 39 routes listed, which is the only way to know a guard is load-bearing.


Where this layer is still quiet

The claim above is that this frontend converts silences into failures. I counted the silences it still has.

Twenty one places in apps/web/src still swallow an error with a bare .catch(() => {}) or catch {}. Ten of the fifteen dashboard pages go through useResource; the rest hand-roll their loads with exactly the pattern the hook was written to delete. settings/page.tsx loads four resources in one effect and discards the failure of all four. content/[id]/page.tsx, the page a user spends the most time on, has four. Even content/page.tsx, which does use the hook for its main list, still swallows the campaign-name lookup that renders the chips on every row.

Two other honest limits. The parsed SSE payload is a type assertion, not a runtime check: JSON.parse returns any, the generator asserts it into the union, and nothing validates it. That trust boundary is at least in one place instead of at every consumer, but it is trust. And useResource's own test file tests a re-implementation of the sequence guard rather than the hook itself, which keeps the race deterministic and means the hook has no test of its own.

The whole frontend suite is 54 tests across 10 files. That is a small number for 41 pages. Thirty one of the 54 sit on the streaming path, the loading hook, the API client and auth, which is where the bugs actually were; the other 23 cover a dialog, a diff helper and two pages. Every other page is untested.


The one-liner: every design decision in this layer is the same decision, which is to take something the app was doing quietly and make it fail out loud, at compile time if possible and at build time otherwise, because the browser is the only place that will do it for you.


Next in the series: the deployment layer. One t4g.small ARM spot instance, Neon Postgres, and containers behind Caddy for six to eight dollars a month.

The views expressed here are my own and are not related to or reflective of my work or any organization I am affiliated with.

Next

Expand Only

The data layer is allowed to add to the schema and to do nothing else, and every other decision in it follows from that one rule.