#2515·encore

Encore.ts: api.raw delivers no client-disconnect signal — response 'close'/'error' never fire, streaming/SSE handlers can't abort abandoned work

Author: shitianfangCreated Jul 17, 2026Updated Sep 9, 2026

Encore version: encore CLI + encore.dev 1.57.9 (TypeScript), Node 24, Linux

Summary

An api.raw handler never learns that its client has disconnected. With an SSE (or any streaming) response mid-stream and the peer gone:

  • no 'close' and no 'error' ever fire on the response,
  • resp.write() keeps returning true (it is hard-coded to in encore.dev/dist/api/node_http.js — "HACK: Work around pipe deadlock"),
  • write callbacks never receive an error,
  • resp.destroyed stays false,
  • the request side emits 'end'/'close' at body consumption (normal Node ≥16 semantics) and 'aborted'/'error' never fire.

The handler happily streams into the void forever. Only after the handler itself calls end() does 'close' fire, with writableEnded === true — the normal-completion shape, indistinguishable from a healthy request.

Behavior is identical when hitting the embedded Rust runtime's own listener (ENCORE_LISTEN_ADDR) directly, so this is the runtime bridge itself, not the local dev daemon's proxy.

Why it matters

Any api.raw streaming or relay endpoint (SSE proxy, LLM gateway, long-poll) has no way to stop work when the client goes away. In our case — a Messages-API relay that swaps in a server-side key and pipes the upstream SSE back — a client that abandons a streamed turn leaves the upstream generating and billing to the end of the turn, because the standard plain-Node abort pattern is unreachable:

typescript
resp.on("close", () => {
  if (!resp.writableEnded) upstreamAbort.abort(); // never fires under Encore
});

This exact pattern works as expected on a plain node:http server.

Minimal repro

typescript
import { api } from "encore.dev/api";

export const sse = api.raw(
  { expose: true, path: "/sse", method: "GET" },
  async (req, resp) => {
    resp.writeHead(200, { "content-type": "text/event-stream" });
    let n = 0;
    setInterval(() => {
      const ok = resp.write(`data: ${++n}\n\n`, (err) =>
        err ? console.log("write cb error:", err.message) : undefined,
      );
      console.log("tick", n, { ok, writableEnded: resp.writableEnded, destroyed: resp.destroyed });
    }, 300);
    resp.on("close", () => console.log("resp close; writableEnded =", resp.writableEnded));
    resp.on("error", (e) => console.log("resp error:", e.message));
    req.on("aborted", () => console.log("req aborted"));
    req.on("error", (e) => console.log("req error:", e.message));
  },
);
  1. encore run
  2. timeout 2 curl -N http://127.0.0.1:4000/sse (client drops after ~6 events)
  3. Watch the service log: ticks keep incrementing indefinitely, ok stays true, no close/error/aborted ever logs.

Expected (plain-Node behavior): shortly after step 2's client drop, resp emits 'close' with writableEnded === false (and/or the write callback errors), so the handler can stop.

Root cause (as far as we can see)

In encore.dev/dist/api/node_http.js, RawResponse is a stream.Writable over the NAPI body writer; nothing ever destroys or errors it on peer disconnect, and its write() is hard-coded to return true. There appears to be no disconnect surface in the raw API at all.

Ask

Surface peer disconnect on the raw pair in any Node-idiomatic form — destroy/error the RawResponse writable on peer reset, emit 'close' with writableEnded === false, and/or expose an AbortSignal on RawRequest. Happy to test a fix: we keep a standing probe (mock SSE upstream + mid-stream client drop asserting the upstream abort) that turns green the moment the bridge grows the signal.