Repository navigation
Description
#9339 added HttpApiSchema.Upgrade, so an endpoint can switch the connection to a raw socket. It also listed some follow-ups. Two of them matter for real APIs such as Docker's. Both should land before Upgrade ships in a release (it is @since 4.0.6; npm is at 4.0.5), because they may change the handler's shape.
1. Upgrade endpoints can't take a payload
Docker's POST /exec/{id}/start, the request behind docker exec -it, sends a JSON body and is answered with 101 and Upgrade: tcp. HttpApi rejects this when the endpoint is defined:
HttpApiEndpoint.post("start", "/exec/:id/start", {
payload: Schema.Struct({ Detach: Schema.Boolean, Tty: Schema.Boolean }),
success: HttpApiSchema.Upgrade({ protocol: "tcp" })
})
// Error: Upgrade endpoints cannot declare a payload (HttpApiEndpoint.ts:1365)The reason given in #9339 was that on Node the body bytes arrive on the upgraded socket. In tests on Node 26, Deno 2.8 and Bun 1.3 they didn't. Whether the body was sent with Content-Length or chunked, it was readable from the request as usual, and only bytes sent after it arrived on the socket. One layer below HttpApi, a plain HttpClientRequest with a JSON body and .upgrade("tcp"), sent to a NodeHttpServer route that reads request.json and then calls request.upgrade, already works with both Node clients. The builder already decodes the payload before it upgrades (HttpApiBuilder.ts:970-984).
Proposal: allow buffered payloads (JSON, form, text, bytes) on upgrade endpoints. A payload that fails to decode gets the usual 400 and no 101. Streaming payloads stay excluded, because the body and the socket would share one connection.
2. No headers on the 101 response
Docker uses the 101 response to say how the stream is framed:
HTTP/1.1 101 UPGRADED
Content-Type: application/vnd.docker.multiplexed-stream
Connection: Upgrade
Upgrade: tcp
Today neither side of an Effect upgrade can deal with that header:
NodeHttpServerwrites a fixed response head (NodeHttpServer.ts:338), andHttpServerRequest.upgradetakes no options.HttpApiSchema.WithHeadersrefuses to wrapUpgrade(HttpApiSchema.ts:860). The client returns the101headers untyped, and OpenAPI only documentsconnectionandupgrade.
Proposal:
// server, core
request.upgradeWith({ headers: { "content-type": "application/vnd.docker.raw-stream" } })
// HttpApi, the same shape as other responses
success: HttpApiSchema.WithHeaders(HttpApiSchema.Upgrade({ protocol: "tcp" }), {
"content-type": Schema.Literals(["application/vnd.docker.raw-stream", "application/vnd.docker.multiplexed-stream"])
})
// client: { headers: { "content-type": ... }, socket }Scope
In scope:
- Payloads on upgrade endpoints, and response headers on the
101, across the endpoint, builder, client and OpenAPI.
Out of scope:
- Streaming payloads on upgrade endpoints.
- Headers on WebSocket handshakes.
- Docker's stream framing, which belongs in a Docker client.
Open questions
- Handler shape: if the response headers can depend on the request (Docker's do), the handler needs an
upgrade(headers)function rather than a ready-madesocket. Should it always be a function? - Node 18 and 20 weren't tested for the body-before-upgrade behaviour. The package declares
node >= 18.
Activity
tim-smart commentedon Oct 21, 2026
Thanks for the detailed report. We would like to take this as a follow-up to #9339. Below is the design we intend to implement, plus three questions for you at the end, since the handler shape should be settled before
Upgradeships in a release.One finding changes the Node side: Node 25 and older do not parse the body of an upgrade request. Node 26 does (nodejs/node#60016, semver-major). We reproduced this with raw TCP probes against Node 22.23, 24.21 and 26.8:
headat theupgradeeventreqYour report mentions Node 26, Deno 2.8 and Bun 1.3, which matches.
@effect/platform-nodedeclaresnode >=18, so the adapter has to handle both behaviours. This also means today's no-payloadUpgradeendpoint already leaks a stray request body into the socket on Node ≤25.Proposed design
1. Headers are declared on
Upgrade, not throughWithHeaders. Your proposal usedWithHeaders(Upgrade, H). We preferUpgrade({ protocol, headers }): every consumer (handler type, builder, client, OpenAPI) already special-casesUpgrade, and theWithHeadersvalue type would invite returningwithHeaders(...)from the handler, which arrives after the 101 is already committed. The headers still use the same string-tree codec asWithHeaders, so OpenAPI and the client reuse it.2. Handler shape.
upgradeis always a function:upgrade()when no headers are declared,upgrade({ headers })when they are, with the argument typed fromUpgrade<H>. The 101 is still written when the socket reader is acquired, so headers can depend on the decoded payload (your Tty case). A header encoding failure takes the same 500 path as any bad response header today. The handler keeps returningvoid.3. Payload. Buffered payloads (JSON, text, urlencoded, buffered multipart) become allowed.
StreamUint8Array,StreamNdjsonandMultipartStreamstay rejected with a clear error: the 101 can only follow the end of the request message, so a streaming payload would have to be fully drained before the socket exists anyway.4. Request pipeline order. Params, headers, query, then the
426 Upgrade Requiredcheck, then payload decoding, then the handler. An invalid body gets the normal 400 with no 101.5.
HttpServerRequest.upgradeWith({ headers })is added next to the existingupgradegetter, which becomes sugar forupgradeWith({}). WebSocket upgrades with non-empty headers fail with anHttpServerErrorrather than silently dropping them. WebSocket handshake headers stay out of scope.6. Node adapter.
upgradeWithruns, usinghttp.validateHeaderName/Value.connection,upgrade,content-lengthandtransfer-encodingare rejected, not stripped. Custom headers are written after the fixed two.upgradecan fire while the body is still arriving. The 101 write and reader attach wait for the request body to end; an unconsumed body is drained within the usual size limit, otherwise the upgrade fails.headplus the socket,Content-Lengthonly, within the payload limit, unshifting leftovers. Chunked upgrade bodies get411 Length Required. Docker clients and our clients sendContent-Lengthfor buffered bodies.7. Client and OpenAPI. The payload is encoded and sent as for any endpoint. The client returns
{ headers, socket }with headers decoded through the declared schema. OpenAPI documents the request body as usual and listsconnection,upgradeand the declared headers on the 101.Questions for you
Upgrade({ protocol, headers })instead ofWithHeaders(Upgrade, H)? Both give you the typed{ headers, socket }on the client.Content-Lengthframing and reject chunked bodies with 411 there. The alternative is requiring Node 26 for upgrade bodies and failing explicitly on older runtimes.upgrade()call acceptable for endpoints without declared headers, or would you rather keep the pre-acquiredsocketin that case? We prefer the uniform function so there is one shape to document.Anything else from your Docker use case that this design would block, let us know here.
fubhy commentedon Oct 21, 2026
tim-smart commentedon Oct 21, 2026
Node 22 and 24 are still under LTS
fubhy commentedon Oct 21, 2026