Encore.ts: api.raw delivers no client-disconnect signal — response 'close'/'error' never fire, streaming/SSE handlers can't abort abandoned work
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 returningtrue(it is hard-coded to inencore.dev/dist/api/node_http.js— "HACK: Work around pipe deadlock"),- write callbacks never receive an error,
resp.destroyedstaysfalse,- 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:
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
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));
},
);encore runtimeout 2 curl -N http://127.0.0.1:4000/sse(client drops after ~6 events)- Watch the service log: ticks keep incrementing indefinitely,
okstaystrue, noclose/error/abortedever 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.
Source: encoredev/encore