committed 08:39PM - 16 Aug 26 UTC
* perf(frontend): stream the API proxy instead of buffering every response
`ser…ver/api/[...].get.ts` forwards anything under `/api` to the backend. It did
so with `$fetch`, which buffers the whole body, parses it into JS objects and
lets Nitro re-serialize it -- so the raw body, the object graph and the encoded
output were all live at once. Because the route is a catch-all, the size of that
payload is bounded by what the backend will return, not by what the UI asks for.
`sendProxy` pipes the upstream response through untouched. Measured with six
concurrent 3.2 MB `values` requests over three rounds, peak RSS above baseline
drops from 134 MB to 22 MB.
`sendProxy` rather than `proxyRequest`: the latter also forwards the client's
request headers (`cookie`, `authorization`) to the backend, which `$fetch` never
did, and this route is GET-only so there is no request body to carry over.
Backend errors no longer need reshaping -- FastAPI's error body is already the
shape the UI reads, and `sendProxy` forwards a non-2xx status and body verbatim
instead of throwing. The catch block now only covers an unreachable backend.
Adds `tests/e2e/proxy.spec.ts`, the first coverage of this route: `api.spec.ts`
talks to the backend directly on port 3000 and never exercised the proxy. The
new specs assert JSON passthrough, error status and `detail` shape, repeated
query parameters, and byte-identical delivery of a multi-megabyte payload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): handle proxy failures that happen mid-stream
Streaming moved the failure boundary: `sendProxy` writes the status line with
the first chunk, so a backend that dies part-way through the body lands in the
catch with the response already on the wire. `setResponseStatus` is then a no-op
and h3 skips `res.end()` for a handled event, so the client sat on a truncated
200 until it timed out -- verified against a backend that cuts the socket
mid-body: 30s hang (curl exit 28) before, 0.33s broken transfer (exit 18) after.
Destroy the socket instead, so the truncation is visible and retryable.
Also surface the real cause when the backend is unreachable. h3 wraps connection
failures via `createError({ statusMessage: 'Bad Gateway', cause })`, and
`createError` resolves `message` from `input.message ?? input.statusMessage`, so
`error.message` is always the constant "Bad Gateway" and the ECONNREFUSED or DNS
detail lives only on `cause`.
In the e2e specs, assert `ok()` rather than branching on it: comparing payloads
inside `if (viaProxy.ok())` let a backend failure pass the test green while
asserting nothing about the thing it exists to catch. Add explicit request and
test timeouts too -- Playwright defaults to 30s for both, which is tight for the
multi-megabyte case when CI runs it against a cold cache that has to pull from
DWD opendata first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): stop the proxy republishing the backend's response headers
Streaming relays upstream response headers; buffering never did. Comparing what
the browser receives on main against this branch, three headers were newly
crossing the proxy -- `server: uvicorn`, `alt-svc` and `vary` -- alongside the
expected `content-length` to `transfer-encoding: chunked` swap.
`set-cookie` is the one that matters. The backend sets no cookies today (no
`set_cookie` call, no `SessionMiddleware`, none on the wire), but `sendProxy`
collects and re-emits them, so the day one appears it would be scoped to the
frontend's origin instead of the backend's -- moving a session boundary by
accident. `alt-svc` would claim HTTP/3 support on the frontend's behalf, and
`server` names the stack behind the proxy for no benefit.
Strip those three in `onResponse`, which h3 runs after the headers are set and
before the first chunk. `vary: Accept-Encoding` is accurate caching metadata and
stays. Verified against a stand-in backend that sends all of them: the three are
removed and an unrelated header survives, so the strip is targeted.
The new e2e case asserts `server` is not `uvicorn`, which uvicorn really does
send on every response -- so it fails if the strip regresses rather than passing
vacuously the way a `set-cookie` assertion alone would.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>