@orkestrel/sse 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orkestrel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @orkestrel/sse
2
+
3
+ A typed Server-Sent Events parser — incremental, spec-compliant parsing of
4
+ event-stream chunks into typed events with `data`, `event`, `id`, and `retry`
5
+ fields. Feed it string chunks as they arrive; a blank line dispatches the
6
+ accumulated event, and a partial line or in-progress event split across
7
+ chunk boundaries is buffered until the rest arrives. The `id` / `retry`
8
+ fields also persist as sticky connection state — surfaced via the `id` /
9
+ `retry` getters for reconnection — and an optional `limit` bounds total
10
+ buffered characters. A pure functional primitive — no Emitter, no events, no
11
+ server / HTTP / agent coupling; it never throws on malformed input, only a
12
+ typed `SSEError('OVERFLOW')` when a configured `limit` is exceeded. Part of
13
+ the `@orkestrel` line.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install @orkestrel/sse
19
+ ```
20
+
21
+ ## Requirements
22
+
23
+ - Node.js >= 24
24
+ - ESM + CJS (dual-format build)
25
+ - No runtime dependencies
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ import { createSSEParser, isSSEError } from '@orkestrel/sse'
31
+
32
+ const parser = createSSEParser({ limit: 1_000_000 })
33
+ parser.parse('data: a\ndata: b\n\n') // [{ data: 'a\nb' }] - the two data lines joined
34
+ parser.parse('event: ping\ndata: 1') // [] - buffered until its blank line
35
+ parser.parse('\n\n') // [{ data: '1', event: 'ping' }]
36
+
37
+ parser.id // '1' - sticky last-event-id, survives dispatch
38
+ parser.retry // undefined - sticky reconnection time, until a retry: field arrives
39
+
40
+ try {
41
+ parser.parse('x'.repeat(2_000_000))
42
+ } catch (error) {
43
+ if (isSSEError(error) && error.code === 'OVERFLOW') parser.reset()
44
+ }
45
+
46
+ parser.flush() // force out a trailing unterminated event at end-of-stream
47
+ parser.reset() // full reset - drops buffered state and sticky id/retry
48
+ ```
49
+
50
+ Pair it with a `TextDecoder({ stream: true })` when reading a byte stream so
51
+ multi-byte UTF-8 characters split across reads are handled — the decoder
52
+ handles partial characters, this parser handles partial lines and events.
53
+
54
+ The optional `limit` option caps total buffered characters; when set, a
55
+ `parse(chunk)` call that would exceed it throws a typed `SSEError('OVERFLOW')`
56
+ instead of growing unbounded, leaving parser state unchanged. Without
57
+ `flush()`, a stream that ends without a final blank line has its last event
58
+ discarded per spec — call `flush()` at end-of-stream to force it out.
59
+
60
+ ## Guide
61
+
62
+ For the full surface — the `SSEParser` class, its `SSEEvent` shape, the wire
63
+ format it implements, and the `createSSEParser` factory — see
64
+ [`guides/src/sse.md`](guides/src/sse.md).
65
+
66
+ ## Package
67
+
68
+ Published as a single typed entry point per the `exports` field in
69
+ `package.json`.
70
+
71
+ ## License
72
+
73
+ MIT © [Orkestrel](https://github.com/orkestrel) — see [LICENSE](./LICENSE).
@@ -0,0 +1,304 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/core/constants.ts
3
+ /**
4
+ * The NUL byte (`U+0000`). The SSE spec voids an `id:` field whose value contains
5
+ * it, so an `id` carrying a NUL is never surfaced. Spelled as a codepoint so the
6
+ * wire content is unambiguous in source.
7
+ */
8
+ var NUL = String.fromCharCode(0);
9
+ /**
10
+ * The byte-order mark (`U+FEFF`), stripped from the very first chunk of an SSE
11
+ * stream (a leading BOM on later chunks is ordinary content). Spelled as a
12
+ * codepoint so the wire content is unambiguous in source.
13
+ */
14
+ var BOM = String.fromCharCode(65279);
15
+ //#endregion
16
+ //#region src/core/errors.ts
17
+ /**
18
+ * An error thrown by the SSE parser.
19
+ *
20
+ * @remarks
21
+ * Thrown for: a `parse(chunk)` call whose resulting buffered total (un-consumed
22
+ * line buffer + accumulated per-event field lengths + the incoming chunk) would
23
+ * exceed a configured {@link import('./types.js').SSEParserOptions.limit}
24
+ * (`OVERFLOW`). The parser's state is left UNCHANGED by the throwing call - the
25
+ * chunk is not appended - so a consumer may `reset()` and continue. `context`
26
+ * carries at least `{ limit, size }`: the configured limit and the size the
27
+ * buffer would have reached.
28
+ */
29
+ var SSEError = class extends Error {
30
+ code;
31
+ context;
32
+ constructor(code, message, context) {
33
+ super(message);
34
+ this.name = "SSEError";
35
+ this.code = code;
36
+ this.context = context;
37
+ }
38
+ };
39
+ /**
40
+ * Narrow an unknown caught value to an {@link SSEError}.
41
+ *
42
+ * @param value - The value to test (typically a `catch` binding)
43
+ * @returns `true` when `value` is an {@link SSEError}
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { isSSEError } from '@src/core'
48
+ *
49
+ * try {
50
+ * parser.parse(chunk)
51
+ * } catch (error) {
52
+ * if (isSSEError(error) && error.code === 'OVERFLOW') parser.reset()
53
+ * }
54
+ * ```
55
+ */
56
+ function isSSEError(value) {
57
+ return value instanceof SSEError;
58
+ }
59
+ //#endregion
60
+ //#region src/core/SSEParser.ts
61
+ /**
62
+ * A stateful Server-Sent-Events (SSE) stream parser - feed it string chunks, get
63
+ * back the complete events dispatched so far.
64
+ *
65
+ * @remarks
66
+ * - **The wire format.** SSE is a UTF-8 text stream of events separated by a blank
67
+ * line. Within an event each line is a `field: value` (one optional space after the
68
+ * colon is stripped; a line with no colon is a field with an empty value; a line
69
+ * STARTING with a colon is a comment and is ignored). The fields: `data` is appended
70
+ * to the event's data buffer - MULTIPLE `data:` lines concatenate with `\n` between
71
+ * them (`data: a` + `data: b` → `"a\nb"`), with no trailing newline; `event` sets the
72
+ * event type (last wins); `id` sets the last-event-id (last wins; an `id` containing a
73
+ * NUL is ignored per spec); `retry` sets the reconnection time in ms (integer only -
74
+ * a non-integer is ignored). Unknown fields are ignored.
75
+ * - **Blank-line dispatch.** A blank line flushes the accumulated event: an
76
+ * {@link SSEEvent} is emitted ONLY when the data buffer is non-empty (a dispatch with
77
+ * an empty data buffer emits nothing - a comment-only or field-only "event" produces
78
+ * no event), and the data buffer + event type reset for the next event afterwards.
79
+ * `id` / `retry` ride on the emitted event for the consumer to track per-event - the
80
+ * emitted event's shape is unchanged - AND are separately persisted as connection
81
+ * state, exposed via the sticky `id` / `retry` getters (see below).
82
+ * - **Sticky connection state.** Each valid `id:` field updates a persisted
83
+ * last-event-id, exposed via the `id` getter; each valid `retry:` field updates a
84
+ * persisted reconnection time, exposed via the `retry` getter. Neither is cleared
85
+ * when an event dispatches - only `reset()` clears them - matching WHATWG
86
+ * last-event-id semantics. A NUL-voided `id` field does not alter the persisted
87
+ * value.
88
+ * - **Cross-chunk reassembly.** `parse(chunk)` appends `chunk` to an internal buffer,
89
+ * splits on the line terminator, processes every COMPLETE line, and retains the
90
+ * trailing partial line (and any in-progress event) for the next call - so an event
91
+ * split across chunk boundaries is reassembled once its blank line arrives. An
92
+ * un-terminated final event stays buffered and is never emitted until its blank
93
+ * line arrives - call `flush()` to force it out at end-of-stream instead.
94
+ * - **Line endings + BOM.** `\r\n`, `\r`, and `\n` are all valid terminators and are
95
+ * normalized; a CRLF split across two chunks is held safely (a trailing `\r` is
96
+ * retained, not flushed as a line, until its `\n` is known). A leading byte-order
97
+ * mark on the first NON-EMPTY chunk is stripped (an empty `parse('')` call before
98
+ * any content does not arm the BOM check).
99
+ * - **Bounded buffering.** Pass {@link SSEParserOptions.limit} to cap the total
100
+ * buffered characters (un-consumed line buffer + in-progress event field lengths);
101
+ * a `parse(chunk)` call that would exceed it throws an {@link SSEError} with code
102
+ * `'OVERFLOW'` and leaves parser state unchanged - the chunk is not appended.
103
+ * - **Total + event-free.** A pure functional primitive - no Emitter, no events, no
104
+ * server / HTTP / agent coupling. It never throws on malformed input (a bad `retry`
105
+ * is ignored, not thrown) - it throws only an {@link SSEError} when a configured
106
+ * `limit` is exceeded. Testable with plain strings. Pair it with a
107
+ * `TextDecoder({ stream: true })` when reading a byte stream so multi-byte UTF-8
108
+ * characters split across reads are handled (the decoder handles partial CHARS, this
109
+ * parser handles partial LINES + events).
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const parser = new SSEParser({ limit: 1_000_000 })
114
+ * parser.parse('data: a\ndata: b\n\n') // [{ data: 'a\nb' }] - the two data lines joined
115
+ * parser.parse('event: ping\ndata: 1') // [] - the event is buffered until its blank line
116
+ * parser.parse('\n\n') // [{ data: '1', event: 'ping' }]
117
+ * parser.id // undefined - no `id:` field has been seen
118
+ * ```
119
+ */
120
+ var SSEParser = class {
121
+ #limit;
122
+ #buffer = "";
123
+ #offset = 0;
124
+ #started = false;
125
+ #carriage = false;
126
+ #data = [];
127
+ #event = void 0;
128
+ #id = void 0;
129
+ #retry = void 0;
130
+ #lastId = void 0;
131
+ #lastRetry = void 0;
132
+ constructor(options) {
133
+ this.#limit = options?.limit;
134
+ }
135
+ get id() {
136
+ return this.#lastId;
137
+ }
138
+ get retry() {
139
+ return this.#lastRetry;
140
+ }
141
+ parse(chunk) {
142
+ if (this.#limit !== void 0) {
143
+ const size = this.#size() + chunk.length;
144
+ if (size > this.#limit) throw new SSEError("OVERFLOW", `SSE parser buffer would exceed the configured limit of ${this.#limit} characters`, {
145
+ limit: this.#limit,
146
+ size
147
+ });
148
+ }
149
+ if (!this.#started && chunk.length > 0) {
150
+ this.#started = true;
151
+ if (chunk.startsWith("")) chunk = chunk.slice(1);
152
+ }
153
+ this.#buffer += chunk;
154
+ const events = [];
155
+ for (;;) {
156
+ const line = this.#take();
157
+ if (line === void 0) break;
158
+ this.#process(line, events);
159
+ }
160
+ if (this.#offset > 0) {
161
+ this.#buffer = this.#buffer.slice(this.#offset);
162
+ this.#offset = 0;
163
+ }
164
+ return events;
165
+ }
166
+ flush() {
167
+ const events = [];
168
+ if (this.#buffer.length > 0) {
169
+ const line = this.#buffer;
170
+ this.#buffer = "";
171
+ this.#offset = 0;
172
+ this.#process(line, events);
173
+ }
174
+ this.#dispatch(events);
175
+ return events;
176
+ }
177
+ reset() {
178
+ this.#buffer = "";
179
+ this.#offset = 0;
180
+ this.#started = false;
181
+ this.#carriage = false;
182
+ this.#clear();
183
+ this.#lastId = void 0;
184
+ this.#lastRetry = void 0;
185
+ }
186
+ #size() {
187
+ let dataTotal = 0;
188
+ for (const segment of this.#data) dataTotal += segment.length;
189
+ return this.#buffer.length + dataTotal + (this.#event?.length ?? 0) + (this.#id?.length ?? 0);
190
+ }
191
+ #take() {
192
+ if (this.#carriage && this.#offset < this.#buffer.length) {
193
+ if (this.#buffer[this.#offset] === "\n") this.#offset += 1;
194
+ this.#carriage = false;
195
+ }
196
+ const newline = this.#buffer.indexOf("\n", this.#offset);
197
+ const carriage = this.#buffer.indexOf("\r", this.#offset);
198
+ if (newline === -1 && carriage === -1) return void 0;
199
+ if (carriage !== -1 && (newline === -1 || carriage < newline)) {
200
+ this.#carriage = true;
201
+ const line = this.#buffer.slice(this.#offset, carriage);
202
+ this.#offset = carriage + 1;
203
+ return line;
204
+ }
205
+ const line = this.#buffer.slice(this.#offset, newline);
206
+ this.#offset = newline + 1;
207
+ return line;
208
+ }
209
+ #process(line, events) {
210
+ if (line.length === 0) {
211
+ this.#dispatch(events);
212
+ return;
213
+ }
214
+ if (line.startsWith(":")) return;
215
+ const colon = line.indexOf(":");
216
+ const field = colon === -1 ? line : line.slice(0, colon);
217
+ let value = colon === -1 ? "" : line.slice(colon + 1);
218
+ if (value.startsWith(" ")) value = value.slice(1);
219
+ this.#field(field, value);
220
+ }
221
+ #field(field, value) {
222
+ if (field === "data") this.#data.push(value);
223
+ else if (field === "event") this.#event = value;
224
+ else if (field === "id") {
225
+ if (!value.includes("\0")) {
226
+ this.#id = value;
227
+ this.#lastId = value;
228
+ }
229
+ } else if (field === "retry") {
230
+ if (/^\d+$/.test(value)) {
231
+ this.#retry = Number(value);
232
+ this.#lastRetry = this.#retry;
233
+ }
234
+ }
235
+ }
236
+ #dispatch(events) {
237
+ if (this.#data.length > 0) {
238
+ const event = {
239
+ data: this.#data.join("\n"),
240
+ ...this.#event !== void 0 ? { event: this.#event } : {},
241
+ ...this.#id !== void 0 ? { id: this.#id } : {},
242
+ ...this.#retry !== void 0 ? { retry: this.#retry } : {}
243
+ };
244
+ events.push(event);
245
+ }
246
+ this.#clear();
247
+ }
248
+ #clear() {
249
+ this.#data = [];
250
+ this.#event = void 0;
251
+ this.#id = void 0;
252
+ this.#retry = void 0;
253
+ }
254
+ };
255
+ //#endregion
256
+ //#region src/core/factories.ts
257
+ /**
258
+ * Create a Server-Sent-Events (SSE) stream parser - a stateful handle that turns
259
+ * string chunks into the complete events dispatched so far.
260
+ *
261
+ * @param options - See {@link SSEParserOptions}.
262
+ * @remarks
263
+ * `options.limit` caps the total buffered characters (un-consumed line buffer plus
264
+ * the in-progress event's field lengths); unset → unbounded, the default. Feed
265
+ * chunks to `parse(chunk)` as they arrive; each blank line DISPATCHES an event whose
266
+ * `data` is its `data:` fields joined by `\n` (plus the last `event:` / `id:` /
267
+ * `retry:`), and an in-progress event split across chunk boundaries is buffered until
268
+ * its blank line arrives (or `flush()` forces it out at end-of-stream). The `id` /
269
+ * `retry` getters expose the persisted last-event-id / reconnection time, which
270
+ * survive dispatch and only clear on `reset()`. Generic and event-free - no
271
+ * server / agent coupling; never throws on malformed input, only on a configured
272
+ * `limit` being exceeded (an {@link import('./errors.js').SSEError} with code
273
+ * `'OVERFLOW'`). Pair it with a `TextDecoder({ stream: true })` to also handle
274
+ * multi-byte UTF-8 characters split across byte reads.
275
+ *
276
+ * @returns A working {@link SSEParserInterface}
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * import { createSSEParser, isSSEError } from '@src/core'
281
+ *
282
+ * const parser = createSSEParser({ limit: 1_000_000 })
283
+ * parser.parse('data: a\ndata: b\n\n') // [{ data: 'a\nb' }] - the two data lines joined
284
+ * parser.parse('event: ping\ndata: 1') // [] - buffered until its blank line
285
+ * parser.parse('\n\n') // [{ data: '1', event: 'ping' }]
286
+ * try {
287
+ * parser.parse('x'.repeat(2_000_000))
288
+ * } catch (error) {
289
+ * if (isSSEError(error) && error.code === 'OVERFLOW') parser.reset()
290
+ * }
291
+ * ```
292
+ */
293
+ function createSSEParser(options) {
294
+ return new SSEParser(options);
295
+ }
296
+ //#endregion
297
+ exports.BOM = BOM;
298
+ exports.NUL = NUL;
299
+ exports.SSEError = SSEError;
300
+ exports.SSEParser = SSEParser;
301
+ exports.createSSEParser = createSSEParser;
302
+ exports.isSSEError = isSSEError;
303
+
304
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["#limit","#lastId","#lastRetry","#size","#started","#buffer","#take","#process","#offset","#dispatch","#carriage","#clear","#data","#event","#id","#field","#retry"],"sources":["../../../src/core/constants.ts","../../../src/core/errors.ts","../../../src/core/SSEParser.ts","../../../src/core/factories.ts"],"sourcesContent":["/**\n * The NUL byte (`U+0000`). The SSE spec voids an `id:` field whose value contains\n * it, so an `id` carrying a NUL is never surfaced. Spelled as a codepoint so the\n * wire content is unambiguous in source.\n */\nexport const NUL = String.fromCharCode(0)\n\n/**\n * The byte-order mark (`U+FEFF`), stripped from the very first chunk of an SSE\n * stream (a leading BOM on later chunks is ordinary content). Spelled as a\n * codepoint so the wire content is unambiguous in source.\n */\nexport const BOM = String.fromCharCode(0xfeff)\n","import type { SSEErrorCode } from './types.js'\n\n// AGENTS §12: a configured `limit` exceeded by a `parse(chunk)` call `throw`s an\n// `SSEError` carrying a machine-readable `code`, so a `catch` branches on `error.code`\n// instead of parsing the message. Every other malformed-input case (a bad `retry`, a\n// NUL-voided `id`, an unknown field) is ignored per the WHATWG SSE algorithm and never\n// throws.\n\n/**\n * An error thrown by the SSE parser.\n *\n * @remarks\n * Thrown for: a `parse(chunk)` call whose resulting buffered total (un-consumed\n * line buffer + accumulated per-event field lengths + the incoming chunk) would\n * exceed a configured {@link import('./types.js').SSEParserOptions.limit}\n * (`OVERFLOW`). The parser's state is left UNCHANGED by the throwing call - the\n * chunk is not appended - so a consumer may `reset()` and continue. `context`\n * carries at least `{ limit, size }`: the configured limit and the size the\n * buffer would have reached.\n */\nexport class SSEError extends Error {\n\treadonly code: SSEErrorCode\n\treadonly context?: Readonly<Record<string, unknown>>\n\n\tconstructor(code: SSEErrorCode, message: string, context?: Readonly<Record<string, unknown>>) {\n\t\tsuper(message)\n\t\tthis.name = 'SSEError'\n\t\tthis.code = code\n\t\tthis.context = context\n\t}\n}\n\n/**\n * Narrow an unknown caught value to an {@link SSEError}.\n *\n * @param value - The value to test (typically a `catch` binding)\n * @returns `true` when `value` is an {@link SSEError}\n *\n * @example\n * ```ts\n * import { isSSEError } from '@src/core'\n *\n * try {\n * \tparser.parse(chunk)\n * } catch (error) {\n * \tif (isSSEError(error) && error.code === 'OVERFLOW') parser.reset()\n * }\n * ```\n */\nexport function isSSEError(value: unknown): value is SSEError {\n\treturn value instanceof SSEError\n}\n","import type { SSEEvent, SSEParserInterface, SSEParserOptions } from './types.js'\nimport { BOM, NUL } from './constants.js'\nimport { SSEError } from './errors.js'\n\n/**\n * A stateful Server-Sent-Events (SSE) stream parser - feed it string chunks, get\n * back the complete events dispatched so far.\n *\n * @remarks\n * - **The wire format.** SSE is a UTF-8 text stream of events separated by a blank\n * line. Within an event each line is a `field: value` (one optional space after the\n * colon is stripped; a line with no colon is a field with an empty value; a line\n * STARTING with a colon is a comment and is ignored). The fields: `data` is appended\n * to the event's data buffer - MULTIPLE `data:` lines concatenate with `\\n` between\n * them (`data: a` + `data: b` → `\"a\\nb\"`), with no trailing newline; `event` sets the\n * event type (last wins); `id` sets the last-event-id (last wins; an `id` containing a\n * NUL is ignored per spec); `retry` sets the reconnection time in ms (integer only -\n * a non-integer is ignored). Unknown fields are ignored.\n * - **Blank-line dispatch.** A blank line flushes the accumulated event: an\n * {@link SSEEvent} is emitted ONLY when the data buffer is non-empty (a dispatch with\n * an empty data buffer emits nothing - a comment-only or field-only \"event\" produces\n * no event), and the data buffer + event type reset for the next event afterwards.\n * `id` / `retry` ride on the emitted event for the consumer to track per-event - the\n * emitted event's shape is unchanged - AND are separately persisted as connection\n * state, exposed via the sticky `id` / `retry` getters (see below).\n * - **Sticky connection state.** Each valid `id:` field updates a persisted\n * last-event-id, exposed via the `id` getter; each valid `retry:` field updates a\n * persisted reconnection time, exposed via the `retry` getter. Neither is cleared\n * when an event dispatches - only `reset()` clears them - matching WHATWG\n * last-event-id semantics. A NUL-voided `id` field does not alter the persisted\n * value.\n * - **Cross-chunk reassembly.** `parse(chunk)` appends `chunk` to an internal buffer,\n * splits on the line terminator, processes every COMPLETE line, and retains the\n * trailing partial line (and any in-progress event) for the next call - so an event\n * split across chunk boundaries is reassembled once its blank line arrives. An\n * un-terminated final event stays buffered and is never emitted until its blank\n * line arrives - call `flush()` to force it out at end-of-stream instead.\n * - **Line endings + BOM.** `\\r\\n`, `\\r`, and `\\n` are all valid terminators and are\n * normalized; a CRLF split across two chunks is held safely (a trailing `\\r` is\n * retained, not flushed as a line, until its `\\n` is known). A leading byte-order\n * mark on the first NON-EMPTY chunk is stripped (an empty `parse('')` call before\n * any content does not arm the BOM check).\n * - **Bounded buffering.** Pass {@link SSEParserOptions.limit} to cap the total\n * buffered characters (un-consumed line buffer + in-progress event field lengths);\n * a `parse(chunk)` call that would exceed it throws an {@link SSEError} with code\n * `'OVERFLOW'` and leaves parser state unchanged - the chunk is not appended.\n * - **Total + event-free.** A pure functional primitive - no Emitter, no events, no\n * server / HTTP / agent coupling. It never throws on malformed input (a bad `retry`\n * is ignored, not thrown) - it throws only an {@link SSEError} when a configured\n * `limit` is exceeded. Testable with plain strings. Pair it with a\n * `TextDecoder({ stream: true })` when reading a byte stream so multi-byte UTF-8\n * characters split across reads are handled (the decoder handles partial CHARS, this\n * parser handles partial LINES + events).\n *\n * @example\n * ```ts\n * const parser = new SSEParser({ limit: 1_000_000 })\n * parser.parse('data: a\\ndata: b\\n\\n') // [{ data: 'a\\nb' }] - the two data lines joined\n * parser.parse('event: ping\\ndata: 1') // [] - the event is buffered until its blank line\n * parser.parse('\\n\\n') // [{ data: '1', event: 'ping' }]\n * parser.id // undefined - no `id:` field has been seen\n * ```\n */\nexport class SSEParser implements SSEParserInterface {\n\treadonly #limit: number | undefined\n\t#buffer = ''\n\t// Read cursor into `#buffer` for the current `parse()` call - lines are consumed\n\t// by advancing this offset, never by front-slicing `#buffer` per line. The\n\t// invariant `#offset === 0` holds BETWEEN calls: `parse()` compacts `#buffer` to\n\t// its unconsumed tail (and resets `#offset` to 0) once, at the end of the call.\n\t#offset = 0\n\t#started = false\n\t// True when the last consumed line was terminated by a `\\r`: a `\\n` arriving next\n\t// (the second half of a CRLF split across chunks) is then swallowed, not read as a\n\t// fresh blank line. This lets a bare-`\\r` terminator flush immediately AND keeps a\n\t// CRLF straddling a chunk boundary as one terminator.\n\t#carriage = false\n\t// The in-progress event accumulator, reset after each blank-line dispatch.\n\t#data: string[] = []\n\t#event: string | undefined = undefined\n\t#id: string | undefined = undefined\n\t#retry: number | undefined = undefined\n\t// Persisted connection state (WHATWG last-event-id / reconnection time) - updated\n\t// by every valid `id:` / `retry:` field, never cleared on dispatch; only `reset()`\n\t// clears them.\n\t#lastId: string | undefined = undefined\n\t#lastRetry: number | undefined = undefined\n\n\tconstructor(options?: SSEParserOptions) {\n\t\tthis.#limit = options?.limit\n\t}\n\n\tget id(): string | undefined {\n\t\treturn this.#lastId\n\t}\n\n\tget retry(): number | undefined {\n\t\treturn this.#lastRetry\n\t}\n\n\tparse(chunk: string): readonly SSEEvent[] {\n\t\tif (this.#limit !== undefined) {\n\t\t\tconst size = this.#size() + chunk.length\n\t\t\tif (size > this.#limit) {\n\t\t\t\tthrow new SSEError(\n\t\t\t\t\t'OVERFLOW',\n\t\t\t\t\t`SSE parser buffer would exceed the configured limit of ${this.#limit} characters`,\n\t\t\t\t\t{ limit: this.#limit, size },\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\t// Strip a leading byte-order mark on the first NON-EMPTY chunk only - an empty\n\t\t// `parse('')` before any content must not disarm the check.\n\t\tif (!this.#started && chunk.length > 0) {\n\t\t\tthis.#started = true\n\t\t\tif (chunk.startsWith(BOM)) chunk = chunk.slice(BOM.length)\n\t\t}\n\t\tthis.#buffer += chunk\n\t\tconst events: SSEEvent[] = []\n\t\t// Pull every COMPLETE line off the buffer by advancing the read cursor, leaving\n\t\t// the trailing partial line buffered (unconsumed) for the next call.\n\t\tfor (;;) {\n\t\t\tconst line = this.#take()\n\t\t\tif (line === undefined) break\n\t\t\tthis.#process(line, events)\n\t\t}\n\t\t// Compact once per call: drop the consumed prefix and reset the cursor.\n\t\tif (this.#offset > 0) {\n\t\t\tthis.#buffer = this.#buffer.slice(this.#offset)\n\t\t\tthis.#offset = 0\n\t\t}\n\t\treturn events\n\t}\n\n\tflush(): SSEEvent[] {\n\t\tconst events: SSEEvent[] = []\n\t\tif (this.#buffer.length > 0) {\n\t\t\tconst line = this.#buffer\n\t\t\tthis.#buffer = ''\n\t\t\tthis.#offset = 0\n\t\t\tthis.#process(line, events)\n\t\t}\n\t\tthis.#dispatch(events)\n\t\treturn events\n\t}\n\n\treset(): void {\n\t\tthis.#buffer = ''\n\t\tthis.#offset = 0\n\t\tthis.#started = false\n\t\tthis.#carriage = false\n\t\tthis.#clear()\n\t\tthis.#lastId = undefined\n\t\tthis.#lastRetry = undefined\n\t}\n\n\t// Total characters currently buffered: the un-consumed line buffer (always\n\t// compacted to offset 0 between calls) plus the in-progress event's accumulated\n\t// field lengths (every `data:` segment + the pending `event` + the pending `id`).\n\t#size(): number {\n\t\tlet dataTotal = 0\n\t\tfor (const segment of this.#data) dataTotal += segment.length\n\t\treturn this.#buffer.length + dataTotal + (this.#event?.length ?? 0) + (this.#id?.length ?? 0)\n\t}\n\n\t// Take the next complete line off the buffer, or `undefined` when only a partial\n\t// line remains. A line ends at `\\n`, a `\\r`, or a `\\r\\n`. A `\\r` flushes its line\n\t// immediately (bare-`\\r` is a valid terminator) and arms {@link #carriage}; a `\\n`\n\t// directly following a consumed `\\r` is the CRLF's second half and is swallowed.\n\t#take(): string | undefined {\n\t\t// Swallow a `\\n` left over from a `\\r\\n` whose `\\r` already terminated a line -\n\t\t// only when a byte is present to inspect, so the flag survives an empty buffer\n\t\t// between calls (a `\\r` ends one chunk, its `\\n` opens the next).\n\t\tif (this.#carriage && this.#offset < this.#buffer.length) {\n\t\t\tif (this.#buffer[this.#offset] === '\\n') this.#offset += 1\n\t\t\tthis.#carriage = false\n\t\t}\n\t\tconst newline = this.#buffer.indexOf('\\n', this.#offset)\n\t\tconst carriage = this.#buffer.indexOf('\\r', this.#offset)\n\t\t// No terminator at all → the rest of the buffer is a partial line.\n\t\tif (newline === -1 && carriage === -1) return undefined\n\t\t// Pick the earliest terminator; a leading `\\r` flushes its line and arms the\n\t\t// carriage flag so the matching `\\n` (this chunk or the next) is swallowed.\n\t\tif (carriage !== -1 && (newline === -1 || carriage < newline)) {\n\t\t\tthis.#carriage = true\n\t\t\tconst line = this.#buffer.slice(this.#offset, carriage)\n\t\t\tthis.#offset = carriage + 1\n\t\t\treturn line\n\t\t}\n\t\tconst line = this.#buffer.slice(this.#offset, newline)\n\t\tthis.#offset = newline + 1\n\t\treturn line\n\t}\n\n\t// Process one complete line: a blank line dispatches the accumulated event; a\n\t// comment (a line starting with `:`) is ignored; otherwise it is a `field: value`.\n\t#process(line: string, events: SSEEvent[]): void {\n\t\tif (line.length === 0) {\n\t\t\tthis.#dispatch(events)\n\t\t\treturn\n\t\t}\n\t\tif (line.startsWith(':')) return // comment line - ignored\n\t\tconst colon = line.indexOf(':')\n\t\t// No colon → the whole line is the field name, value is empty (per spec).\n\t\tconst field = colon === -1 ? line : line.slice(0, colon)\n\t\t// One optional leading space after the colon is stripped; nothing else.\n\t\tlet value = colon === -1 ? '' : line.slice(colon + 1)\n\t\tif (value.startsWith(' ')) value = value.slice(1)\n\t\tthis.#field(field, value)\n\t}\n\n\t// Apply one parsed field to the in-progress event (and the persisted connection\n\t// state for `id` / `retry`). Unknown fields are ignored.\n\t#field(field: string, value: string): void {\n\t\tif (field === 'data') this.#data.push(value)\n\t\telse if (field === 'event') this.#event = value\n\t\telse if (field === 'id') {\n\t\t\t// An `id` containing a NUL is voided per spec - never surfaced, and the\n\t\t\t// persisted last-event-id is left unchanged.\n\t\t\tif (!value.includes(NUL)) {\n\t\t\t\tthis.#id = value\n\t\t\t\tthis.#lastId = value\n\t\t\t}\n\t\t} else if (field === 'retry') {\n\t\t\t// Integer-only; a non-integer reconnection time is ignored (never throws).\n\t\t\tif (/^\\d+$/.test(value)) {\n\t\t\t\tthis.#retry = Number(value)\n\t\t\t\tthis.#lastRetry = this.#retry\n\t\t\t}\n\t\t}\n\t}\n\n\t// Dispatch the accumulated event on a blank line (or on `flush()`): emit ONLY when\n\t// data was buffered (the spec's empty-data rule), then reset the accumulator for\n\t// the next event. Persisted `id` / `retry` state is untouched.\n\t#dispatch(events: SSEEvent[]): void {\n\t\tif (this.#data.length > 0) {\n\t\t\tconst event: SSEEvent = {\n\t\t\t\tdata: this.#data.join('\\n'),\n\t\t\t\t...(this.#event !== undefined ? { event: this.#event } : {}),\n\t\t\t\t...(this.#id !== undefined ? { id: this.#id } : {}),\n\t\t\t\t...(this.#retry !== undefined ? { retry: this.#retry } : {}),\n\t\t\t}\n\t\t\tevents.push(event)\n\t\t}\n\t\tthis.#clear()\n\t}\n\n\t// Reset the in-progress event accumulator (data buffer + last-seen fields).\n\t#clear(): void {\n\t\tthis.#data = []\n\t\tthis.#event = undefined\n\t\tthis.#id = undefined\n\t\tthis.#retry = undefined\n\t}\n}\n","import type { SSEParserInterface, SSEParserOptions } from './types.js'\nimport { SSEParser } from './SSEParser.js'\n\n/**\n * Create a Server-Sent-Events (SSE) stream parser - a stateful handle that turns\n * string chunks into the complete events dispatched so far.\n *\n * @param options - See {@link SSEParserOptions}.\n * @remarks\n * `options.limit` caps the total buffered characters (un-consumed line buffer plus\n * the in-progress event's field lengths); unset → unbounded, the default. Feed\n * chunks to `parse(chunk)` as they arrive; each blank line DISPATCHES an event whose\n * `data` is its `data:` fields joined by `\\n` (plus the last `event:` / `id:` /\n * `retry:`), and an in-progress event split across chunk boundaries is buffered until\n * its blank line arrives (or `flush()` forces it out at end-of-stream). The `id` /\n * `retry` getters expose the persisted last-event-id / reconnection time, which\n * survive dispatch and only clear on `reset()`. Generic and event-free - no\n * server / agent coupling; never throws on malformed input, only on a configured\n * `limit` being exceeded (an {@link import('./errors.js').SSEError} with code\n * `'OVERFLOW'`). Pair it with a `TextDecoder({ stream: true })` to also handle\n * multi-byte UTF-8 characters split across byte reads.\n *\n * @returns A working {@link SSEParserInterface}\n *\n * @example\n * ```ts\n * import { createSSEParser, isSSEError } from '@src/core'\n *\n * const parser = createSSEParser({ limit: 1_000_000 })\n * parser.parse('data: a\\ndata: b\\n\\n') // [{ data: 'a\\nb' }] - the two data lines joined\n * parser.parse('event: ping\\ndata: 1') // [] - buffered until its blank line\n * parser.parse('\\n\\n') // [{ data: '1', event: 'ping' }]\n * try {\n * \tparser.parse('x'.repeat(2_000_000))\n * } catch (error) {\n * \tif (isSSEError(error) && error.code === 'OVERFLOW') parser.reset()\n * }\n * ```\n */\nexport function createSSEParser(options?: SSEParserOptions): SSEParserInterface {\n\treturn new SSEParser(options)\n}\n"],"mappings":"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"}