Repository navigation
feat(custom): send several values under one ingress header name - #472
Conversation
An ingress header rule carried one value per name, so an operator could not keep the client's value and add one of their own, nor send a name twice. Both persistence and the dashboard rejected a repeated name outright. Model a rule as one value rather than one name. A name resolves as a whole: its admitted client values are dropped and its rules rebuild the value list in rule order, so a passthrough rule reinstates what the client sent and every configured rule contributes its own value beside it. A name still carries at most one passthrough rule, because the client's values enter the request once, and a name with no rule reaches no upstream at all. Cover the contract as a table over what the operator configured and what the client sent, run it across every Custom endpoint, and drive each client protocol — including the cross-protocol translations — through the gateway. Assert the resulting field lines against a real HTTP server over both Node egress paths, since undici and our own socket writer serialize a repeated name independently.
A repeated header name had nowhere to live. `HttpRequest.headers` was a `Record<string, string>`, so the serializer emitted one line per name and the gateway's dial seam collapsed a `Headers` into that record — losing the repetition, and silently keeping only the last Set-Cookie. On the response side every field line was appended into a `Headers`, which merges a repeated name on undici, so no caller could read the lines the upstream actually sent. Model both directions as ordered field lines. `HttpRequest.headers` and the new `RawHttpResponse.headerLines` are `[name, value]` in wire order, the serializer writes one line per entry, and the parser records each line beside the `Headers` it still builds for the Web bridge. The decoded Transfer-Encoding leaves both views together. Carry the same shape through the provider transport boundary: `UpstreamFetchOptions.extraHeaders` and `headersForMessagesCall` take field lines, and the Custom provider resolves its ingress rules into them, so a name configured several times reaches the wire several times. Node's `direct_fetch` remains the one transport that cannot express it — undici concatenates inside `Headers.append`, before any transport sees the request — and its combined form is the same field value per RFC 9110 §5.3.
Live-instance experiment: what a repeated ingress header rule puts on the wireA script that starts an isolated Floway instance and a capture HTTP server, drives a real client request through the gateway, and records the field lines the upstream actually received. It confirms the wire behaviour this PR describes. GitHub's attachment endpoint refuses What it does
Rules under test
The client sentx-passthrough: kept-a
x-passthrough: kept-b
x-route: client-a
x-route: client-b
x-configured: client-copy
x-dropped: goneThe upstream receivedNode v22.23.2, both requests answered
POST /v1/embeddings HTTP/1.1
x-passthrough: kept-a, kept-b
x-route: client-a, client-b
x-route: appended
x-configured: first
x-configured: second
x-mixed:
x-mixed: after-empty
POST /v1/embeddings HTTP/1.1
x-passthrough: kept-a, kept-b
x-route: client-a, client-b, appended
x-configured: first, second
x-mixed: , after-empty
Two things worth naming:
Downloadingress-header-lines-experiment.zip — the script and its results. GitHub's attachment endpoint accepts only image and video content types, so the archive is stored under a curl -sL https://github.com/user-attachments/assets/0f910932-ab23-4037-b253-bd3739abf776 -o ingress-header-lines-experiment.zip && unzip -o ingress-header-lines-experiment.zipThe same content is inlined below, so nothing depends on that link.
|
…eader-multi-value
#472 landed on main and reshaped the same provider surfaces this branch renames: an ingress header rule became one value rather than one name, so a call takes `HttpHeaderLines` positionally instead of a `Headers` inside `opts`. Twelve conflicts, all the same shape — main's new signature, this branch's names: - `provider/anthropic-messages.ts` and its test: the filter-and-append body over header lines, under `headersForAnthropicMessagesCall`. - azure, copilot, custom, ollama and claude-code providers: `[...opts.headers]` threaded as its own argument at every call. - the two locale files and the dashboard test: main's `duplicatePassthrough` key and its wording, with `anthropicMessagesOwned` spelled in full. Three files main *added* merged without conflict and needed the rename applied by hand — git cannot see that a new file spells a protocol the old way. One substitution over-applied while doing it: `messages:` is the Anthropic Messages wire field as well as the old endpoint-map key, and the payloads in those tests carry the field. The wire form is restored; only the capability-map key is renamed.
Brings main's #472 down the stack. No conflicts here — the three files it added merge clean — but one of them fails against this branch, which is the point of merging it down. `/v1/completions reaches a Completions upstream` sent a non-streaming request and answered it with `text/event-stream`. The replaced handler served that; the pipeline reads the upstream's body against what the request asked for, so a non-streaming turn handed a stream has nothing it can parse and says 502 rather than serving a body it never read. The fixture is what is wrong, not the reading: the case exists to prove the ingress header rules reach the upstream on this route, and it now asks to stream, which is what its own upstream answers with.
Summary
An ingress header rule carried one value per name: persistence and the dashboard both rejected a repeated name, and the resolver wrote each configured value with
Headers.set. An operator could not keep the client's value and add one of their own, nor send a name twice.A rule is now one value rather than one name. A name resolves as a whole — its admitted client values are dropped and its rules rebuild the value list in rule order:
aaandb(passthrough)aa, boneoneoneoneone,twoone,twoone,twoone,two(passthrough),oneonea,onea, b,oneone,(passthrough)oneone,aone,a, b(empty),oneoneoneoneEach cell lists the values the upstream receives, one field line each. A client that repeats a name contributes one value, because both runtimes merge a repeated name when the inbound request becomes a
Headers.Field lines end to end
Sending several values means several field lines, which nothing below the resolver could express.
HttpRequest.headerswas aRecord<string, string>, the gateway's dial seam collapsed aHeadersinto that record — losing repetitions, and silently keeping only the lastSet-Cookie— and on the response side every field line was appended into aHeaders, which merges a repeated name on undici.Both directions of
@floway-dev/httpnow carry ordered[name, value]field lines: the serializer writes one line per entry, and the parser recordsRawHttpResponse.headerLinesbeside theHeadersit still builds for the Web bridge (the decodedTransfer-Encodingleaves both views together). The same shape reaches the transport throughUpstreamFetchOptions.extraHeadersandheadersForMessagesCall.The result per egress:
direct_connect/ proxy (the default)fetchdirect_fetchHeaders.append, before any transport sees the requestRFC 9110 §5.3 makes the combined form the same field value for a list-typed name, and
direct_fetchis opt-in, so the default egress on both runtimes sends separate lines.Test Plan
Headers.node:httpserver'srawHeadersover both Node egress paths:direct_connectsendsx-routetwice andx-configuredtwice,direct_fetchsends each combined.@floway-dev/httpwrites one request field line per entry, and keeps every response field line of a repeated name in wire order and casing, includingSet-Cookie.x-routeandx-configuredon two field lines each overdirect_connectand combines them overdirect_fetch— script and captured bytes in this comment.pnpm run verify— 545 test files and 5,687 tests passed, plus lint, typecheck, installer harness, generated assets, AGENTS validation, verification parity, and web build.