# Recipe — Server-Sent Events (SSE) through a dev/prod proxy

> **Audience:** Web consumers with a live-push surface — a FastAPI
> (or any ASGI) `text/event-stream` endpoint consumed by a browser
> `EventSource`, sitting behind a SvelteKit / Next / Vite dev proxy
> in development and an nginx / Caddy / ALB reverse proxy in
> production. Also covers the Playwright gotchas that make SSE
> end-to-end tests flaky.

This recipe closes **PAPER-93**. The API side of an SSE endpoint is
usually correct on the first try; the failure mode is that the
**proxy silently buffers** the response body, so live push violates a
low-latency budget even though nothing is wrong with the publisher.
The root cause is never in the API code, and the symptoms (a stalled
stream, a Playwright test that hangs forever) point everywhere except
the four real culprits below.

---

## When to use this recipe

You have all of:

- A server endpoint that returns `Content-Type: text/event-stream`
  and writes events incrementally over a long-lived connection.
- A browser-side `EventSource` (or `fetch` + `ReadableStream`)
  opening that endpoint as a **same-origin relative URL**
  (`/api/...`), which therefore travels through a proxy hop that the
  API author doesn't control.
- A latency budget on push ("the client must see the event within
  N seconds") — SSE without a budget rarely surfaces buffering.

If your browser hits the API on an absolute cross-origin URL with no
proxy in between, only the Playwright section applies.

---

## The failure mode

An `EventSource('/api/dispatch/stream')` opens, the server writes an
event immediately, and the browser sees… nothing — until many seconds
later when several events arrive batched together. The API logs show
each event flushed on time. The latency budget fails end-to-end but
passes when you `curl` the API directly.

The body is being **buffered** somewhere in the proxy / framework /
Node-socket pipeline (a compression layer filling a window, Nagle on
the socket, or a framework response buffer). Four layered problems,
none in the API:

1. The proxy isn't told to disable buffering / compression for the
   stream.
2. Node `fetch` forwarding of a streamed body needs an explicit
   `duplex` flag.
3. Playwright's `networkidle` wait never fires while an `EventSource`
   is open.
4. Playwright starts the `webServer` **before** `globalSetup`, so any
   env the proxy needs isn't set yet — and the resulting 404 *masks
   as* a buffering problem.

---

## Fix 1 — proxy SSE-response headers (the buffering disable)

When your proxy forwards an upstream response whose content-type is
`text/event-stream`, set these three response headers **on that
branch only**:

| Header | Value | Why |
|---|---|---|
| `Cache-Control` | `no-cache, no-transform` | Blocks any middlebox/framework from caching **or transforming** the body. The `no-transform` clause is the one that prevents a gzip/brotli rewrap. |
| `Content-Encoding` | `identity` | Declares the body uncompressed, suppressing any downstream compression middleware that would buffer to fill a compression window. |
| `X-Accel-Buffering` | `no` | nginx-style hint (honoured by nginx and several other reverse proxies) to disable response buffering for this one connection. Harmless when ignored. |

Apply them **only** for `text/event-stream` — a normal JSON response
should keep its caching and compression.

A SvelteKit `+server.ts` reverse-proxy branch (dev/preview):

```ts
const contentType = respHeaders.get('content-type') ?? '';
const isSse = contentType.includes('text/event-stream');
if (isSse) {
    respHeaders.set('cache-control', 'no-cache, no-transform');
    respHeaders.set('content-encoding', 'identity');
    respHeaders.set('x-accel-buffering', 'no');
}

// Belt-and-braces: pipe the SSE body through an identity
// TransformStream so each upstream chunk flushes synchronously and
// no framework middleware downstream can coalesce chunks on the way
// out. A plain `new Response(upstream.body)` was observed to batch.
const body =
    isSse && upstreamResponse.body
        ? upstreamResponse.body.pipeThrough(new TransformStream())
        : upstreamResponse.body;

return new Response(body, {
    status: upstreamResponse.status,
    statusText: upstreamResponse.statusText,
    headers: respHeaders
});
```

For a production **nginx** front:

```nginx
location /api/ {
    proxy_pass http://backend;
    proxy_buffering off;            # the X-Accel-Buffering header equivalent
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}
```

---

## Fix 2 — `duplex: 'half'` on the forwarding `fetch`

When the proxy forwards a request whose **body is a stream** (any
method that can carry a body — POST/PUT/PATCH), the modern fetch spec
requires `duplex: 'half'`. Without it, Node throws (or silently drops
the streamed body) the moment a streaming request body is present.

```ts
const init: RequestInit = {
    method: request.method,
    headers: buildUpstreamHeaders(request),
    body:
        request.method === 'GET' || request.method === 'HEAD'
            ? undefined
            : request.body,
    // @ts-expect-error — `duplex` is part of the modern fetch spec
    // but missing from Node's TS lib types until Node 22.
    duplex: 'half'
};
```

`EventSource` itself is always a GET (no request body), so this fix
matters for the *other* half of the proxy — the POST/PUT routes that
share the same forwarding handler. Get it wrong and those routes 500
while the SSE GET works, which sends you debugging the wrong endpoint.

---

## Fix 3 — Playwright `networkidle` never fires with an open EventSource

`page.waitForLoadState('networkidle')` waits for ~500 ms with no
in-flight requests. An open `EventSource` is a long-lived connection
that **counts as in-flight forever**, so `networkidle` never resolves
and the test hangs until the timeout.

Wait on a **concrete readiness signal** the app emits, not on network
quiescence:

```ts
// ✗ Hangs forever — the EventSource keeps the network "busy".
await page.waitForLoadState('networkidle');

// ✓ Wait for an app-level signal: the first rendered event, a
//   data-testid that appears once the stream is live, or a
//   response to the stream open.
await expect(page.getByTestId('dispatch-feed-live')).toBeVisible();
// or:
await page.waitForResponse((r) => r.url().includes('/api/dispatch/stream'));
```

Same rule applies to any `await page.goto(url, { waitUntil: 'networkidle' })`
on a page that opens a stream — use `'load'` or `'domcontentloaded'`
and then wait on the readiness signal.

---

## Fix 4 — Playwright spawns `webServer` *before* `globalSetup`

Playwright's documented startup order is **`webServer` → `globalSetup`**.
So any environment variable the proxy needs at boot (the upstream API
base URL, per-role auth tokens, …) **must be declared statically in
`webServer.env`** — it cannot be threaded in from `globalSetup`,
because the server is already running by the time `globalSetup` runs.

The classic symptom: the proxy boots with an empty `API_BASE_URL`,
returns 404 to the browser's `EventSource`, and the missing events
*look exactly like a buffering problem* — so you spend the session on
Fix 1 when the real bug is an unset env var.

```ts
// playwright.config.ts
export default defineConfig({
    globalSetup: './tests/global-setup.mjs',
    webServer: {
        command: 'pnpm run build && pnpm run preview',
        port: 4173,
        reuseExistingServer: !process.env.CI,
        timeout: 120_000,
        env: {
            // Declared HERE, not threaded from globalSetup — the
            // server is already up by the time globalSetup runs.
            // Use a FIXED upstream port so the proxy and the test
            // API server agree without a handshake.
            API_BASE_URL: 'http://127.0.0.1:47100',
            API_TOKEN_OPERATOR: 'playwright-operator-token',
            API_TOKEN_DRIVER: 'playwright-driver-token'
        }
    }
});
```

A fixed upstream port + statically-declared tokens eliminate the race
entirely; there's nothing left for `globalSetup` to hand off to the
already-running server.

---

## Checklist

- [ ] Proxy SSE branch sets `Cache-Control: no-cache, no-transform`,
      `Content-Encoding: identity`, `X-Accel-Buffering: no` — **only**
      for `text/event-stream`.
- [ ] SSE body piped through an identity `TransformStream` (or
      `proxy_buffering off` on nginx) to defeat chunk coalescing.
- [ ] Forwarding `fetch` for body-carrying methods sets
      `duplex: 'half'`.
- [ ] Playwright SSE tests wait on an app readiness signal, never on
      `networkidle`.
- [ ] All env the proxy needs at boot is in `webServer.env`, not
      `globalSetup`; upstream port is fixed.

---

## Cross-references

- [`mgf-standard: web/README.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/web/README.md) — the
  web-domain standards subtree; this recipe is listed in its recipe
  index.
- [`FEEDBACK.md` PAPER-93](../../FEEDBACK.md) — the consumer entry
  (PlasmaMapper) this recipe closes.
- The reference implementation is PlasmaMapper's
  `apps/web/src/routes/api/[...path]/+server.ts` SSE branch and
  `apps/web/playwright.config.ts`.
