Visitar URL original
`HttpApiSchema.Upgrade`: allow a request payload and headers on the `101` response · Issue #9682 · Effect-TS/effect · GitHub
Skip to content

HttpApiSchema.Upgrade: allow a request payload and headers on the 101 response #9682

Closed

Description

@fubhy

#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:

  • NodeHttpServer writes a fixed response head (NodeHttpServer.ts:338), and HttpServerRequest.upgrade takes no options.
  • HttpApiSchema.WithHeaders refuses to wrap Upgrade (HttpApiSchema.ts:860). The client returns the 101 headers untyped, and OpenAPI only documents connection and upgrade.

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-made socket. 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

  1. self-assigned this
    on Oct 21, 2026
  2. tim-smart commented on Oct 21, 2026

    @tim-smart
    Contributor

    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 Upgrade ships 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:

    Node head at the upgrade event request body on req
    26 bytes after the body parsed, including chunked
    22 / 24 body bytes (chunk framing included) followed by any raw bytes empty

    Your report mentions Node 26, Deno 2.8 and Bun 1.3, which matches. @effect/platform-node declares node >=18, so the adapter has to handle both behaviours. This also means today's no-payload Upgrade endpoint already leaks a stray request body into the socket on Node ≤25.

    Proposed design

    1. Headers are declared on Upgrade, not through WithHeaders. Your proposal used WithHeaders(Upgrade, H). We prefer Upgrade({ protocol, headers }): every consumer (handler type, builder, client, OpenAPI) already special-cases Upgrade, and the WithHeaders value type would invite returning withHeaders(...) from the handler, which arrives after the 101 is already committed. The headers still use the same string-tree codec as WithHeaders, so OpenAPI and the client reuse it.

    const start = HttpApiEndpoint.post("start", "/exec/:id/start", {
      params: { id: Schema.String },
      payload: Schema.Struct({ Detach: Schema.Boolean, Tty: Schema.Boolean }),
      success: HttpApiSchema.Upgrade({
        protocol: "tcp",
        headers: {
          "content-type": Schema.Literals([
            "application/vnd.docker.raw-stream",
            "application/vnd.docker.multiplexed-stream"
          ])
        }
      })
    })
    
    // server: `socket` is replaced by a deferred `upgrade` function
    handlers.handle("start", ({ params, payload, upgrade }) =>
      Effect.gen(function*() {
        const exec = yield* lookup(params.id) // declared errors still work, no 101 is sent
        const socket = yield* upgrade({
          headers: {
            "content-type": payload.Tty
              ? "application/vnd.docker.raw-stream"
              : "application/vnd.docker.multiplexed-stream"
          }
        })
        // ...
      }))
    
    // client
    const { headers, socket } = yield* client.exec.start({
      params: { id },
      payload: { Detach: false, Tty: true }
    })
    headers["content-type"] // typed literal union; plain `Headers` when none are declared

    2. Handler shape. upgrade is always a function: upgrade() when no headers are declared, upgrade({ headers }) when they are, with the argument typed from Upgrade<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 returning void.

    3. Payload. Buffered payloads (JSON, text, urlencoded, buffered multipart) become allowed. StreamUint8Array, StreamNdjson and MultipartStream stay 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 Required check, 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 existing upgrade getter, which becomes sugar for upgradeWith({}). WebSocket upgrades with non-empty headers fail with an HttpServerError rather than silently dropping them. WebSocket handshake headers stay out of scope.

    6. Node adapter.

    • Custom headers are validated when upgradeWith runs, using http.validateHeaderName/Value. connection, upgrade, content-length and transfer-encoding are rejected, not stripped. Custom headers are written after the fixed two.
    • Node 26: upgrade can 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.
    • Node ≤25: the adapter frames the body itself from head plus the socket, Content-Length only, within the payload limit, unshifting leftovers. Chunked upgrade bodies get 411 Length Required. Docker clients and our clients send Content-Length for buffered bodies.
    • The 101 response that middleware and logging observe carries the sent headers.

    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 lists connection, upgrade and the declared headers on the 101.

    Questions for you

    1. Are you fine with Upgrade({ protocol, headers }) instead of WithHeaders(Upgrade, H)? Both give you the typed { headers, socket } on the client.
    2. Do you need upgrade bodies to work on Node 25 and older? Our plan is to support them with Content-Length framing and reject chunked bodies with 411 there. The alternative is requiring Node 26 for upgrade bodies and failing explicitly on older runtimes.
    3. Is a bare upgrade() call acceptable for endpoints without declared headers, or would you rather keep the pre-acquired socket in 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.

  3. fubhy commented on Oct 21, 2026

    @fubhy
    MemberAuthor
    1. Okay
    2. Node v25 is already EOL, I'd argue we should require Node v26 at this point
    3. Yes
  4. tim-smart commented on Oct 21, 2026

    @tim-smart
    Contributor

    Node 22 and 24 are still under LTS

    Image
  5. fubhy commented on Oct 21, 2026

    @fubhy
    MemberAuthor
    1. Tim is boring. But yes, your proposed fallback is acceptable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions