@urbicon-ui/sveltekit-utils 6.38.0 → 6.40.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/README.md CHANGED
@@ -7,6 +7,7 @@ Currently shipping:
7
7
  - **URL-state runes** — reactive `useUrlParam` / `useUrlArrayParam` that keep component state in sync with `?query=` parameters
8
8
  - **Table-query URL sync** — opt-in `?q=…&sort=…&page=…` mirroring for `@urbicon-ui/table` server mode, plus the pure serializers behind it
9
9
  - **Cron runner** — interval-based background fetcher for scheduled server endpoints
10
+ - **SSE stream reader** — `streamSse`, a spec-correct async-generator client for one-shot POST `text/event-stream` endpoints (LLM relays)
10
11
 
11
12
  ## Installation
12
13
 
@@ -152,6 +153,57 @@ export const POST = async ({ request }) => {
152
153
  - Simple `setInterval`-based scheduler. No drift compensation, no distributed locking, no exponential backoff — intended for single-process SvelteKit deployments. For scale-out scenarios use a real scheduler (e.g. BullMQ) and point it at the same HTTP endpoints.
153
154
  - Header name defaults to `x-cron-secret`; override via `secretHeader`.
154
155
 
156
+ ## SSE Stream Reader (`sse`)
157
+
158
+ Read a POST endpoint that answers `text/event-stream` — the pattern where a SvelteKit API route relays an LLM (or any) stream to the browser. `streamSse` is an async generator: `for await` over it and each `data:`/`event:` frame arrives as a parsed `SseEvent`.
159
+
160
+ ```typescript
161
+ import { streamSse, SseRequestError } from '@urbicon-ui/sveltekit-utils/sse';
162
+
163
+ const controller = new AbortController();
164
+
165
+ try {
166
+ for await (const ev of streamSse('/api/chat', {
167
+ body: { messages }, // JSON-encoded, content-type set for you
168
+ signal: controller.signal
169
+ })) {
170
+ if (ev.event === 'token') appendToken(JSON.parse(ev.data).text);
171
+ else if (ev.event === 'error') throw new Error(JSON.parse(ev.data).message);
172
+ }
173
+ } catch (err) {
174
+ if (err instanceof SseRequestError) showError(err.body); // raw response body
175
+ else if ((err as Error).name !== 'AbortError') throw err;
176
+ }
177
+
178
+ // Cancelling the stream closes the HTTP connection:
179
+ controller.abort();
180
+ ```
181
+
182
+ Emit the matching frames from the endpoint:
183
+
184
+ ```typescript
185
+ // src/routes/api/chat/+server.ts
186
+ export const POST = async ({ request }) => {
187
+ const stream = new ReadableStream({
188
+ async start(controller) {
189
+ const enc = new TextEncoder();
190
+ const send = (event: string, data: unknown) =>
191
+ controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
192
+ for await (const token of runModel(await request.json())) send('token', { text: token });
193
+ controller.close();
194
+ }
195
+ });
196
+ return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
197
+ };
198
+ ```
199
+
200
+ **Design notes**
201
+
202
+ - **Chunk-decomposition-invariant** — the emitted event sequence is identical no matter how the byte stream splits into network chunks, including a split inside a CRLF pair or in the middle of a multi-byte UTF-8 character. Implements the core of the WHATWG SSE parser: `\r\n`/`\n`/`\r` terminators, multi-`data:` join with `\n`, one-leading-space stripping, `:`-comment lines, `id` persistence (NUL-poisoned ids ignored), leading-BOM strip, no dispatch without a `data` line or a final blank line.
203
+ - **Not an `EventSource`** — it POSTs a body and takes an injectable `fetch` (pass SvelteKit's `load` fetch to stream during SSR). It deliberately does **not** reconnect; `retry:` and unknown fields are parsed and ignored, and a dropped connection surfaces as the underlying `fetch`/read error.
204
+ - **Fail loud** — a non-2xx status, or a 2xx response with no body, throws `SseRequestError` carrying the `status` and a best-effort raw `body`. An abort propagates as an `AbortError` rather than ending the loop silently.
205
+ - **Body shaping** — a string body is sent verbatim (no forced content-type); any other value is JSON-stringified with `content-type: application/json`. `accept: text/event-stream` is always sent; caller `headers` override both defaults.
206
+
155
207
  ## Exports
156
208
 
157
209
  | Subpath | Contents |
@@ -160,6 +212,7 @@ export const POST = async ({ request }) => {
160
212
  | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `createTableQueryUrlSync`, types |
161
213
  | `./table-query` | `tableQueryToSearchParams`, `searchParamsToTableQuery`, `applyTableQueryToSearchParams`, `TableQueryParams`, types |
162
214
  | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
215
+ | `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
163
216
 
164
217
  ## Development
165
218
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './cron';
2
+ export * from './sse';
2
3
  export * from './table-query';
3
4
  export * from './url.svelte';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './cron';
2
+ export * from './sse';
2
3
  export * from './table-query';
3
4
  export * from './url.svelte';
package/dist/sse.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Zero-dependency Server-Sent Events reader for **one-shot POST streams** — the
3
+ * pattern where a SvelteKit API route relays an LLM (or any) stream to the
4
+ * browser as `text/event-stream`. It replaces the hand-rolled
5
+ * `fetch` + frame-splitting loop that grows around every such endpoint.
6
+ *
7
+ * This is deliberately **not** an `EventSource` replacement:
8
+ * - It POSTs a body (EventSource is GET-only) and takes an injectable `fetch`,
9
+ * so it also runs inside a SvelteKit `load`.
10
+ * - It does **not** reconnect. The `retry:` field (and any unknown field) is
11
+ * parsed and ignored — a dropped connection surfaces as the underlying
12
+ * `fetch`/read error, which the consumer sees and decides what to do about.
13
+ *
14
+ * The parser implements the core of the WHATWG SSE stream-interpretation
15
+ * algorithm and is **chunk-decomposition-invariant**: the emitted event
16
+ * sequence is identical no matter how the byte stream is split into chunks —
17
+ * including a split inside a CRLF pair or in the middle of a multi-byte UTF-8
18
+ * character (a `TextDecoder` with `stream: true` coalesces the latter).
19
+ *
20
+ * Supported line terminators: `\r\n`, `\n`, and a lone `\r` (a `\r` followed by
21
+ * a non-`\n` char is its own terminator). Fields: `data` (accumulated; multiple
22
+ * `data:` lines join with `\n`), `event` (event name), `id` (event id; a value
23
+ * containing a NUL is ignored per spec). Comment lines (starting with `:`) and
24
+ * `retry:`/unknown fields are ignored. An event is dispatched on a blank line
25
+ * and only if it carried at least one `data` line; a trailing buffer without a
26
+ * final blank line is not dispatched. A single leading UTF-8 BOM is stripped.
27
+ */
28
+ /** One parsed Server-Sent Event. */
29
+ export interface SseEvent {
30
+ /** Event name from the `event:` field; `'message'` when omitted. */
31
+ event: string;
32
+ /** Data payload — all `data:` lines of the event joined with `\n`. */
33
+ data: string;
34
+ /** Last `id:` field seen at dispatch time, when the stream sets one. */
35
+ id?: string;
36
+ }
37
+ /** Options for {@link streamSse}. */
38
+ export interface StreamSseOptions {
39
+ /**
40
+ * HTTP method for the request.
41
+ * @default 'POST'
42
+ */
43
+ method?: string;
44
+ /**
45
+ * Extra request headers. These win over the defaults: `accept:
46
+ * text/event-stream` is always sent, and `content-type: application/json` is
47
+ * added for a non-string {@link body} — pass either key here to override.
48
+ */
49
+ headers?: Record<string, string>;
50
+ /**
51
+ * Request body. A string is sent verbatim (no `content-type` is forced); any
52
+ * other value is JSON-stringified and sent with `content-type:
53
+ * application/json`. `undefined`/`null` sends no body.
54
+ */
55
+ body?: unknown;
56
+ /** Abort signal; aborting rejects the in-flight read with an `AbortError`. */
57
+ signal?: AbortSignal;
58
+ /**
59
+ * `fetch` implementation to use — pass SvelteKit's `load` fetch to stream
60
+ * during SSR / to inherit its request context.
61
+ * @default globalThis.fetch
62
+ */
63
+ fetch?: typeof globalThis.fetch;
64
+ }
65
+ /**
66
+ * Thrown by {@link streamSse} when the response is not usable: a non-2xx status,
67
+ * or a 2xx response with no readable body. Carries the HTTP {@link status} and a
68
+ * best-effort {@link body} text (raw — the consumer decides how to interpret it,
69
+ * e.g. extract a JSON `message`).
70
+ */
71
+ export declare class SseRequestError extends Error {
72
+ /** HTTP status of the failing response. */
73
+ readonly status: number;
74
+ /** Best-effort response body text (`''` when there was nothing to read). */
75
+ readonly body: string;
76
+ constructor(status: number, body: string, message?: string);
77
+ }
78
+ /**
79
+ * Stream a POST (or other-method) endpoint that answers `text/event-stream` and
80
+ * yield each parsed {@link SseEvent} as it is dispatched.
81
+ *
82
+ * The generator does not resolve until the server closes the stream (or it is
83
+ * aborted / the consumer `break`s). On teardown — normal completion, early
84
+ * `break`, `throw`, or abort — the underlying body reader is cancelled in a
85
+ * `finally`, so a `break` out of the `for await` closes the HTTP connection
86
+ * rather than leaking it. An abort propagates as an `AbortError`; it is not
87
+ * swallowed.
88
+ *
89
+ * @param url - Endpoint to stream from.
90
+ * @param options - Method, headers, body, abort signal, injectable `fetch`.
91
+ * @returns An async generator of {@link SseEvent}s.
92
+ * @throws {SseRequestError} when the response is non-2xx or has no body.
93
+ * @example
94
+ * ```typescript
95
+ * import { streamSse } from '@urbicon-ui/sveltekit-utils/sse';
96
+ *
97
+ * const controller = new AbortController();
98
+ * for await (const ev of streamSse('/api/chat', {
99
+ * body: { messages },
100
+ * signal: controller.signal
101
+ * })) {
102
+ * if (ev.event === 'token') appendToken(JSON.parse(ev.data).text);
103
+ * else if (ev.event === 'error') throw new Error(JSON.parse(ev.data).message);
104
+ * }
105
+ * ```
106
+ */
107
+ export declare function streamSse(url: string, options?: StreamSseOptions): AsyncGenerator<SseEvent, void, undefined>;
package/dist/sse.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Zero-dependency Server-Sent Events reader for **one-shot POST streams** — the
3
+ * pattern where a SvelteKit API route relays an LLM (or any) stream to the
4
+ * browser as `text/event-stream`. It replaces the hand-rolled
5
+ * `fetch` + frame-splitting loop that grows around every such endpoint.
6
+ *
7
+ * This is deliberately **not** an `EventSource` replacement:
8
+ * - It POSTs a body (EventSource is GET-only) and takes an injectable `fetch`,
9
+ * so it also runs inside a SvelteKit `load`.
10
+ * - It does **not** reconnect. The `retry:` field (and any unknown field) is
11
+ * parsed and ignored — a dropped connection surfaces as the underlying
12
+ * `fetch`/read error, which the consumer sees and decides what to do about.
13
+ *
14
+ * The parser implements the core of the WHATWG SSE stream-interpretation
15
+ * algorithm and is **chunk-decomposition-invariant**: the emitted event
16
+ * sequence is identical no matter how the byte stream is split into chunks —
17
+ * including a split inside a CRLF pair or in the middle of a multi-byte UTF-8
18
+ * character (a `TextDecoder` with `stream: true` coalesces the latter).
19
+ *
20
+ * Supported line terminators: `\r\n`, `\n`, and a lone `\r` (a `\r` followed by
21
+ * a non-`\n` char is its own terminator). Fields: `data` (accumulated; multiple
22
+ * `data:` lines join with `\n`), `event` (event name), `id` (event id; a value
23
+ * containing a NUL is ignored per spec). Comment lines (starting with `:`) and
24
+ * `retry:`/unknown fields are ignored. An event is dispatched on a blank line
25
+ * and only if it carried at least one `data` line; a trailing buffer without a
26
+ * final blank line is not dispatched. A single leading UTF-8 BOM is stripped.
27
+ */
28
+ /**
29
+ * Thrown by {@link streamSse} when the response is not usable: a non-2xx status,
30
+ * or a 2xx response with no readable body. Carries the HTTP {@link status} and a
31
+ * best-effort {@link body} text (raw — the consumer decides how to interpret it,
32
+ * e.g. extract a JSON `message`).
33
+ */
34
+ export class SseRequestError extends Error {
35
+ /** HTTP status of the failing response. */
36
+ status;
37
+ /** Best-effort response body text (`''` when there was nothing to read). */
38
+ body;
39
+ constructor(status, body, message) {
40
+ super(message ?? `SSE request failed with status ${status}`);
41
+ this.name = 'SseRequestError';
42
+ this.status = status;
43
+ this.body = body;
44
+ }
45
+ }
46
+ /** Build the request headers + serialized body from the options (caller headers win). */
47
+ function buildRequest(options) {
48
+ const headers = { accept: 'text/event-stream' };
49
+ let body;
50
+ const raw = options.body;
51
+ if (raw !== undefined && raw !== null) {
52
+ if (typeof raw === 'string') {
53
+ body = raw;
54
+ }
55
+ else {
56
+ body = JSON.stringify(raw);
57
+ headers['content-type'] = 'application/json';
58
+ }
59
+ }
60
+ if (options.headers)
61
+ Object.assign(headers, options.headers);
62
+ return { headers, body };
63
+ }
64
+ /**
65
+ * Stream a POST (or other-method) endpoint that answers `text/event-stream` and
66
+ * yield each parsed {@link SseEvent} as it is dispatched.
67
+ *
68
+ * The generator does not resolve until the server closes the stream (or it is
69
+ * aborted / the consumer `break`s). On teardown — normal completion, early
70
+ * `break`, `throw`, or abort — the underlying body reader is cancelled in a
71
+ * `finally`, so a `break` out of the `for await` closes the HTTP connection
72
+ * rather than leaking it. An abort propagates as an `AbortError`; it is not
73
+ * swallowed.
74
+ *
75
+ * @param url - Endpoint to stream from.
76
+ * @param options - Method, headers, body, abort signal, injectable `fetch`.
77
+ * @returns An async generator of {@link SseEvent}s.
78
+ * @throws {SseRequestError} when the response is non-2xx or has no body.
79
+ * @example
80
+ * ```typescript
81
+ * import { streamSse } from '@urbicon-ui/sveltekit-utils/sse';
82
+ *
83
+ * const controller = new AbortController();
84
+ * for await (const ev of streamSse('/api/chat', {
85
+ * body: { messages },
86
+ * signal: controller.signal
87
+ * })) {
88
+ * if (ev.event === 'token') appendToken(JSON.parse(ev.data).text);
89
+ * else if (ev.event === 'error') throw new Error(JSON.parse(ev.data).message);
90
+ * }
91
+ * ```
92
+ */
93
+ export async function* streamSse(url, options = {}) {
94
+ const doFetch = options.fetch ?? globalThis.fetch;
95
+ const { headers, body } = buildRequest(options);
96
+ const response = await doFetch(url, {
97
+ method: options.method ?? 'POST',
98
+ headers,
99
+ body,
100
+ signal: options.signal
101
+ });
102
+ if (!response.ok) {
103
+ const text = await response.text().catch(() => '');
104
+ throw new SseRequestError(response.status, text);
105
+ }
106
+ if (response.body == null) {
107
+ throw new SseRequestError(response.status, '', `SSE response had no readable body (status ${response.status})`);
108
+ }
109
+ // `ignoreBOM: true` passes a leading BOM through as U+FEFF so we strip exactly
110
+ // one ourselves (matching the SSE spec / EventSource), rather than relying on
111
+ // the decoder's implicit stripping.
112
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
113
+ const reader = response.body.getReader();
114
+ let buffer = '';
115
+ let streamStart = true;
116
+ // Event being assembled across lines.
117
+ let dataBuffer = '';
118
+ let hasData = false;
119
+ let eventType = '';
120
+ // Last event id (persists across events until reset); `undefined` = never set.
121
+ let lastEventId;
122
+ function stripLeadingBom() {
123
+ if (streamStart && buffer.length > 0) {
124
+ if (buffer.charCodeAt(0) === 0xfeff)
125
+ buffer = buffer.slice(1);
126
+ streamStart = false;
127
+ }
128
+ }
129
+ function handleLine(line) {
130
+ // Blank line → dispatch the accumulated event.
131
+ if (line === '') {
132
+ if (!hasData) {
133
+ // No data field: reset event type + data, do not dispatch (id persists).
134
+ eventType = '';
135
+ dataBuffer = '';
136
+ return undefined;
137
+ }
138
+ // Strip the single trailing '\n' accumulated after the last data line.
139
+ const data = dataBuffer.endsWith('\n') ? dataBuffer.slice(0, -1) : dataBuffer;
140
+ const event = { event: eventType === '' ? 'message' : eventType, data };
141
+ if (lastEventId !== undefined)
142
+ event.id = lastEventId;
143
+ eventType = '';
144
+ dataBuffer = '';
145
+ hasData = false;
146
+ return event;
147
+ }
148
+ // Comment line.
149
+ if (line.charCodeAt(0) === 0x3a /* : */)
150
+ return undefined;
151
+ let field;
152
+ let value;
153
+ const colon = line.indexOf(':');
154
+ if (colon === -1) {
155
+ field = line;
156
+ value = '';
157
+ }
158
+ else {
159
+ field = line.slice(0, colon);
160
+ value = line.slice(colon + 1);
161
+ // Remove a single leading space after the colon; further spaces stay.
162
+ if (value.charCodeAt(0) === 0x20 /* space */)
163
+ value = value.slice(1);
164
+ }
165
+ switch (field) {
166
+ case 'event':
167
+ eventType = value;
168
+ break;
169
+ case 'data':
170
+ dataBuffer += `${value}\n`;
171
+ hasData = true;
172
+ break;
173
+ case 'id':
174
+ // A value containing a NUL is ignored (spec); otherwise it persists.
175
+ if (!value.includes('\u0000'))
176
+ lastEventId = value;
177
+ break;
178
+ // 'retry' and any unknown field are ignored — this is a one-shot stream
179
+ // with no reconnect (see the module doc).
180
+ }
181
+ return undefined;
182
+ }
183
+ // Emit every complete line currently in `buffer`, updating `buffer` to the
184
+ // unconsumed remainder. When `ended` is false a trailing lone `\r` is held
185
+ // back, because the next chunk might begin with `\n` (a split CRLF pair).
186
+ function* consume(ended) {
187
+ let start = 0;
188
+ for (;;) {
189
+ const idxN = buffer.indexOf('\n', start);
190
+ const idxR = buffer.indexOf('\r', start);
191
+ if (idxN === -1 && idxR === -1)
192
+ break;
193
+ let term;
194
+ let isCr;
195
+ if (idxN === -1) {
196
+ term = idxR;
197
+ isCr = true;
198
+ }
199
+ else if (idxR === -1 || idxN < idxR) {
200
+ term = idxN;
201
+ isCr = false;
202
+ }
203
+ else {
204
+ term = idxR;
205
+ isCr = true;
206
+ }
207
+ // Hold a trailing CR that could be the first half of a CRLF split across
208
+ // chunk boundaries — unless the stream has ended (then it terminates).
209
+ if (isCr && term === buffer.length - 1 && !ended)
210
+ break;
211
+ const line = buffer.slice(start, term);
212
+ let next = term + 1;
213
+ if (isCr && buffer.charCodeAt(term + 1) === 0x0a /* \n */)
214
+ next = term + 2;
215
+ start = next;
216
+ const event = handleLine(line);
217
+ if (event)
218
+ yield event;
219
+ }
220
+ buffer = buffer.slice(start);
221
+ }
222
+ try {
223
+ for (;;) {
224
+ const { value, done } = await reader.read();
225
+ if (done)
226
+ break;
227
+ buffer += decoder.decode(value, { stream: true });
228
+ stripLeadingBom();
229
+ yield* consume(false);
230
+ }
231
+ // Flush any bytes the decoder is still holding, then interpret the tail. A
232
+ // held trailing `\r` now terminates its line; an incomplete final line (no
233
+ // terminator) stays in `buffer` and is discarded (spec).
234
+ buffer += decoder.decode();
235
+ stripLeadingBom();
236
+ yield* consume(true);
237
+ }
238
+ finally {
239
+ // Cancel closes the connection on an early `break`/`throw`; on a fully
240
+ // drained or already-errored (aborted) stream it is a best-effort no-op.
241
+ try {
242
+ await reader.cancel();
243
+ }
244
+ catch {
245
+ /* teardown is best-effort */
246
+ }
247
+ }
248
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@urbicon-ui/sveltekit-utils",
3
- "version": "6.38.0",
4
- "description": "SvelteKit helper utilities — createCronRunner and URL-state runes",
3
+ "version": "6.40.1",
4
+ "description": "SvelteKit helper utilities — createCronRunner, streamSse, and URL-state runes",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -15,7 +15,8 @@
15
15
  "sveltekit",
16
16
  "utilities",
17
17
  "cron",
18
- "url-state"
18
+ "url-state",
19
+ "sse"
19
20
  ],
20
21
  "type": "module",
21
22
  "private": false,
@@ -41,6 +42,11 @@
41
42
  "types": "./dist/cron.d.ts",
42
43
  "import": "./dist/cron.js"
43
44
  },
45
+ "./sse": {
46
+ "types": "./dist/sse.d.ts",
47
+ "import": "./dist/sse.js",
48
+ "default": "./dist/sse.js"
49
+ },
44
50
  "./table-query": {
45
51
  "types": "./dist/table-query.d.ts",
46
52
  "import": "./dist/table-query.js",