@tetsujs/sse 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -3
- package/dist/src/index.js.map +4 -4
- package/dist/src/stream.d.ts +2 -1
- package/dist/src/stream.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +2 -1
- package/src/stream.ts +6 -4
package/README.md
CHANGED
|
@@ -81,8 +81,9 @@ sse(ctx, async function* (signal) {
|
|
|
81
81
|
});
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
A generator that throws ends the stream where it stood, and the error
|
|
85
|
-
|
|
84
|
+
A generator that throws ends the stream where it stood, and the error goes
|
|
85
|
+
to the application's `reportError` with `source: "stream"` — printed as
|
|
86
|
+
`[tetsu] stream failed:` when there is none. The response has already
|
|
86
87
|
left, so there is no `onError` to hand it to.
|
|
87
88
|
|
|
88
89
|
## Knowing what a stream did
|
package/dist/src/index.d.ts
CHANGED
|
@@ -139,7 +139,8 @@ export interface SseSummary extends Omit<StreamSummary, "chunks"> {
|
|
|
139
139
|
* generator keeps yielding, or if it waits on the signal.** A generator
|
|
140
140
|
* that does neither leaks, and no amount of care out here can collect it.
|
|
141
141
|
*
|
|
142
|
-
* A generator that fails instead of ending is
|
|
142
|
+
* A generator that fails instead of ending is reported — to the
|
|
143
|
+
* application's `reportError`, with `source: "stream"` — and the stream is
|
|
143
144
|
* closed where it stood, so what already went out stays valid and the
|
|
144
145
|
* client sees an ordinary end of stream. Letting the failure escape
|
|
145
146
|
* `start()` instead would reach no one the application can hear: the
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG/D,YAAY,EACV,SAAS,EACT,aAAa,EACb,YAAY,EACZ,aAAa,GACd,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAEvB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,8BAA8B;AAC9B,MAAM,WAAW,UAAU;IACzB;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;CAChD;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,YAAY,CAAC;AAErC;;;;;;GAMG;AACH,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC;IAC/D,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG/D,YAAY,EACV,SAAS,EACT,aAAa,EACb,YAAY,EACZ,aAAa,GACd,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAEvB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,8BAA8B;AAC9B,MAAM,WAAW,UAAU;IACzB;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,UAAU,KAAK,IAAI,CAAC;CAChD;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,YAAY,CAAC;AAErC;;;;;;GAMG;AACH,MAAM,WAAW,UAAW,SAAQ,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC;IAC/D,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH,wBAAgB,GAAG,CACjB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,CACN,MAAM,EAAE,WAAW,KAChB,cAAc,CAAC,eAAe,EAAE,IAAI,EAAE,SAAS,CAAC,EACrD,OAAO,GAAE,UAAe,GACvB,QAAQ,CAsCV;AAcD;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,CA6BpD;AA6BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE5D"}
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// packages/sse/src/stream.ts
|
|
3
|
+
import { reportFailure } from "@tetsujs/core";
|
|
3
4
|
function stream(ctx, source, options = {}) {
|
|
4
5
|
const encoder = new TextEncoder;
|
|
5
6
|
const ending = new AbortController;
|
|
@@ -28,7 +29,7 @@ function stream(ctx, source, options = {}) {
|
|
|
28
29
|
reason
|
|
29
30
|
});
|
|
30
31
|
} catch (error) {
|
|
31
|
-
|
|
32
|
+
reportFailure(ctx, "stream", error);
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
const emit = (controller, chunk) => {
|
|
@@ -64,7 +65,7 @@ function stream(ctx, source, options = {}) {
|
|
|
64
65
|
written += 1;
|
|
65
66
|
emit(controller, next.value);
|
|
66
67
|
} catch (error) {
|
|
67
|
-
|
|
68
|
+
reportFailure(ctx, "stream", error);
|
|
68
69
|
done("failed");
|
|
69
70
|
close(controller);
|
|
70
71
|
}
|
|
@@ -149,5 +150,5 @@ export {
|
|
|
149
150
|
stream
|
|
150
151
|
};
|
|
151
152
|
|
|
152
|
-
//# debugId=
|
|
153
|
+
//# debugId=8A0071BAD723F56C64756E2164756E21
|
|
153
154
|
//# sourceMappingURL=index.js.map
|
package/dist/src/index.js.map
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/stream.ts", "../src/index.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * A generator, piped to a response at the rate the client reads it.\n *\n * This is the machinery `sse()` is built on, exported because the wire\n * format is the only part of it that is about server-sent events. A\n * server-to-server feed wants newline-delimited JSON, an export wants CSV,\n * a proxy wants whatever it was handed — and none of them should have to\n * rediscover backpressure, the abort signal, ending the generator on\n * cancellation, or reporting what the stream did.\n *\n * ```ts\n * handler: (ctx) =>\n * stream(\n * ctx,\n * async function* (signal) {\n * for await (const row of rows.watch({ signal })) {\n * yield `${JSON.stringify(row)}\\n`;\n * }\n * },\n * { contentType: \"application/x-ndjson\" },\n * );\n * ```\n *\n * @module\n */\n\nimport type { BaseCtx } from \"@tetsujs/core\";\n\n/** How a stream ended. */\nexport type StreamReason =\n /** The generator ran out on its own. */\n | \"ended\"\n /**\n * Nobody is reading any more — the client disconnected, or the pipeline\n * discarded the response the stream was the body of.\n */\n | \"cancelled\"\n /** The generator threw. What had already gone out stayed valid. */\n | \"failed\";\n\n/** One finished stream. */\nexport interface StreamSummary {\n /**\n * Chunks the generator yielded and the stream wrote, not counting\n * keep-alives. For `sse()` that is one per event.\n */\n readonly chunks: number;\n\n /**\n * Bytes enqueued, keep-alives included — what the stream put on the wire\n * rather than what the application meant to say.\n */\n readonly bytes: number;\n\n /** How long the stream lived, in milliseconds, to the microsecond. */\n readonly durationMs: number;\n\n readonly reason: StreamReason;\n}\n\n/** Something written on a schedule, so an idle connection stays open. */\nexport interface KeepAlive {\n /** How often, in milliseconds. */\n readonly everyMs: number;\n\n /**\n * What to write. It has to be something the consumer's parser ignores —\n * a comment in a format that has them, a blank line in one that does\n * not, nothing at all in a format where neither is true.\n */\n readonly chunk: string;\n}\n\n/** How the stream behaves and what it answers with. */\nexport interface StreamOptions {\n /** The response's `content-type`. Omitted, none is set. */\n readonly contentType?: string;\n\n /** Status of the response. `200` by default. */\n readonly status?: number;\n\n /** Headers to send alongside — `cache-control`, and whatever else. */\n readonly headers?: Record<string, string>;\n\n /** A filler written while nothing else is. Off by default. */\n readonly keepAlive?: KeepAlive;\n\n /** Called once when the stream is over, with what it did. */\n readonly onEnd?: (summary: StreamSummary) => void;\n}\n\n/**\n * Builds a streaming response from an async generator.\n *\n * The generator is handed an `AbortSignal` that fires when the stream is\n * over, whichever way it ended — the client disconnected, the consumer\n * cancelled, the generator itself failed. It is not `ctx.req.signal`\n * directly: a source wants to know that this stream is finished, not which\n * of the ways finished it.\n *\n * **Passing it on is what makes cleanup work**, and it is the caller's job\n * rather than this module's. A generator between two `yield`s leaves on\n * its own — the loop sees the abort at the next chunk, and `return()` runs\n * its `finally`. A generator parked inside an `await` is resumed by\n * nothing: `return()` on it is queued behind that `await` and applies only\n * once it settles, so an `await` on a source that has gone quiet never\n * unwinds, and the subscription inside it lives as long as the process.\n * Neither cancelling the stream nor `return()` changes that — both were\n * measured, both fire, neither wakes it — which is why the signal goes to\n * the source instead.\n *\n * So the rule, stated plainly: **a stream ends with the connection if its\n * generator keeps yielding, or if it waits on the signal.** A generator\n * that does neither leaks, and no amount of care out here can collect it.\n *\n * A generator that fails instead of ending is logged and the stream is\n * closed where it stood, so what already went out stays valid and the\n * client sees an ordinary end of stream. Letting the failure escape\n * instead would reach no one the application can hear: the platform prints\n * a raw stack and tears the connection down, and whether the bytes already\n * queued are lost with it depends on whether a macrotask happened to run\n * in between.\n */\nexport function stream(\n ctx: BaseCtx,\n source: (signal: AbortSignal) => AsyncGenerator<string, void, undefined>,\n options: StreamOptions = {},\n): Response {\n const encoder = new TextEncoder();\n const ending = new AbortController();\n const signal = AbortSignal.any([ctx.req.signal, ending.signal]);\n\n const chunks = source(signal);\n\n const startedAt = performance.now();\n\n let beating: ReturnType<typeof setInterval> | undefined;\n let written = 0;\n let bytes = 0;\n let over = false;\n\n /**\n * Ends the stream once, whichever path got here first.\n *\n * Four of them do — the generator running out, the consumer going away,\n * the generator throwing, and a keep-alive finding the controller shut —\n * and the summary must be reported once, not once per path.\n */\n const done = (reason: StreamReason): void => {\n if (beating !== undefined) {\n clearInterval(beating);\n\n beating = undefined;\n }\n\n ending.abort();\n\n if (over) {\n return;\n }\n\n over = true;\n\n try {\n options.onEnd?.({\n chunks: written,\n bytes,\n durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,\n reason,\n });\n } catch (error) {\n /**\n * The response left long ago, so there is nothing to map this to and\n * nobody to answer — the same reason the generator's own failure is\n * printed rather than raised.\n */\n console.error(\"[tetsu] stream onEnd failed:\", error);\n }\n };\n\n /** Writes one chunk and counts what it put on the wire. */\n const emit = (\n controller: ReadableStreamDefaultController<Uint8Array>,\n chunk: string,\n ): void => {\n const encoded = encoder.encode(chunk);\n\n bytes += encoded.byteLength;\n\n controller.enqueue(encoded);\n };\n\n const body = new ReadableStream<Uint8Array>({\n /**\n * Starts the keep-alive, which is the only thing that writes on its\n * own schedule rather than on demand.\n *\n * It skips a beat the consumer has no room for, by the same rule the\n * chunks follow: a stream with a full queue is backed up, not idle,\n * and the filler exists only to keep an idle connection from being\n * closed by a proxy. Without the check a stalled stream would collect\n * one every interval for as long as it stalls, which is small and\n * unbounded — the shape of the defect this whole pull loop exists to\n * remove, in miniature.\n *\n * No test separates the two: the fillers are a few bytes each and they\n * queue behind the megabyte the transport is already holding, so\n * nothing observable through a socket ever reaches them. The check is\n * kept on the reasoning, not on a measurement, and this is the note\n * saying so.\n */\n start(controller) {\n const alive = options.keepAlive;\n\n if (!alive || alive.everyMs <= 0) {\n return;\n }\n\n beating = setInterval(() => {\n if ((controller.desiredSize ?? 0) <= 0) {\n return;\n }\n\n try {\n emit(controller, alive.chunk);\n } catch {\n done(\"cancelled\");\n }\n }, alive.everyMs);\n },\n\n /**\n * Produces one chunk, and only when the consumer has room for it.\n *\n * This is the whole of the backpressure: the platform calls `pull`\n * while the queue wants more and stops calling it when it does not, so\n * exactly one `next()` is ever in flight and the generator advances at\n * the rate the client reads. Driving the generator from a loop instead\n * asks it for everything at once, because `enqueue` never blocks and\n * never refuses: a client that stopped reading had a million chunks\n * built for it and held in memory.\n *\n * The cost of the shape is that leaving a loop no longer ends the\n * generator, because there is no loop; `cancel` calls `return()` in\n * its place.\n */\n async pull(controller) {\n try {\n const next = await chunks.next();\n\n if (next.done || signal.aborted) {\n /**\n * The signal is checked first on purpose: a generator that takes\n * it does the polite thing and returns, so `done` would be true\n * on a stream the client walked away from. What ended it is the\n * departure, and that is what the summary should say.\n *\n * No test separates this from always reporting `ended`, and that\n * is not a gap in the tests. Every way the signal becomes true\n * here runs through a `done` call that has already fixed the\n * reason — `cancel` on the consumer's side, the keep-alive\n * finding a shut controller — and all of them say `cancelled`\n * too. The branch decides a race whose other outcome agrees with\n * it, which is why it is kept and why nothing can observe it.\n */\n done(signal.aborted ? \"cancelled\" : \"ended\");\n close(controller);\n\n return;\n }\n\n written += 1;\n\n emit(controller, next.value);\n } catch (error) {\n console.error(\"[tetsu] stream generator failed:\", error);\n\n done(\"failed\");\n close(controller);\n }\n },\n\n /**\n * The stream's own end of life, told by whoever consumed it.\n *\n * Not a backstop: this is the only thing that ends a stream whose\n * response never reached the client. The pipeline releases a response\n * it discards — one a `beforeResponse` hook replaced, one an error\n * displaced, one a `HEAD` request answered without — by cancelling its\n * body, and the request's own signal says nothing in those cases,\n * because the request itself ended normally.\n *\n * A client that leaves aborts `ctx.req.signal` first, so that path\n * does not depend on this line; the request whose response was thrown\n * away depends on nothing else.\n */\n cancel() {\n done(\"cancelled\");\n\n void chunks.return();\n },\n });\n\n return new Response(body, {\n status: options.status ?? 200,\n headers: {\n ...(options.contentType\n ? { \"content-type\": options.contentType }\n : undefined),\n ...options.headers,\n },\n });\n}\n\n/**\n * Ends the stream, tolerating a client that already left.\n *\n * `close()` throws on a controller the platform closed when the connection\n * went away, and that is not a failure worth reporting: the stream ended\n * exactly as it was going to.\n */\nfunction close(controller: ReadableStreamDefaultController<Uint8Array>): void {\n try {\n controller.close();\n } catch {\n // The stream is already closed because the client left.\n }\n}\n",
|
|
6
|
-
"/**\n * Server-sent events.\n *\n * ```ts\n * route({\n * method: \"GET\",\n * path: \"/prices\",\n * handler: (ctx) =>\n * sse(ctx, async function* (signal) {\n * for await (const price of prices.watch({ signal })) {\n * yield { data: price, id: price.at };\n * }\n * }),\n * });\n * ```\n *\n * The framework needs none of this to stream — a handler returning a\n * `Response` with a stream already works. What this adds is the part that\n * is easy to get quietly wrong: the wire format (a blank line ends an\n * event, and every line of a multi-line payload carries its own `data:`\n * prefix), the headers a proxy needs to see, the heartbeat that keeps an\n * idle connection from being closed by one, an `AbortSignal` that says the\n * stream is over so a source that waits can stop waiting, and the\n * backpressure that makes a slow client cost a buffer rather than a heap:\n * events are produced on demand, one at a time, at the rate they are\n * read.\n *\n * @module\n */\n\nimport type { BaseCtx } from \"@tetsujs/core\";\nimport type { StreamReason, StreamSummary } from \"./stream.ts\";\nimport { stream } from \"./stream.ts\";\n\nexport type {\n KeepAlive,\n StreamOptions,\n StreamReason,\n StreamSummary,\n} from \"./stream.ts\";\nexport { stream } from \"./stream.ts\";\n\n/**\n * One event, as it goes over the wire.\n *\n * `data` is deliberately untyped. A stream usually carries several kinds\n * of event under different names, so one type parameter would be wrong for\n * all but the simplest feed — and nothing on the server consumes the\n * payload's type anyway: it leaves as JSON. A feed that is uniform can say\n * so where it is written, by typing its own generator.\n */\nexport interface ServerSentEvent {\n /**\n * The payload. A string is sent as it is; anything else is JSON, which\n * is what a browser's `EventSource` expects to parse. A value JSON has\n * no form for — `undefined`, a function, a symbol — is refused rather\n * than sent as an empty string: a payload that went missing, a\n * `map.get()` that found nothing, would reach the page as a valid event\n * with nothing in it.\n */\n readonly data: unknown;\n\n /** Event name, read by `addEventListener(name)` rather than `onmessage`. */\n readonly event?: string;\n\n /**\n * Event id. The browser sends the last one back as `Last-Event-ID` when\n * it reconnects, which is how a stream resumes where it stopped.\n */\n readonly id?: string | number;\n\n /** How long the browser waits before reconnecting, in milliseconds. */\n readonly retry?: number;\n}\n\n/** How the stream behaves. */\nexport interface SseOptions {\n /**\n * How often a comment line is sent to keep the connection alive, in\n * milliseconds. Defaults to 15 seconds; `0` turns it off.\n *\n * On by default because the failure it prevents is silent and remote: a\n * proxy between the server and the browser closes a connection that has\n * been idle — nginx after 60 seconds by default — and the application\n * sees a client that keeps reconnecting for no visible reason.\n */\n readonly heartbeatMs?: number;\n\n /** Status of the response. `200` by default. */\n readonly status?: number;\n\n /**\n * Called once when the stream is over, with what it did.\n *\n * The gap this closes: `afterResponse` runs when the response is handed\n * to the runtime, which for a stream is the moment it *starts*. An\n * access log therefore records a forty-minute feed as a `200` that took\n * microseconds, and a torn connection as a success. Delivery to the\n * client is not observable in the fetch model and stays that way — but\n * the end of *generation* is, and that is what this reports.\n *\n * It carries no request id on purpose. This callback is written at the\n * call site, where `ctx` is already in scope, so the caller adds\n * whatever identifies the request better than this package could guess.\n *\n * @example\n * ```ts\n * sse(ctx, feed, {\n * onEnd: (summary) =>\n * logger.info({ ...summary, requestId: ctx.requestId }, \"stream closed\"),\n * });\n * ```\n */\n readonly onEnd?: (summary: SseSummary) => void;\n}\n\n/**\n * How an SSE stream ended — {@link StreamReason} by another name, because\n * a reader of this package should not have to go looking.\n */\nexport type SseReason = StreamReason;\n\n/**\n * One finished stream, as server-sent events count it.\n *\n * The same record {@link StreamSummary} carries, with `chunks` named\n * `events`: for this helper one chunk is one event, and the word an author\n * reads at the call site should be the one they wrote.\n */\nexport interface SseSummary extends Omit<StreamSummary, \"chunks\"> {\n /** Events yielded and written, not counting heartbeats. */\n readonly events: number;\n}\n\n/**\n * Builds a server-sent events response from an async generator.\n *\n * The generator is handed an `AbortSignal` that fires when the stream is\n * over, whichever way it ended — the client disconnected, the consumer\n * cancelled, the generator itself failed. It is not `ctx.req.signal`\n * directly: a source wants to know that this stream is finished, not which\n * of the ways finished it.\n *\n * **Passing it on is what makes cleanup work**, and it is the caller's job\n * rather than this package's. A generator between two `yield`s leaves on\n * its own — the loop sees the abort at the next value, ends the `for\n * await`, and the language calls the generator's `return()`, which runs\n * its `finally`. A generator parked inside an `await` is resumed by\n * nothing: `return()` on it is queued behind that `await` and applies only\n * once it settles, so an `await` on a source that has gone quiet never\n * unwinds, and the subscription inside it lives as long as the process.\n * Neither `cancel()` on the stream nor `return()` on the generator changes\n * that — both were measured, both fire, neither wakes it — which is why\n * the signal goes to the source instead.\n *\n * So the rule, stated plainly: **a stream ends with the connection if its\n * generator keeps yielding, or if it waits on the signal.** A generator\n * that does neither leaks, and no amount of care out here can collect it.\n *\n * A generator that fails instead of ending is logged and the stream is\n * closed where it stood, so what already went out stays valid and the\n * client sees an ordinary end of stream. Letting the failure escape\n * `start()` instead would reach no one the application can hear: the\n * platform prints a raw stack and tears the connection down, and whether\n * the bytes already queued are lost with it depends on whether a macrotask\n * happened to run in between.\n *\n * @example A source that yields on its own — the loop ends it.\n * ```ts\n * sse(ctx, async function* () {\n * const subscription = topic.subscribe();\n *\n * try {\n * for await (const message of subscription) {\n * yield { event: \"message\", data: message, id: message.id };\n * }\n * } finally {\n * subscription.close();\n * }\n * });\n * ```\n *\n * @example A source that can go quiet — it has to take the signal.\n * ```ts\n * sse(ctx, async function* (signal) {\n * const queue = await broker.subscribe(\"prices\", { signal });\n *\n * try {\n * for await (const price of queue) {\n * yield { data: price, id: price.at };\n * }\n * } finally {\n * await queue.close();\n * }\n * });\n * ```\n */\nexport function sse(\n ctx: BaseCtx,\n source: (\n signal: AbortSignal,\n ) => AsyncGenerator<ServerSentEvent, void, undefined>,\n options: SseOptions = {},\n): Response {\n const heartbeatMs = options.heartbeatMs ?? 15_000;\n\n const { onEnd } = options;\n\n return stream(\n ctx,\n /**\n * The only part of this helper that is about server-sent events: the\n * events become frames, and everything else — backpressure, the\n * signal, ending the generator, the summary — is the stream's.\n *\n * A `for await` rather than a manual loop, because leaving it is what\n * passes `return()` on to the source when the stream is cancelled.\n */\n async function* (signal) {\n for await (const event of source(signal)) {\n yield frame(event);\n }\n },\n {\n contentType: \"text/event-stream\",\n headers: { \"cache-control\": \"no-cache\" },\n\n ...(options.status === undefined ? {} : { status: options.status }),\n\n ...(heartbeatMs > 0\n ? { keepAlive: { everyMs: heartbeatMs, chunk: \": ping\\n\\n\" } }\n : {}),\n\n ...(onEnd\n ? {\n onEnd: ({ chunks, ...rest }: StreamSummary) =>\n onEnd({ ...rest, events: chunks }),\n }\n : {}),\n },\n );\n}\n\n/**\n * What ends a line for a client reading this stream.\n *\n * The protocol terminates a line on CRLF, CR or LF — all three, which is\n * why splitting the payload on `\\n` alone is not enough: a lone `\\r` ends\n * the field just as surely, and the rest of the value is read as a new one.\n */\nconst lineBreak = /\\r\\n|[\\r\\n]/;\n\n/** What a single-line field cannot carry without ceasing to be one. */\nconst unrepresentable = /[\\r\\n\\0]/;\n\n/**\n * Formats one event.\n *\n * Every line of the payload carries its own `data:` prefix — a raw line\n * break inside one would otherwise end the field — and a blank line ends\n * the event, which is what makes the client dispatch it.\n */\nexport function frame(event: ServerSentEvent): string {\n const lines: string[] = [];\n\n if (event.event !== undefined) {\n lines.push(`event: ${single(\"event\", event.event)}`);\n }\n\n if (event.id !== undefined) {\n lines.push(`id: ${single(\"id\", String(event.id))}`);\n }\n\n if (event.retry !== undefined) {\n lines.push(`retry: ${event.retry}`);\n }\n\n const payload =\n typeof event.data === \"string\" ? event.data : JSON.stringify(event.data);\n\n if (payload === undefined) {\n throw new TypeError(\n `an SSE event's data has no JSON form: ${typeof event.data}`,\n );\n }\n\n for (const line of payload.split(lineBreak)) {\n lines.push(`data: ${line}`);\n }\n\n return `${lines.join(\"\\n\")}\\n\\n`;\n}\n\n/**\n * Checks a field the protocol gives no way to continue onto a second line.\n *\n * `data` can carry a line break because every one of its lines is prefixed\n * again; `event` and `id` cannot — the protocol has no syntax for it. So a\n * value holding one is not \"an event name with a newline in it\", it is a\n * second field the client will read and act on: a name the stream never\n * sent, or an id it will send back on reconnect. That is an injection, and\n * the values most likely to hold a line break are exactly the ones built\n * from outside input — a topic name, a row's key, a user's label.\n *\n * Throwing rather than stripping, for the reason the platform throws on a\n * header value with CRLF in it: a quietly rewritten id resumes the stream\n * somewhere else, and the application never learns it asked for something\n * the wire cannot carry. NUL joins them because a client drops an `id`\n * holding one, which breaks resumption just as silently.\n */\nfunction single(field: string, value: string): string {\n if (unrepresentable.test(value)) {\n throw new TypeError(\n `an SSE ${field} cannot contain a line break or NUL: ${JSON.stringify(value)}`,\n );\n }\n\n return value;\n}\n\n/**\n * The id the client last received, when it is reconnecting.\n *\n * A browser sends it automatically after a dropped connection; a stream\n * that yields ids can resume from it instead of starting over.\n *\n * @example\n * ```ts\n * handler: (ctx) =>\n * sse(ctx, async function* () {\n * for await (const item of history.since(lastEventId(ctx))) {\n * yield { data: item, id: item.id };\n * }\n * });\n * ```\n */\nexport function lastEventId(ctx: BaseCtx): string | undefined {\n return ctx.req.headers.get(\"last-event-id\") ?? undefined;\n}\n"
|
|
5
|
+
"/**\n * A generator, piped to a response at the rate the client reads it.\n *\n * This is the machinery `sse()` is built on, exported because the wire\n * format is the only part of it that is about server-sent events. A\n * server-to-server feed wants newline-delimited JSON, an export wants CSV,\n * a proxy wants whatever it was handed — and none of them should have to\n * rediscover backpressure, the abort signal, ending the generator on\n * cancellation, or reporting what the stream did.\n *\n * ```ts\n * handler: (ctx) =>\n * stream(\n * ctx,\n * async function* (signal) {\n * for await (const row of rows.watch({ signal })) {\n * yield `${JSON.stringify(row)}\\n`;\n * }\n * },\n * { contentType: \"application/x-ndjson\" },\n * );\n * ```\n *\n * @module\n */\n\nimport type { BaseCtx } from \"@tetsujs/core\";\nimport { reportFailure } from \"@tetsujs/core\";\n\n/** How a stream ended. */\nexport type StreamReason =\n /** The generator ran out on its own. */\n | \"ended\"\n /**\n * Nobody is reading any more — the client disconnected, or the pipeline\n * discarded the response the stream was the body of.\n */\n | \"cancelled\"\n /** The generator threw. What had already gone out stayed valid. */\n | \"failed\";\n\n/** One finished stream. */\nexport interface StreamSummary {\n /**\n * Chunks the generator yielded and the stream wrote, not counting\n * keep-alives. For `sse()` that is one per event.\n */\n readonly chunks: number;\n\n /**\n * Bytes enqueued, keep-alives included — what the stream put on the wire\n * rather than what the application meant to say.\n */\n readonly bytes: number;\n\n /** How long the stream lived, in milliseconds, to the microsecond. */\n readonly durationMs: number;\n\n readonly reason: StreamReason;\n}\n\n/** Something written on a schedule, so an idle connection stays open. */\nexport interface KeepAlive {\n /** How often, in milliseconds. */\n readonly everyMs: number;\n\n /**\n * What to write. It has to be something the consumer's parser ignores —\n * a comment in a format that has them, a blank line in one that does\n * not, nothing at all in a format where neither is true.\n */\n readonly chunk: string;\n}\n\n/** How the stream behaves and what it answers with. */\nexport interface StreamOptions {\n /** The response's `content-type`. Omitted, none is set. */\n readonly contentType?: string;\n\n /** Status of the response. `200` by default. */\n readonly status?: number;\n\n /** Headers to send alongside — `cache-control`, and whatever else. */\n readonly headers?: Record<string, string>;\n\n /** A filler written while nothing else is. Off by default. */\n readonly keepAlive?: KeepAlive;\n\n /** Called once when the stream is over, with what it did. */\n readonly onEnd?: (summary: StreamSummary) => void;\n}\n\n/**\n * Builds a streaming response from an async generator.\n *\n * The generator is handed an `AbortSignal` that fires when the stream is\n * over, whichever way it ended — the client disconnected, the consumer\n * cancelled, the generator itself failed. It is not `ctx.req.signal`\n * directly: a source wants to know that this stream is finished, not which\n * of the ways finished it.\n *\n * **Passing it on is what makes cleanup work**, and it is the caller's job\n * rather than this module's. A generator between two `yield`s leaves on\n * its own — the loop sees the abort at the next chunk, and `return()` runs\n * its `finally`. A generator parked inside an `await` is resumed by\n * nothing: `return()` on it is queued behind that `await` and applies only\n * once it settles, so an `await` on a source that has gone quiet never\n * unwinds, and the subscription inside it lives as long as the process.\n * Neither cancelling the stream nor `return()` changes that — both were\n * measured, both fire, neither wakes it — which is why the signal goes to\n * the source instead.\n *\n * So the rule, stated plainly: **a stream ends with the connection if its\n * generator keeps yielding, or if it waits on the signal.** A generator\n * that does neither leaks, and no amount of care out here can collect it.\n *\n * A generator that fails instead of ending is reported — to the\n * application's `reportError`, with `source: \"stream\"` — and the stream is\n * closed where it stood, so what already went out stays valid and the\n * client sees an ordinary end of stream. Letting the failure escape\n * instead would reach no one the application can hear: the platform prints\n * a raw stack and tears the connection down, and whether the bytes already\n * queued are lost with it depends on whether a macrotask happened to run\n * in between.\n */\nexport function stream(\n ctx: BaseCtx,\n source: (signal: AbortSignal) => AsyncGenerator<string, void, undefined>,\n options: StreamOptions = {},\n): Response {\n const encoder = new TextEncoder();\n const ending = new AbortController();\n const signal = AbortSignal.any([ctx.req.signal, ending.signal]);\n\n const chunks = source(signal);\n\n const startedAt = performance.now();\n\n let beating: ReturnType<typeof setInterval> | undefined;\n let written = 0;\n let bytes = 0;\n let over = false;\n\n /**\n * Ends the stream once, whichever path got here first.\n *\n * Four of them do — the generator running out, the consumer going away,\n * the generator throwing, and a keep-alive finding the controller shut —\n * and the summary must be reported once, not once per path.\n */\n const done = (reason: StreamReason): void => {\n if (beating !== undefined) {\n clearInterval(beating);\n\n beating = undefined;\n }\n\n ending.abort();\n\n if (over) {\n return;\n }\n\n over = true;\n\n try {\n options.onEnd?.({\n chunks: written,\n bytes,\n durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,\n reason,\n });\n } catch (error) {\n /**\n * The response left long ago, so there is nothing to map this to and\n * nobody to answer — the same reason the generator's own failure is\n * reported rather than raised.\n */\n reportFailure(ctx, \"stream\", error);\n }\n };\n\n /** Writes one chunk and counts what it put on the wire. */\n const emit = (\n controller: ReadableStreamDefaultController<Uint8Array>,\n chunk: string,\n ): void => {\n const encoded = encoder.encode(chunk);\n\n bytes += encoded.byteLength;\n\n controller.enqueue(encoded);\n };\n\n const body = new ReadableStream<Uint8Array>({\n /**\n * Starts the keep-alive, which is the only thing that writes on its\n * own schedule rather than on demand.\n *\n * It skips a beat the consumer has no room for, by the same rule the\n * chunks follow: a stream with a full queue is backed up, not idle,\n * and the filler exists only to keep an idle connection from being\n * closed by a proxy. Without the check a stalled stream would collect\n * one every interval for as long as it stalls, which is small and\n * unbounded — the shape of the defect this whole pull loop exists to\n * remove, in miniature.\n *\n * No test separates the two: the fillers are a few bytes each and they\n * queue behind the megabyte the transport is already holding, so\n * nothing observable through a socket ever reaches them. The check is\n * kept on the reasoning, not on a measurement, and this is the note\n * saying so.\n */\n start(controller) {\n const alive = options.keepAlive;\n\n if (!alive || alive.everyMs <= 0) {\n return;\n }\n\n beating = setInterval(() => {\n if ((controller.desiredSize ?? 0) <= 0) {\n return;\n }\n\n try {\n emit(controller, alive.chunk);\n } catch {\n done(\"cancelled\");\n }\n }, alive.everyMs);\n },\n\n /**\n * Produces one chunk, and only when the consumer has room for it.\n *\n * This is the whole of the backpressure: the platform calls `pull`\n * while the queue wants more and stops calling it when it does not, so\n * exactly one `next()` is ever in flight and the generator advances at\n * the rate the client reads. Driving the generator from a loop instead\n * asks it for everything at once, because `enqueue` never blocks and\n * never refuses: a client that stopped reading had a million chunks\n * built for it and held in memory.\n *\n * The cost of the shape is that leaving a loop no longer ends the\n * generator, because there is no loop; `cancel` calls `return()` in\n * its place.\n */\n async pull(controller) {\n try {\n const next = await chunks.next();\n\n if (next.done || signal.aborted) {\n /**\n * The signal is checked first on purpose: a generator that takes\n * it does the polite thing and returns, so `done` would be true\n * on a stream the client walked away from. What ended it is the\n * departure, and that is what the summary should say.\n *\n * No test separates this from always reporting `ended`, and that\n * is not a gap in the tests. Every way the signal becomes true\n * here runs through a `done` call that has already fixed the\n * reason — `cancel` on the consumer's side, the keep-alive\n * finding a shut controller — and all of them say `cancelled`\n * too. The branch decides a race whose other outcome agrees with\n * it, which is why it is kept and why nothing can observe it.\n */\n done(signal.aborted ? \"cancelled\" : \"ended\");\n close(controller);\n\n return;\n }\n\n written += 1;\n\n emit(controller, next.value);\n } catch (error) {\n reportFailure(ctx, \"stream\", error);\n\n done(\"failed\");\n close(controller);\n }\n },\n\n /**\n * The stream's own end of life, told by whoever consumed it.\n *\n * Not a backstop: this is the only thing that ends a stream whose\n * response never reached the client. The pipeline releases a response\n * it discards — one a `beforeResponse` hook replaced, one an error\n * displaced, one a `HEAD` request answered without — by cancelling its\n * body, and the request's own signal says nothing in those cases,\n * because the request itself ended normally.\n *\n * A client that leaves aborts `ctx.req.signal` first, so that path\n * does not depend on this line; the request whose response was thrown\n * away depends on nothing else.\n */\n cancel() {\n done(\"cancelled\");\n\n void chunks.return();\n },\n });\n\n return new Response(body, {\n status: options.status ?? 200,\n headers: {\n ...(options.contentType\n ? { \"content-type\": options.contentType }\n : undefined),\n ...options.headers,\n },\n });\n}\n\n/**\n * Ends the stream, tolerating a client that already left.\n *\n * `close()` throws on a controller the platform closed when the connection\n * went away, and that is not a failure worth reporting: the stream ended\n * exactly as it was going to.\n */\nfunction close(controller: ReadableStreamDefaultController<Uint8Array>): void {\n try {\n controller.close();\n } catch {\n // The stream is already closed because the client left.\n }\n}\n",
|
|
6
|
+
"/**\n * Server-sent events.\n *\n * ```ts\n * route({\n * method: \"GET\",\n * path: \"/prices\",\n * handler: (ctx) =>\n * sse(ctx, async function* (signal) {\n * for await (const price of prices.watch({ signal })) {\n * yield { data: price, id: price.at };\n * }\n * }),\n * });\n * ```\n *\n * The framework needs none of this to stream — a handler returning a\n * `Response` with a stream already works. What this adds is the part that\n * is easy to get quietly wrong: the wire format (a blank line ends an\n * event, and every line of a multi-line payload carries its own `data:`\n * prefix), the headers a proxy needs to see, the heartbeat that keeps an\n * idle connection from being closed by one, an `AbortSignal` that says the\n * stream is over so a source that waits can stop waiting, and the\n * backpressure that makes a slow client cost a buffer rather than a heap:\n * events are produced on demand, one at a time, at the rate they are\n * read.\n *\n * @module\n */\n\nimport type { BaseCtx } from \"@tetsujs/core\";\nimport type { StreamReason, StreamSummary } from \"./stream.ts\";\nimport { stream } from \"./stream.ts\";\n\nexport type {\n KeepAlive,\n StreamOptions,\n StreamReason,\n StreamSummary,\n} from \"./stream.ts\";\nexport { stream } from \"./stream.ts\";\n\n/**\n * One event, as it goes over the wire.\n *\n * `data` is deliberately untyped. A stream usually carries several kinds\n * of event under different names, so one type parameter would be wrong for\n * all but the simplest feed — and nothing on the server consumes the\n * payload's type anyway: it leaves as JSON. A feed that is uniform can say\n * so where it is written, by typing its own generator.\n */\nexport interface ServerSentEvent {\n /**\n * The payload. A string is sent as it is; anything else is JSON, which\n * is what a browser's `EventSource` expects to parse. A value JSON has\n * no form for — `undefined`, a function, a symbol — is refused rather\n * than sent as an empty string: a payload that went missing, a\n * `map.get()` that found nothing, would reach the page as a valid event\n * with nothing in it.\n */\n readonly data: unknown;\n\n /** Event name, read by `addEventListener(name)` rather than `onmessage`. */\n readonly event?: string;\n\n /**\n * Event id. The browser sends the last one back as `Last-Event-ID` when\n * it reconnects, which is how a stream resumes where it stopped.\n */\n readonly id?: string | number;\n\n /** How long the browser waits before reconnecting, in milliseconds. */\n readonly retry?: number;\n}\n\n/** How the stream behaves. */\nexport interface SseOptions {\n /**\n * How often a comment line is sent to keep the connection alive, in\n * milliseconds. Defaults to 15 seconds; `0` turns it off.\n *\n * On by default because the failure it prevents is silent and remote: a\n * proxy between the server and the browser closes a connection that has\n * been idle — nginx after 60 seconds by default — and the application\n * sees a client that keeps reconnecting for no visible reason.\n */\n readonly heartbeatMs?: number;\n\n /** Status of the response. `200` by default. */\n readonly status?: number;\n\n /**\n * Called once when the stream is over, with what it did.\n *\n * The gap this closes: `afterResponse` runs when the response is handed\n * to the runtime, which for a stream is the moment it *starts*. An\n * access log therefore records a forty-minute feed as a `200` that took\n * microseconds, and a torn connection as a success. Delivery to the\n * client is not observable in the fetch model and stays that way — but\n * the end of *generation* is, and that is what this reports.\n *\n * It carries no request id on purpose. This callback is written at the\n * call site, where `ctx` is already in scope, so the caller adds\n * whatever identifies the request better than this package could guess.\n *\n * @example\n * ```ts\n * sse(ctx, feed, {\n * onEnd: (summary) =>\n * logger.info({ ...summary, requestId: ctx.requestId }, \"stream closed\"),\n * });\n * ```\n */\n readonly onEnd?: (summary: SseSummary) => void;\n}\n\n/**\n * How an SSE stream ended — {@link StreamReason} by another name, because\n * a reader of this package should not have to go looking.\n */\nexport type SseReason = StreamReason;\n\n/**\n * One finished stream, as server-sent events count it.\n *\n * The same record {@link StreamSummary} carries, with `chunks` named\n * `events`: for this helper one chunk is one event, and the word an author\n * reads at the call site should be the one they wrote.\n */\nexport interface SseSummary extends Omit<StreamSummary, \"chunks\"> {\n /** Events yielded and written, not counting heartbeats. */\n readonly events: number;\n}\n\n/**\n * Builds a server-sent events response from an async generator.\n *\n * The generator is handed an `AbortSignal` that fires when the stream is\n * over, whichever way it ended — the client disconnected, the consumer\n * cancelled, the generator itself failed. It is not `ctx.req.signal`\n * directly: a source wants to know that this stream is finished, not which\n * of the ways finished it.\n *\n * **Passing it on is what makes cleanup work**, and it is the caller's job\n * rather than this package's. A generator between two `yield`s leaves on\n * its own — the loop sees the abort at the next value, ends the `for\n * await`, and the language calls the generator's `return()`, which runs\n * its `finally`. A generator parked inside an `await` is resumed by\n * nothing: `return()` on it is queued behind that `await` and applies only\n * once it settles, so an `await` on a source that has gone quiet never\n * unwinds, and the subscription inside it lives as long as the process.\n * Neither `cancel()` on the stream nor `return()` on the generator changes\n * that — both were measured, both fire, neither wakes it — which is why\n * the signal goes to the source instead.\n *\n * So the rule, stated plainly: **a stream ends with the connection if its\n * generator keeps yielding, or if it waits on the signal.** A generator\n * that does neither leaks, and no amount of care out here can collect it.\n *\n * A generator that fails instead of ending is reported — to the\n * application's `reportError`, with `source: \"stream\"` — and the stream is\n * closed where it stood, so what already went out stays valid and the\n * client sees an ordinary end of stream. Letting the failure escape\n * `start()` instead would reach no one the application can hear: the\n * platform prints a raw stack and tears the connection down, and whether\n * the bytes already queued are lost with it depends on whether a macrotask\n * happened to run in between.\n *\n * @example A source that yields on its own — the loop ends it.\n * ```ts\n * sse(ctx, async function* () {\n * const subscription = topic.subscribe();\n *\n * try {\n * for await (const message of subscription) {\n * yield { event: \"message\", data: message, id: message.id };\n * }\n * } finally {\n * subscription.close();\n * }\n * });\n * ```\n *\n * @example A source that can go quiet — it has to take the signal.\n * ```ts\n * sse(ctx, async function* (signal) {\n * const queue = await broker.subscribe(\"prices\", { signal });\n *\n * try {\n * for await (const price of queue) {\n * yield { data: price, id: price.at };\n * }\n * } finally {\n * await queue.close();\n * }\n * });\n * ```\n */\nexport function sse(\n ctx: BaseCtx,\n source: (\n signal: AbortSignal,\n ) => AsyncGenerator<ServerSentEvent, void, undefined>,\n options: SseOptions = {},\n): Response {\n const heartbeatMs = options.heartbeatMs ?? 15_000;\n\n const { onEnd } = options;\n\n return stream(\n ctx,\n /**\n * The only part of this helper that is about server-sent events: the\n * events become frames, and everything else — backpressure, the\n * signal, ending the generator, the summary — is the stream's.\n *\n * A `for await` rather than a manual loop, because leaving it is what\n * passes `return()` on to the source when the stream is cancelled.\n */\n async function* (signal) {\n for await (const event of source(signal)) {\n yield frame(event);\n }\n },\n {\n contentType: \"text/event-stream\",\n headers: { \"cache-control\": \"no-cache\" },\n\n ...(options.status === undefined ? {} : { status: options.status }),\n\n ...(heartbeatMs > 0\n ? { keepAlive: { everyMs: heartbeatMs, chunk: \": ping\\n\\n\" } }\n : {}),\n\n ...(onEnd\n ? {\n onEnd: ({ chunks, ...rest }: StreamSummary) =>\n onEnd({ ...rest, events: chunks }),\n }\n : {}),\n },\n );\n}\n\n/**\n * What ends a line for a client reading this stream.\n *\n * The protocol terminates a line on CRLF, CR or LF — all three, which is\n * why splitting the payload on `\\n` alone is not enough: a lone `\\r` ends\n * the field just as surely, and the rest of the value is read as a new one.\n */\nconst lineBreak = /\\r\\n|[\\r\\n]/;\n\n/** What a single-line field cannot carry without ceasing to be one. */\nconst unrepresentable = /[\\r\\n\\0]/;\n\n/**\n * Formats one event.\n *\n * Every line of the payload carries its own `data:` prefix — a raw line\n * break inside one would otherwise end the field — and a blank line ends\n * the event, which is what makes the client dispatch it.\n */\nexport function frame(event: ServerSentEvent): string {\n const lines: string[] = [];\n\n if (event.event !== undefined) {\n lines.push(`event: ${single(\"event\", event.event)}`);\n }\n\n if (event.id !== undefined) {\n lines.push(`id: ${single(\"id\", String(event.id))}`);\n }\n\n if (event.retry !== undefined) {\n lines.push(`retry: ${event.retry}`);\n }\n\n const payload =\n typeof event.data === \"string\" ? event.data : JSON.stringify(event.data);\n\n if (payload === undefined) {\n throw new TypeError(\n `an SSE event's data has no JSON form: ${typeof event.data}`,\n );\n }\n\n for (const line of payload.split(lineBreak)) {\n lines.push(`data: ${line}`);\n }\n\n return `${lines.join(\"\\n\")}\\n\\n`;\n}\n\n/**\n * Checks a field the protocol gives no way to continue onto a second line.\n *\n * `data` can carry a line break because every one of its lines is prefixed\n * again; `event` and `id` cannot — the protocol has no syntax for it. So a\n * value holding one is not \"an event name with a newline in it\", it is a\n * second field the client will read and act on: a name the stream never\n * sent, or an id it will send back on reconnect. That is an injection, and\n * the values most likely to hold a line break are exactly the ones built\n * from outside input — a topic name, a row's key, a user's label.\n *\n * Throwing rather than stripping, for the reason the platform throws on a\n * header value with CRLF in it: a quietly rewritten id resumes the stream\n * somewhere else, and the application never learns it asked for something\n * the wire cannot carry. NUL joins them because a client drops an `id`\n * holding one, which breaks resumption just as silently.\n */\nfunction single(field: string, value: string): string {\n if (unrepresentable.test(value)) {\n throw new TypeError(\n `an SSE ${field} cannot contain a line break or NUL: ${JSON.stringify(value)}`,\n );\n }\n\n return value;\n}\n\n/**\n * The id the client last received, when it is reconnecting.\n *\n * A browser sends it automatically after a dropped connection; a stream\n * that yields ids can resume from it instead of starting over.\n *\n * @example\n * ```ts\n * handler: (ctx) =>\n * sse(ctx, async function* () {\n * for await (const item of history.since(lastEventId(ctx))) {\n * yield { data: item, id: item.id };\n * }\n * });\n * ```\n */\nexport function lastEventId(ctx: BaseCtx): string | undefined {\n return ctx.req.headers.get(\"last-event-id\") ?? undefined;\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";;
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "8A0071BAD723F56C64756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/dist/src/stream.d.ts
CHANGED
|
@@ -99,7 +99,8 @@ export interface StreamOptions {
|
|
|
99
99
|
* generator keeps yielding, or if it waits on the signal.** A generator
|
|
100
100
|
* that does neither leaks, and no amount of care out here can collect it.
|
|
101
101
|
*
|
|
102
|
-
* A generator that fails instead of ending is
|
|
102
|
+
* A generator that fails instead of ending is reported — to the
|
|
103
|
+
* application's `reportError`, with `source: "stream"` — and the stream is
|
|
103
104
|
* closed where it stood, so what already went out stays valid and the
|
|
104
105
|
* client sees an ordinary end of stream. Letting the failure escape
|
|
105
106
|
* instead would reach no one the application can hear: the platform prints
|
package/dist/src/stream.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"stream.d.ts","sourceRoot":"","sources":["../../src/stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7C,0BAA0B;AAC1B,MAAM,MAAM,YAAY;AACtB,wCAAwC;AACtC,OAAO;AACT;;;GAGG;GACD,WAAW;AACb,mEAAmE;GACjE,QAAQ,CAAC;AAEb,2BAA2B;AAC3B,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B;AAED,yEAAyE;AACzE,MAAM,WAAW,SAAS;IACxB,kCAAkC;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,uDAAuD;AACvD,MAAM,WAAW,aAAa;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,sEAAsE;IACtE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAE1C,8DAA8D;IAC9D,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAE/B,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,MAAM,CACpB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,cAAc,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,CAAC,EACxE,OAAO,GAAE,aAAkB,GAC1B,QAAQ,CAyLV"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tetsujs/sse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Server-sent events and streamed responses for Tetsu, from an async generator",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@tetsujs/core": "^0.
|
|
38
|
+
"@tetsujs/core": "^0.3.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@tetsujs/core": "0.
|
|
41
|
+
"@tetsujs/core": "0.3.0"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -157,7 +157,8 @@ export interface SseSummary extends Omit<StreamSummary, "chunks"> {
|
|
|
157
157
|
* generator keeps yielding, or if it waits on the signal.** A generator
|
|
158
158
|
* that does neither leaks, and no amount of care out here can collect it.
|
|
159
159
|
*
|
|
160
|
-
* A generator that fails instead of ending is
|
|
160
|
+
* A generator that fails instead of ending is reported — to the
|
|
161
|
+
* application's `reportError`, with `source: "stream"` — and the stream is
|
|
161
162
|
* closed where it stood, so what already went out stays valid and the
|
|
162
163
|
* client sees an ordinary end of stream. Letting the failure escape
|
|
163
164
|
* `start()` instead would reach no one the application can hear: the
|
package/src/stream.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { BaseCtx } from "@tetsujs/core";
|
|
28
|
+
import { reportFailure } from "@tetsujs/core";
|
|
28
29
|
|
|
29
30
|
/** How a stream ended. */
|
|
30
31
|
export type StreamReason =
|
|
@@ -113,7 +114,8 @@ export interface StreamOptions {
|
|
|
113
114
|
* generator keeps yielding, or if it waits on the signal.** A generator
|
|
114
115
|
* that does neither leaks, and no amount of care out here can collect it.
|
|
115
116
|
*
|
|
116
|
-
* A generator that fails instead of ending is
|
|
117
|
+
* A generator that fails instead of ending is reported — to the
|
|
118
|
+
* application's `reportError`, with `source: "stream"` — and the stream is
|
|
117
119
|
* closed where it stood, so what already went out stays valid and the
|
|
118
120
|
* client sees an ordinary end of stream. Letting the failure escape
|
|
119
121
|
* instead would reach no one the application can hear: the platform prints
|
|
@@ -172,9 +174,9 @@ export function stream(
|
|
|
172
174
|
/**
|
|
173
175
|
* The response left long ago, so there is nothing to map this to and
|
|
174
176
|
* nobody to answer — the same reason the generator's own failure is
|
|
175
|
-
*
|
|
177
|
+
* reported rather than raised.
|
|
176
178
|
*/
|
|
177
|
-
|
|
179
|
+
reportFailure(ctx, "stream", error);
|
|
178
180
|
}
|
|
179
181
|
};
|
|
180
182
|
|
|
@@ -273,7 +275,7 @@ export function stream(
|
|
|
273
275
|
|
|
274
276
|
emit(controller, next.value);
|
|
275
277
|
} catch (error) {
|
|
276
|
-
|
|
278
|
+
reportFailure(ctx, "stream", error);
|
|
277
279
|
|
|
278
280
|
done("failed");
|
|
279
281
|
close(controller);
|