Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pnpm add @open-elements/ui next next-auth react react-dom lucide-react
### `@open-elements/nextjs-app-layer/server` (server-only)

- `createAppLayerAuth({ issuer, clientId, clientSecret, ...tuning })`
- `createBackendProxyHandler({ backendUrl, auth })`
- `createBackendProxyHandler({ backendUrl, auth, forwardRequestHeaders? })`
- `createLogoutHandler({ auth, oidcIssuer, authUrl })`
- `middlewareConfig` (reference value — see warning below)

Expand Down Expand Up @@ -128,6 +128,46 @@ const handler = createBackendProxyHandler({
export { handler as GET, handler as POST, handler as PUT, handler as DELETE };
```

The proxy **streams** the request body to the backend (`duplex: "half"`), so
arbitrarily large uploads pass through with constant memory use instead of being
buffered on the heap.

It always forwards `Content-Type`, `Content-Length`, `Accept`, and the range /
conditional headers `Range`, `If-Range`, `If-Match`, `If-None-Match`,
`If-Modified-Since`, and `If-Unmodified-Since`, so browser media seeking
(`206 Partial Content`) and conditional caching work out of the box.

`Content-Length` is forwarded because the body is streamed: without it the
upstream request is framed as `Transfer-Encoding: chunked`, and a backend asking
for the declared length (e.g. Servlet `getContentLengthLong()`) gets `-1`.
Backends use that value to reject an over-sized upload *before* reading the
body, so dropping it would turn a cheap rejection into a full transfer.

On the way back, headers that `fetch` invalidated are corrected rather than
relayed blindly: Node's `fetch` decompresses a `Content-Encoding` response
transparently but leaves the encoding and length describing the compressed
bytes, so both are dropped when the upstream response was encoded (an
uncompressed `206` keeps its `Content-Length`). Hop-by-hop headers
(`Connection`, `Keep-Alive`, `Transfer-Encoding`, …) are never relayed.

The upstream request uses `redirect: "manual"`, because a streamed body is
single-use and cannot be replayed for a `307`/`308`; the `3xx` and its
`Location` are relayed to the client instead. The client's `AbortSignal` is
passed through, so cancelling an upload also aborts the upstream request.

To forward extra application headers (e.g. idempotency keys or checksums), pass
`forwardRequestHeaders`. Matching is case-insensitive. Security-sensitive headers
are always excluded even if listed: `Cookie`, `Host`, `Authorization` (the proxy
sets its own bearer token), and `Connection`.

```ts
const handler = createBackendProxyHandler({
backendUrl: process.env.BACKEND_URL ?? "http://localhost:8080",
auth,
forwardRequestHeaders: ["Idempotency-Key", "X-Checksum-Sha256"],
});
```

```ts
// src/app/api/logout/route.ts
import { auth, oidcIssuer } from "@/auth";
Expand Down
32 changes: 32 additions & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,35 @@ were left untested (out of scope for the bug fix). Testing requires extracting
the callback or mocking NextAuth, which is disproportionate for that spec.

**Prerequisite:** None.

## Let the backend reject an upload before the body flows (`Expect: 100-continue`)

The proxy streams the request body and forwards `Content-Length`, so a backend
can answer `413` (or `400`/`412`) from the headers alone — but the bytes are
already on their way, and Tomcat has to drain the rest of the body to deliver
that status at all (`server.tomcat.max-swallow-size`). A rejected 500 MB upload
is therefore transferred in full to be discarded.

`Expect: 100-continue` removes that: the request stops after the headers and the
body flows only after `100 Continue`. Browsers cannot use it (`Expect` is a
forbidden header for XHR/fetch), but the proxy leg can — it holds the client
body in backpressure, gets the header verdict, and forwards nothing on a
rejection. Two prerequisites:

- **Node's `fetch` cannot request it.** `undici.request({ expectContinue: true })`
can, which means replacing `fetch` in `createBackendProxyHandler` with a direct
undici call — a real dependency and API change, not a flag.
- **The backend must not answer it itself.** Tomcat's `continueResponseTiming`
defaults to `IMMEDIATELY`, i.e. it sends `100 Continue` when the headers
arrive, before the application sees the request. It needs
`ON_REQUEST_BODY_READ` (a connector attribute, not a `server.tomcat.*`
property) so the application can reject first.

**Context:** surfaced while reviewing the streaming proxy fix (#10 / #11) for
`open-transcript`, whose upload queue re-`PUT`s a recording after a lost
response and is answered `412` — today at the cost of re-uploading the whole
file. Deferred because it only pays off once a consumer measurably suffers from
it, and it touches both the library's HTTP client and the consumer's servlet
container.

**Prerequisite:** #11 (streamed body, forwarded `Content-Length`).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@open-elements/ui": "^0.9.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
Expand Down
38 changes: 28 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading