Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/docs/content/docs/integrations/elysia.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ const app = new Elysia()
});
```

By default the terminal `error` frame carries a generic `data: error` token — the raw
`event.message` is **not** echoed, since it can disclose internal network topology (a
transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the
upstream's status (`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream
messages are known safe (`errorData: (e) => e.message`); `onError` still receives the real
failure server-side for logging.

<Callout type="warn">
**Anti-pattern:** return the `streamStitchSse` `Response` as the handler's
result — don't also set `set.status` / `set.headers` or return a second
Expand Down
7 changes: 7 additions & 0 deletions packages/elysia/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ disconnect cancels the body and aborts the upstream stitch stream rather than
leaving it running. Control events (`start`/`progress`/`result`/`done`/…) are not
forwarded.

By default the `error` frame carries a generic `data: error` token, **not** the raw
error message — echoing it can disclose internal network topology (a transport failure
reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status
(`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream messages are
known safe (`errorData: (e) => e.message`); `onError` still receives the real failure
server-side.

## Error handling

The plugin registers an `.onError` that maps a `StitchError` to an HTTP response
Expand Down
67 changes: 59 additions & 8 deletions packages/elysia/src/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// (mirrors @stitchapi/hono's sse.ts, adapted to Elysia's Web-standard return model). A streaming/SSE
// stitch's `.stream()` is an `AsyncIterable<StitchEvent>`; this forwards each `delta` chunk as one
// SSE message, ends the stream cleanly on `done`, and turns an `error` event into a final
// `event: error` message. When the client disconnects the `ReadableStream` is cancelled — the helper
// `event: error` message — a generic `data: error` by default, the raw message withheld (opt in via
// `errorData`). When the client disconnects the `ReadableStream` is cancelled — the helper
// calls the iterator's `return()` so the upstream stitch stream is torn down rather than left running.
//
// Web-standard: returns a plain `Response` whose body is a `ReadableStream` — no `node:*`, so it runs
Expand All @@ -14,6 +15,9 @@ export type StitchEventSource<T> =
| AsyncIterable<StitchEvent<T>>
| AsyncGenerator<StitchEvent<T>, void>;

/** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */
type StitchErrorEvent = Extract<StitchEvent, { type: 'error' }>;

export interface StreamStitchSseOptions {
/**
* Map a `delta` chunk to the SSE message `data` string. The default JSON-stringifies the chunk
Expand All @@ -27,8 +31,21 @@ export interface StreamStitchSseOptions {
*/
event?: string;
/**
* Called once if the underlying stream errors (a stitch `error` event, or a throw). After it
* runs, an `error`-typed SSE message carrying the error text is written and the stream closes.
* Shape the SSE `data` written for a terminal `error` event (or an uncaught throw mid-stream,
* normalised to an error event). **Default: a generic token (`data: error`)** — the raw
* `event.message` is deliberately *not* echoed, because it can disclose internal network
* topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or
* the upstream's status (`HTTP 401`) to an untrusted client. Opt in with `(e) => e.message`
* when the upstream messages are known safe, or return your own payload
* (e.g. `() => JSON.stringify({ error: 'stream failed' })`). A multi-line return gets one
* `data:` line each (SSE spec); the `event: error` name is fixed.
*/
errorData?: (event: StitchErrorEvent) => string;
/**
* Called once, server-side, if the underlying stream errors (a stitch `error` event, or a
* throw) — use it to observe/log the real failure. It does **not** shape the client-facing
* frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by
* default), so the raw message reaches your logs here but not the client.
*/
onError?: (err: unknown) => void;
}
Expand All @@ -46,6 +63,25 @@ function frame(data: string, event?: string): string {
return `${lines.join('\n')}\n\n`;
}

// The generic token written as an `error` message's `data` by default: the raw upstream message is
// withheld so an internal hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`)
// never reaches the client. Override with `options.errorData`.
const DEFAULT_ERROR_DATA = 'error';

// Normalise a thrown value into the terminal `error` event shape, so an `errorData` opt-in sees a
// consistent argument whether the failure arrived as a surfaced `error` event or an unexpected
// throw. `attempts`/`at` are best-effort placeholders — an `errorData` hook keys off `name`/`message`.
function toErrorEvent(reason: unknown): StitchErrorEvent {
const e = reason instanceof Error ? reason : new Error(String(reason));
return {
type: 'error',
name: e.name,
message: e.message,
attempts: 0,
at: 0,
};
}

/**
* Stream a stitch's events to the client as SSE. Returns a Web-standard {@link Response} with a
* `text/event-stream` body, so an Elysia handler is one line:
Expand All @@ -62,9 +98,10 @@ function frame(data: string, event?: string): string {
* ```
*
* Each `delta` becomes a `data:` message; a terminal `error` event (or a throw) becomes a final
* `event: error` message and ends the stream; every control event (`start`/`progress`/`result`/
* `done`/…) is consumed but not forwarded. On client disconnect the stream is cancelled and the
* upstream iterator is `return()`-ed so the stitch stream is aborted.
* `event: error` message and ends the stream — a generic `data: error` by default, the raw message
* withheld to avoid disclosing internal topology (opt in via `errorData`); every control event
* (`start`/`progress`/`result`/`done`/…) is consumed but not forwarded. On client disconnect the
* stream is cancelled and the upstream iterator is `return()`-ed so the stitch stream is aborted.
*/
export function streamStitchSse<T>(
source: StitchEventSource<T>,
Expand Down Expand Up @@ -92,9 +129,19 @@ export function streamStitchSse<T>(
return; // one frame per pull → precise back-pressure
}
if (event.type === 'error') {
// `onError` gets the real failure server-side; the client frame is a
// generic token by default (never the raw `event.message`) — `errorData`
// opts in.
options.onError?.(new Error(event.message));
controller.enqueue(
enc.encode(frame(event.message, 'error')),
enc.encode(
frame(
options.errorData
? options.errorData(event)
: DEFAULT_ERROR_DATA,
'error',
),
),
);
controller.close();
await iterator.return?.(undefined);
Expand All @@ -104,11 +151,15 @@ export function streamStitchSse<T>(
// loop on to the next event without emitting a frame.
}
} catch (err) {
// A throw (not a surfaced `error` event): still withhold the raw message by
// default — normalise it to an error event so `errorData` sees a consistent shape.
options.onError?.(err);
controller.enqueue(
enc.encode(
frame(
err instanceof Error ? err.message : String(err),
options.errorData
? options.errorData(toErrorEvent(err))
: DEFAULT_ERROR_DATA,
'error',
),
),
Expand Down
67 changes: 65 additions & 2 deletions packages/elysia/test/sse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,11 @@ describe('streamStitchSse — error paths', () => {
const body = await res.text();
expect(body).toContain('data: a');
expect(body).toContain('event: error');
expect(body).toContain('upstream failed');
// the raw upstream message is withheld from the client by default (leak-regression below)
expect(body).not.toContain('upstream failed');
expect(body).toContain('data: error');
expect(body).not.toContain('never');
// onError still sees the real failure server-side
expect((captured as Error).message).toBe('upstream failed');
});

Expand All @@ -159,11 +162,71 @@ describe('streamStitchSse — error paths', () => {
const body = await res.text();
expect(body).toContain('data: a');
expect(body).toContain('event: error');
expect(body).toContain('stream blew up');
// the raw throw message is withheld from the client by default
expect(body).not.toContain('stream blew up');
expect(body).toContain('data: error');
// onError still sees the real failure server-side
expect((captured as Error).message).toBe('stream blew up');
});
});

describe('streamStitchSse — the error frame does not leak the raw message', () => {
// Regression: the default error frame must not echo the raw upstream/transport message, which
// can disclose internal network topology (a transport error names the host it failed to reach)
// or the upstream's status to an untrusted client — bringing @stitchapi/elysia in line with the
// other hosts (express/fastify/hono/nest/next), whose SSE helpers already withhold it.
test('an internal hostname in the error message is not disclosed by default', async () => {
const res = streamStitchSse(
gen([
{
type: 'error',
name: 'StitchError',
message: 'getaddrinfo ENOTFOUND payments.internal.corp',
status: 502,
attempts: 1,
at: 0,
},
]),
);
const body = await res.text();
expect(body).toContain('event: error');
expect(body).toContain('data: error');
expect(body).not.toContain('payments.internal.corp');
expect(body).not.toContain('ENOTFOUND');
});

test('errorData opts in to shaping the client-facing error payload', async () => {
const res = streamStitchSse(
gen([
{
type: 'error',
name: 'StitchError',
message: 'upstream blew up',
attempts: 1,
at: 0,
},
]),
{ errorData: (e) => e.message },
);
const body = await res.text();
expect(body).toContain('event: error');
expect(body).toContain('data: upstream blew up');
});

test('errorData shapes the throw path too', async () => {
async function* boom(): AsyncGenerator<StitchEvent, void> {
yield { type: 'delta', chunk: 'a', at: 0 };
throw new Error('stream blew up');
}
const res = streamStitchSse(boom(), {
errorData: (e) => JSON.stringify({ error: e.message }),
});
const body = await res.text();
expect(body).toContain('event: error');
expect(body).toContain('data: {"error":"stream blew up"}');
});
});

describe('streamStitchSse — response', () => {
test('returns a text/event-stream Response', async () => {
const res = streamStitchSse(
Expand Down
Loading