@luckystack/sync 0.1.9 → 0.2.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/docs/streaming.md CHANGED
@@ -1,349 +1,349 @@
1
- # streaming
2
-
3
- > Four stream primitives are wired into a sync `_server_v{N}.ts` handler — each picks a different audience and cost profile. `_client_v{N}.ts` adds a fifth, per-recipient, primitive. The `createStreamThrottle` helper coalesces tiny pieces (LLM tokens at 3–10 chars apiece) into bigger chunks so a 1000-token response sends ~30 socket messages instead of 1000.
4
-
5
- This doc covers the four primitives, the throttle, the recipient side, and the HTTP/SSE fallback.
6
-
7
- For the originator-side `onStream` callback see [`./sync-request.md`](./sync-request.md). For recipient stream subscription see [`./callback-registration.md`](./callback-registration.md).
8
-
9
- ---
10
-
11
- ## 1. The four primitives — decision matrix
12
-
13
- ```
14
- stream(payload) Originator's socket only cheapest
15
- broadcastStream(p) Every socket in `roomCode` medium
16
- _server_v{N}.ts param: streamTo(tokens, p) Selected session tokens medium
17
- (broadcastStream + streamTo fan out across ALL instances via the Redis adapter)
18
-
19
- _client_v{N}.ts param: stream(payload) Per-recipient (one in the loop) cheapest per-target
20
- ```
21
-
22
- ### `stream(payload)` (server-only entry, originator-targeted)
23
-
24
- Unicast back to the requesting socket only via `socket.emit(buildSyncProgressEventName(responseIndex), payload)`. Cheapest path — no room lookup, no per-recipient iteration. The originator consumes these via `syncRequest({ onStream })`.
25
-
26
- Use for **per-user progress that nobody else cares about**: upload progress, search-result narrowing, "downloading models...".
27
-
28
- ### `broadcastStream(payload)`
29
-
30
- Fanout to every socket in `roomCode`, **across all server instances** — always `io.to(roomCode).emit(...)`, which the Socket.io Redis adapter fans out cluster-wide. It does NOT inspect the local room size: the per-process room view only sees sockets on the current instance, so degrading to a per-socket unicast would silently drop members connected to other instances (see the multi-instance note below).
31
-
32
- Use for **the entire room sees the same stream**: live AI chat tokens to a group, collab-editor diffs, multiplayer game ticks. Recipients consume via `upsertSyncEventStreamCallback`.
33
-
34
- ### `streamTo(tokens, payload)`
35
-
36
- Selective fanout. `tokens` is one or many session tokens. Every socket joins a room named after its session token at connect time, so `io.to(tokens).emit(...)` reaches every device of every targeted user.
37
-
38
- Use for **explicit subscribers**: "stream this only to the admin viewers", "send the heart-rate ticker only to the patient's care team", "user X just asked for a private side-stream from this same sync request".
39
-
40
- ### `_client_v{N}.ts` `stream(payload)` (per-recipient)
41
-
42
- Inside the per-recipient loop of `handleSyncRequest`, each `_client` invocation receives a `stream` callback that emits ONLY to that one recipient. This runs **after** `_server` finishes and after the fanout loop has reached that recipient — chunks emitted here arrive at that recipient after the main `{ serverOutput, clientOutput, ... status: 'success' }` frame from `_server`, ordered.
43
-
44
- Use for **per-recipient customization that needs to stream**: "send each viewer their own translated tokens", "throttle per-recipient based on their connection quality".
45
-
46
- ---
47
-
48
- ## 2. `createStreamThrottle({ flushEveryMs?, flushAtChars?, field? })`
49
-
50
- Coalesces small pieces into bigger ones. Designed for LLM token streams where the provider yields 3–10 characters at a time and emitting one socket message per token is wasteful.
51
-
52
- ```ts
53
- import { createStreamThrottle } from '@luckystack/sync';
54
-
55
- const throttle = createStreamThrottle({
56
- flushEveryMs: 50, // flush at most every 50ms (false = no timer flush)
57
- flushAtChars: 32, // flush once buffered text crosses 32 chars
58
- field: 'chunk', // payload key carrying the buffered text (default 'chunk')
59
- });
60
-
61
- for await (const piece of openaiStream) {
62
- throttle.push(piece.text, broadcastStream);
63
- }
64
- throttle.flush(broadcastStream); // emit whatever is left after the loop ends
65
- ```
66
-
67
- ### Options
68
-
69
- | Option | Default | Effect |
70
- |---|---|---|
71
- | `flushAtChars` | `32` (from `projectConfig.sync.streamThrottle.flushAtChars`) | Flush once `buffer.length >= flushAtChars`. Lower = more frequent emits = smoother UI but more network traffic. Higher = fewer emits but chunkier UI. |
72
- | `flushEveryMs` | `50` (from config) | Timer-based flush — wakes up after Nms and emits whatever is buffered. Set to `false` to disable the timer (only flush at char threshold or explicit `flush()`). |
73
- | `field` | `'chunk'` (from config) | Payload key carrying the buffered text. Override when your stream payload uses a different key (e.g. `'text'`, `'delta'`). |
74
-
75
- ### Returned handle
76
-
77
- ```ts
78
- interface StreamThrottle {
79
- push(text: string, emit: (payload: Record<string, unknown>) => void): void;
80
- flush(emit: (payload: Record<string, unknown>) => void): void;
81
- reset(): void; // discard buffered text without emitting — for aborts
82
- }
83
- ```
84
-
85
- The `emit` argument is the stream callback of your choice — `stream`, `broadcastStream`, or a partial of `streamTo`. The throttle stays agnostic of audience, which is why you pass `emit` per call instead of binding it at construction time. That lets a single throttle drive different audiences across `push` calls if you ever need it (rare).
86
-
87
- ### Why the timer is `.unref()`'d
88
-
89
- When a `flushEveryMs` timer is scheduled, the throttle calls `timer.unref()` so a pending flush does not keep the Node.js event loop alive. This prevents short scripts and tests from hanging on a 50ms timer after the main work resolves.
90
-
91
- ### `reset()` for aborts
92
-
93
- When the upstream LLM stream aborts (client disconnected mid-generation, generation error), call `throttle.reset()` to drop the buffered text without emitting. Forgetting to call `reset()` or `flush()` leaks the pending timer until it fires (and then emits a no-op). It's safe but noisy in logs.
94
-
95
- ---
96
-
97
- ## 3. End-to-end LLM token example
98
-
99
- ```ts
100
- // src/chat/_sync/sendMessage_server_v1.ts
101
- import { createStreamThrottle } from '@luckystack/sync';
102
- import type { SessionLayout, Functions } from '@luckystack/core';
103
-
104
- export const auth = { login: true };
105
-
106
- export interface SyncParams {
107
- clientInput: { prompt: string };
108
- user: SessionLayout;
109
- functions: Functions;
110
- roomCode: string;
111
- broadcastStream: (payload?: Record<string, unknown>) => void;
112
- }
113
-
114
- export const main = async ({ clientInput, user, functions, roomCode, broadcastStream }: SyncParams) => {
115
- const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
116
- let full = '';
117
-
118
- // 1. Mark message as in-progress for the room (so other clients show a typing indicator).
119
- broadcastStream({ status: 'started', author: user.id });
120
-
121
- // 2. Stream LLM tokens through the throttle.
122
- const [error, _] = await functions.tryCatch(async () => {
123
- for await (const piece of yourLlmStream(clientInput.prompt)) {
124
- full += piece.text;
125
- throttle.push(piece.text, broadcastStream);
126
- }
127
- throttle.flush(broadcastStream);
128
- });
129
-
130
- if (error) {
131
- throttle.reset();
132
- return { status: 'error', errorCode: 'chat.llmFailed' };
133
- }
134
-
135
- // 3. Persist the final message and emit a `done` marker.
136
- const message = await functions.db.message.create({ data: { authorId: user.id, body: full, roomId: roomCode } });
137
- broadcastStream({ status: 'done', messageId: message.id });
138
-
139
- return { status: 'success', messageId: message.id };
140
- };
141
- ```
142
-
143
- Recipients in `roomCode` see (in order):
144
-
145
- 1. `{ status: 'stream', cb, fullName, status: 'started', author }`
146
- 2. `{ status: 'stream', cb, fullName, chunk: '<32+ chars>' }` × N
147
- 3. `{ status: 'stream', cb, fullName, status: 'done', messageId }`
148
- 4. `{ status: 'success', serverOutput: { messageId }, clientOutput: {}, ... }`
149
-
150
- The `status` field on the wire frame is always literal `'stream'` for chunks (set by `buildBroadcastFrame` in `streamEmitters.ts`); the inner `status: 'started' | 'done'` you set on the payload is a domain-level marker carried alongside `cb` and `fullName`.
151
-
152
- ---
153
-
154
- ## 4. Recipient side — consuming stream chunks
155
-
156
- Two consumer surfaces on the client:
157
-
158
- ### A. `upsertSyncEventStreamCallback({ name, version, callback })`
159
-
160
- The room-wide subscriber. Receives every chunk fanned out via `broadcastStream` and every chunk targeted at this socket via `streamTo`. Also receives per-recipient chunks emitted by `_client.stream(payload)`.
161
-
162
- ```ts
163
- import { useSyncEvents } from '@luckystack/sync/client';
164
-
165
- const { upsertSyncEventStreamCallback } = useSyncEvents();
166
-
167
- useEffect(() => {
168
- return upsertSyncEventStreamCallback({
169
- name: 'chat/sendMessage',
170
- version: 'v1',
171
- callback: ({ stream }) => {
172
- if ('chunk' in stream) appendToken(stream.chunk);
173
- else if (stream.status === 'done') commitMessage(stream.messageId);
174
- },
175
- });
176
- }, []);
177
- ```
178
-
179
- The callback's `stream` parameter is typed via `SyncRouteStreamCallbackForFullName<F, V>` — TS folds together `serverStream` (from `broadcastStream` / `streamTo`) AND `clientStream` (from `_client.stream`) into a single discriminated union. If neither side ever streams, the callback type collapses to `never` and registration is a compile error.
180
-
181
- ### B. `syncRequest({ onStream })`
182
-
183
- The originator-only listener. Subscribes to `buildSyncProgressEventName(responseIndex)` for the duration of one request. Receives ONLY chunks emitted via `stream(payload)` from `_server` (originator-targeted unicast).
184
-
185
- ```ts
186
- await syncRequest({
187
- name: 'upload/processImage',
188
- version: 'v1',
189
- data: { fileId },
190
- receiver: tokenOfSelf,
191
- onStream: ({ percent }) => setProgress(percent),
192
- });
193
- ```
194
-
195
- Crucially, **`onStream` does NOT receive `broadcastStream` / `streamTo` chunks** — those go through `upsertSyncEventStreamCallback`. Use `onStream` for progress and partial results that are only relevant to the sender; use `upsertSyncEventStreamCallback` for everything else.
196
-
197
- ---
198
-
199
- ## 5. HTTP / SSE fallback
200
-
201
- When a client uses the HTTP transport (`handleHttpSyncRequest`), the originator's `stream` callback is wired by `@luckystack/server` to a Server-Sent-Events writer. Each emit becomes one SSE event.
202
-
203
- ```ts
204
- // On the server side, hooking up the SSE writer (illustrative — already done by @luckystack/server):
205
- await handleHttpSyncRequest({
206
- name: 'chat/sendMessage/v1',
207
- data: req.body,
208
- receiver: roomId,
209
- token: extractToken(req),
210
- requesterIp: req.socket.remoteAddress,
211
- xLanguageHeader: req.headers['x-language'],
212
- acceptLanguageHeader: req.headers['accept-language'],
213
- stream: (payload) => {
214
- res.write(`event: stream\ndata: ${JSON.stringify(payload)}\n\n`);
215
- },
216
- });
217
- ```
218
-
219
- Public type:
220
-
221
- ```ts
222
- export type HttpSyncStreamEvent = Record<string, unknown>;
223
- ```
224
-
225
- Important: `broadcastStream` and `streamTo` chunks still flow over **Socket.io** even when the originator uses HTTP — recipients live on sockets regardless of how the request entered the server. Only the originator's `stream(payload)` chunks travel via SSE.
226
-
227
- ---
228
-
229
- ## 6. Performance notes
230
-
231
- ### `broadcastStream` is always a cross-instance room emit
232
-
233
- ```ts
234
- io.to(receiver).emit(socketEventNames.sync, frame);
235
- ```
236
-
237
- `broadcastStream` does NOT inspect local room membership. A previous "if room size <= 1, unicast to the lone socket" optimization was removed: the per-process room view only sees sockets on the LOCAL instance, so in a multi-instance cluster it dropped members connected to other instances. `io.to(room).emit(...)` lets the Redis adapter resolve the real recipients cluster-wide; `streamTo` uses the same `io.to(tokens).emit(...)` path.
238
-
239
- ### Stream-hook fire-and-forget
240
-
241
- `preSyncStream` and `postSyncStream` hooks dispatch via `void dispatchHook(...)` — chunks never `await` the hook. Hook errors are swallowed by the hook dispatcher's own `tryCatch`. This matters because a slow Sentry breadcrumb (for example) must not delay chunk delivery to the user.
242
-
243
- ### Chunk-index counter
244
-
245
- `postSyncStream` includes `chunkIndex` (1-based) so observers can correlate chunks within a single fanout. The counter is keyed by `(routeName, recipient)` and lives in-memory; it grows for the lifetime of the process but is bounded by the route × recipient cardinality (typically tens of thousands at most).
246
-
247
- ### Throttle math
248
-
249
- For a 1000-token LLM response (~4000 chars), at `flushAtChars: 32` you emit ~125 messages instead of 1000 — an **8× reduction** with no perceptible UI lag. At `flushAtChars: 64` you get ~62 messages, but punctuation/whitespace can cause perceived stuttering. 32 is a good default.
250
-
251
- ---
252
-
253
- ## 7. Types reference
254
-
255
- | Type | Where | Purpose |
256
- |---|---|---|
257
- | `CreateStreamThrottleOptions` | `@luckystack/sync` | Throttle constructor options. |
258
- | `StreamThrottle` | `@luckystack/sync` | The handle returned by `createStreamThrottle`. |
259
- | `HttpSyncStreamEvent` | `@luckystack/sync` | SSE event shape (alias of `Record<string, unknown>`). |
260
- | `SyncRequestStreamEvent<T>` | `@luckystack/sync/client` | Payload shape passed to `onStream` (originator). |
261
- | `SyncRouteStreamEvent<T>` | `@luckystack/sync/client` | Payload shape passed to `upsertSyncEventStreamCallback`. |
262
- | `StreamPayload` | `@luckystack/core` | Base constraint — `Record<string, unknown>`-compatible. |
263
-
264
- ---
265
-
266
- ## 8. Cancellation (`abortSignal`) and backpressure (`flushPressure`)
267
-
268
- Both helpers are injected into every `_server_v{N}.ts` handler alongside `stream` / `broadcastStream` / `streamTo`. Older handlers that do not destructure them still work — destructuring an extra key from a JS function param is a no-op, so the change is fully backwards compatible.
269
-
270
- ### `abortSignal: AbortSignal`
271
-
272
- Aborts when **either** the originating client emits `syncCancel` (e.g. consumer wrote `syncRequest({ ..., signal })` and called `controller.abort()`) **or** the originator's socket disconnects.
273
-
274
- Effects:
275
-
276
- - Every emit through the framework's `stream` / `broadcastStream` / `streamTo` callbacks is short-circuited automatically once the signal aborts. Already-on-the-wire chunks are not unsent — there's no way to do that in Socket.io — but no new chunks will be queued.
277
- - `flushPressure()` resolves immediately when the signal is aborted.
278
- - The handler itself should check `abortSignal.aborted` inside long-running loops (LLM generation, DB cursor walks, file streaming) and break out — only the *emit* gate is wired automatically; the handler's own CPU work keeps going until you read the flag.
279
-
280
- ```ts
281
- export const main = async ({ broadcastStream, abortSignal, flushPressure }: SyncParams) => {
282
- for await (const piece of yourLlmStream(prompt)) {
283
- if (abortSignal.aborted) break; // bail on client cancel
284
- broadcastStream({ chunk: piece.text });
285
- }
286
- return { status: 'success' };
287
- };
288
- ```
289
-
290
- On the client side:
291
-
292
- ```ts
293
- const controller = new AbortController();
294
- const promise = syncRequest({
295
- name: 'chat/sendMessage',
296
- version: 'v1',
297
- data: { prompt: '...' },
298
- receiver: roomCode,
299
- signal: controller.signal, // ← optional opt-in
300
- });
301
-
302
- // later — user clicked Stop:
303
- controller.abort();
304
- // `promise` resolves with { status: 'error', errorCode: 'request.aborted' }.
305
- ```
306
-
307
- Same opt-in surface exists for `apiRequest({ signal })`.
308
-
309
- ### `flushPressure({ thresholdBytes? })`
310
-
311
- Awaitable. Resolves when the worst-case Socket.io write buffer across the sockets you're streaming to drops below the threshold (default `1 MB`). Use it between large batches of small chunks so the Node.js write buffer doesn't balloon.
312
-
313
- ```ts
314
- export const main = async ({ broadcastStream, flushPressure, abortSignal }: SyncParams) => {
315
- let i = 0;
316
- for await (const piece of yourLlmStream(prompt)) {
317
- if (abortSignal.aborted) break;
318
- broadcastStream({ chunk: piece.text });
319
- i++;
320
- //? Every 64 chunks, pause if the room is backed up. 1 MB default.
321
- //? Pass `thresholdBytes` to tighten or loosen the watermark.
322
- if (i % 64 === 0) await flushPressure();
323
- }
324
- return { status: 'success' };
325
- };
326
-
327
- // Tuning the threshold:
328
- await flushPressure({ thresholdBytes: 256 * 1024 }); // pause if >= 256 KB pending
329
- ```
330
-
331
- Notes:
332
-
333
- - For `broadcastStream` / `streamTo`, `flushPressure` samples up to the first **32** sockets in the affected room and waits on the worst-case writer. Rooms larger than 32 trade fairness for O(1) cost.
334
- - For originator-only `stream(payload)`, `flushPressure` polls the originator socket's engine.io write buffer. HTTP/SSE transport has no equivalent — `flushPressure` is a no-op on HTTP, since SSE backpressure is the caller's responsibility (Node's `res.write` returns `false` when full).
335
- - Polling interval is ~10 ms; no busy-loop, no `drain` event (engine.io doesn't expose one in the public Socket.io API).
336
- - Resolves immediately if `abortSignal` is aborted.
337
-
338
- The same `flushPressure({ thresholdBytes? })` is also injected into `_api/<name>_v{N}.ts` handler params. There it always measures the originator socket only.
339
-
340
- ---
341
-
342
- ## 9. Related
343
-
344
- - Originator API + `onStream`: [`./sync-request.md`](./sync-request.md)
345
- - Recipient subscription: [`./callback-registration.md`](./callback-registration.md)
346
- - Handler authoring (where streams come from): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
347
- - Stream reconstruction demo page: [`/docs/STREAMING_RECONSTRUCTION.md`](../../../docs/STREAMING_RECONSTRUCTION.md)
348
- - Full architecture: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md#streaming)
349
- - Throttle config: `projectConfig.sync.streamThrottle.*`
1
+ # streaming
2
+
3
+ > Four stream primitives are wired into a sync `_server_v{N}.ts` handler — each picks a different audience and cost profile. `_client_v{N}.ts` adds a fifth, per-recipient, primitive. The `createStreamThrottle` helper coalesces tiny pieces (LLM tokens at 3–10 chars apiece) into bigger chunks so a 1000-token response sends ~30 socket messages instead of 1000.
4
+
5
+ This doc covers the four primitives, the throttle, the recipient side, and the HTTP/SSE fallback.
6
+
7
+ For the originator-side `onStream` callback see [`./sync-request.md`](./sync-request.md). For recipient stream subscription see [`./callback-registration.md`](./callback-registration.md).
8
+
9
+ ---
10
+
11
+ ## 1. The four primitives — decision matrix
12
+
13
+ ```
14
+ stream(payload) Originator's socket only cheapest
15
+ broadcastStream(p) Every socket in `roomCode` medium
16
+ _server_v{N}.ts param: streamTo(tokens, p) Selected session tokens medium
17
+ (broadcastStream + streamTo fan out across ALL instances via the Redis adapter)
18
+
19
+ _client_v{N}.ts param: stream(payload) Per-recipient (one in the loop) cheapest per-target
20
+ ```
21
+
22
+ ### `stream(payload)` (server-only entry, originator-targeted)
23
+
24
+ Unicast back to the requesting socket only via `socket.emit(buildSyncProgressEventName(responseIndex), payload)`. Cheapest path — no room lookup, no per-recipient iteration. The originator consumes these via `syncRequest({ onStream })`.
25
+
26
+ Use for **per-user progress that nobody else cares about**: upload progress, search-result narrowing, "downloading models...".
27
+
28
+ ### `broadcastStream(payload)`
29
+
30
+ Fanout to every socket in `roomCode`, **across all server instances** — always `io.to(roomCode).emit(...)`, which the Socket.io Redis adapter fans out cluster-wide. It does NOT inspect the local room size: the per-process room view only sees sockets on the current instance, so degrading to a per-socket unicast would silently drop members connected to other instances (see the multi-instance note below).
31
+
32
+ Use for **the entire room sees the same stream**: live AI chat tokens to a group, collab-editor diffs, multiplayer game ticks. Recipients consume via `upsertSyncEventStreamCallback`.
33
+
34
+ ### `streamTo(tokens, payload)`
35
+
36
+ Selective fanout. `tokens` is one or many session tokens. Every socket joins a room named after its session token at connect time, so `io.to(tokens).emit(...)` reaches every device of every targeted user.
37
+
38
+ Use for **explicit subscribers**: "stream this only to the admin viewers", "send the heart-rate ticker only to the patient's care team", "user X just asked for a private side-stream from this same sync request".
39
+
40
+ ### `_client_v{N}.ts` `stream(payload)` (per-recipient)
41
+
42
+ Inside the per-recipient loop of `handleSyncRequest`, each `_client` invocation receives a `stream` callback that emits ONLY to that one recipient. This runs **after** `_server` finishes and after the fanout loop has reached that recipient — chunks emitted here arrive at that recipient after the main `{ serverOutput, clientOutput, ... status: 'success' }` frame from `_server`, ordered.
43
+
44
+ Use for **per-recipient customization that needs to stream**: "send each viewer their own translated tokens", "throttle per-recipient based on their connection quality".
45
+
46
+ ---
47
+
48
+ ## 2. `createStreamThrottle({ flushEveryMs?, flushAtChars?, field? })`
49
+
50
+ Coalesces small pieces into bigger ones. Designed for LLM token streams where the provider yields 3–10 characters at a time and emitting one socket message per token is wasteful.
51
+
52
+ ```ts
53
+ import { createStreamThrottle } from '@luckystack/sync';
54
+
55
+ const throttle = createStreamThrottle({
56
+ flushEveryMs: 50, // flush at most every 50ms (false = no timer flush)
57
+ flushAtChars: 32, // flush once buffered text crosses 32 chars
58
+ field: 'chunk', // payload key carrying the buffered text (default 'chunk')
59
+ });
60
+
61
+ for await (const piece of openaiStream) {
62
+ throttle.push(piece.text, broadcastStream);
63
+ }
64
+ throttle.flush(broadcastStream); // emit whatever is left after the loop ends
65
+ ```
66
+
67
+ ### Options
68
+
69
+ | Option | Default | Effect |
70
+ |---|---|---|
71
+ | `flushAtChars` | `32` (from `projectConfig.sync.streamThrottle.flushAtChars`) | Flush once `buffer.length >= flushAtChars`. Lower = more frequent emits = smoother UI but more network traffic. Higher = fewer emits but chunkier UI. |
72
+ | `flushEveryMs` | `50` (from config) | Timer-based flush — wakes up after Nms and emits whatever is buffered. Set to `false` to disable the timer (only flush at char threshold or explicit `flush()`). |
73
+ | `field` | `'chunk'` (from config) | Payload key carrying the buffered text. Override when your stream payload uses a different key (e.g. `'text'`, `'delta'`). |
74
+
75
+ ### Returned handle
76
+
77
+ ```ts
78
+ interface StreamThrottle {
79
+ push(text: string, emit: (payload: Record<string, unknown>) => void): void;
80
+ flush(emit: (payload: Record<string, unknown>) => void): void;
81
+ reset(): void; // discard buffered text without emitting — for aborts
82
+ }
83
+ ```
84
+
85
+ The `emit` argument is the stream callback of your choice — `stream`, `broadcastStream`, or a partial of `streamTo`. The throttle stays agnostic of audience, which is why you pass `emit` per call instead of binding it at construction time. That lets a single throttle drive different audiences across `push` calls if you ever need it (rare).
86
+
87
+ ### Why the timer is `.unref()`'d
88
+
89
+ When a `flushEveryMs` timer is scheduled, the throttle calls `timer.unref()` so a pending flush does not keep the Node.js event loop alive. This prevents short scripts and tests from hanging on a 50ms timer after the main work resolves.
90
+
91
+ ### `reset()` for aborts
92
+
93
+ When the upstream LLM stream aborts (client disconnected mid-generation, generation error), call `throttle.reset()` to drop the buffered text without emitting. Forgetting to call `reset()` or `flush()` leaks the pending timer until it fires (and then emits a no-op). It's safe but noisy in logs.
94
+
95
+ ---
96
+
97
+ ## 3. End-to-end LLM token example
98
+
99
+ ```ts
100
+ // src/chat/_sync/sendMessage_server_v1.ts
101
+ import { createStreamThrottle } from '@luckystack/sync';
102
+ import type { SessionLayout, Functions } from '@luckystack/core';
103
+
104
+ export const auth = { login: true };
105
+
106
+ export interface SyncParams {
107
+ clientInput: { prompt: string };
108
+ user: SessionLayout;
109
+ functions: Functions;
110
+ roomCode: string;
111
+ broadcastStream: (payload?: Record<string, unknown>) => void;
112
+ }
113
+
114
+ export const main = async ({ clientInput, user, functions, roomCode, broadcastStream }: SyncParams) => {
115
+ const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
116
+ let full = '';
117
+
118
+ // 1. Mark message as in-progress for the room (so other clients show a typing indicator).
119
+ broadcastStream({ status: 'started', author: user.id });
120
+
121
+ // 2. Stream LLM tokens through the throttle.
122
+ const [error, _] = await functions.tryCatch(async () => {
123
+ for await (const piece of yourLlmStream(clientInput.prompt)) {
124
+ full += piece.text;
125
+ throttle.push(piece.text, broadcastStream);
126
+ }
127
+ throttle.flush(broadcastStream);
128
+ });
129
+
130
+ if (error) {
131
+ throttle.reset();
132
+ return { status: 'error', errorCode: 'chat.llmFailed' };
133
+ }
134
+
135
+ // 3. Persist the final message and emit a `done` marker.
136
+ const message = await functions.db.message.create({ data: { authorId: user.id, body: full, roomId: roomCode } });
137
+ broadcastStream({ status: 'done', messageId: message.id });
138
+
139
+ return { status: 'success', messageId: message.id };
140
+ };
141
+ ```
142
+
143
+ Recipients in `roomCode` see (in order):
144
+
145
+ 1. `{ status: 'stream', cb, fullName, status: 'started', author }`
146
+ 2. `{ status: 'stream', cb, fullName, chunk: '<32+ chars>' }` × N
147
+ 3. `{ status: 'stream', cb, fullName, status: 'done', messageId }`
148
+ 4. `{ status: 'success', serverOutput: { messageId }, clientOutput: {}, ... }`
149
+
150
+ The `status` field on the wire frame is always literal `'stream'` for chunks (set by `buildBroadcastFrame` in `streamEmitters.ts`); the inner `status: 'started' | 'done'` you set on the payload is a domain-level marker carried alongside `cb` and `fullName`.
151
+
152
+ ---
153
+
154
+ ## 4. Recipient side — consuming stream chunks
155
+
156
+ Two consumer surfaces on the client:
157
+
158
+ ### A. `upsertSyncEventStreamCallback({ name, version, callback })`
159
+
160
+ The room-wide subscriber. Receives every chunk fanned out via `broadcastStream` and every chunk targeted at this socket via `streamTo`. Also receives per-recipient chunks emitted by `_client.stream(payload)`.
161
+
162
+ ```ts
163
+ import { useSyncEvents } from '@luckystack/sync/client';
164
+
165
+ const { upsertSyncEventStreamCallback } = useSyncEvents();
166
+
167
+ useEffect(() => {
168
+ return upsertSyncEventStreamCallback({
169
+ name: 'chat/sendMessage',
170
+ version: 'v1',
171
+ callback: ({ stream }) => {
172
+ if ('chunk' in stream) appendToken(stream.chunk);
173
+ else if (stream.status === 'done') commitMessage(stream.messageId);
174
+ },
175
+ });
176
+ }, []);
177
+ ```
178
+
179
+ The callback's `stream` parameter is typed via `SyncRouteStreamCallbackForFullName<F, V>` — TS folds together `serverStream` (from `broadcastStream` / `streamTo`) AND `clientStream` (from `_client.stream`) into a single discriminated union. If neither side ever streams, the callback type collapses to `never` and registration is a compile error.
180
+
181
+ ### B. `syncRequest({ onStream })`
182
+
183
+ The originator-only listener. Subscribes to `buildSyncProgressEventName(responseIndex)` for the duration of one request. Receives ONLY chunks emitted via `stream(payload)` from `_server` (originator-targeted unicast).
184
+
185
+ ```ts
186
+ await syncRequest({
187
+ name: 'upload/processImage',
188
+ version: 'v1',
189
+ data: { fileId },
190
+ receiver: tokenOfSelf,
191
+ onStream: ({ percent }) => setProgress(percent),
192
+ });
193
+ ```
194
+
195
+ Crucially, **`onStream` does NOT receive `broadcastStream` / `streamTo` chunks** — those go through `upsertSyncEventStreamCallback`. Use `onStream` for progress and partial results that are only relevant to the sender; use `upsertSyncEventStreamCallback` for everything else.
196
+
197
+ ---
198
+
199
+ ## 5. HTTP / SSE fallback
200
+
201
+ When a client uses the HTTP transport (`handleHttpSyncRequest`), the originator's `stream` callback is wired by `@luckystack/server` to a Server-Sent-Events writer. Each emit becomes one SSE event.
202
+
203
+ ```ts
204
+ // On the server side, hooking up the SSE writer (illustrative — already done by @luckystack/server):
205
+ await handleHttpSyncRequest({
206
+ name: 'chat/sendMessage/v1',
207
+ data: req.body,
208
+ receiver: roomId,
209
+ token: extractToken(req),
210
+ requesterIp: req.socket.remoteAddress,
211
+ xLanguageHeader: req.headers['x-language'],
212
+ acceptLanguageHeader: req.headers['accept-language'],
213
+ stream: (payload) => {
214
+ res.write(`event: stream\ndata: ${JSON.stringify(payload)}\n\n`);
215
+ },
216
+ });
217
+ ```
218
+
219
+ Public type:
220
+
221
+ ```ts
222
+ export type HttpSyncStreamEvent = Record<string, unknown>;
223
+ ```
224
+
225
+ Important: `broadcastStream` and `streamTo` chunks still flow over **Socket.io** even when the originator uses HTTP — recipients live on sockets regardless of how the request entered the server. Only the originator's `stream(payload)` chunks travel via SSE.
226
+
227
+ ---
228
+
229
+ ## 6. Performance notes
230
+
231
+ ### `broadcastStream` is always a cross-instance room emit
232
+
233
+ ```ts
234
+ io.to(receiver).emit(socketEventNames.sync, frame);
235
+ ```
236
+
237
+ `broadcastStream` does NOT inspect local room membership. A previous "if room size <= 1, unicast to the lone socket" optimization was removed: the per-process room view only sees sockets on the LOCAL instance, so in a multi-instance cluster it dropped members connected to other instances. `io.to(room).emit(...)` lets the Redis adapter resolve the real recipients cluster-wide; `streamTo` uses the same `io.to(tokens).emit(...)` path.
238
+
239
+ ### Stream-hook fire-and-forget
240
+
241
+ `preSyncStream` and `postSyncStream` hooks dispatch via `void dispatchHook(...)` — chunks never `await` the hook. Hook errors are swallowed by the hook dispatcher's own `tryCatch`. This matters because a slow Sentry breadcrumb (for example) must not delay chunk delivery to the user.
242
+
243
+ ### Chunk-index counter
244
+
245
+ `postSyncStream` includes `chunkIndex` (1-based) so observers can correlate chunks within a single fanout. The counter is keyed by `(routeName, recipient)` and lives in-memory; it grows for the lifetime of the process but is bounded by the route × recipient cardinality (typically tens of thousands at most).
246
+
247
+ ### Throttle math
248
+
249
+ For a 1000-token LLM response (~4000 chars), at `flushAtChars: 32` you emit ~125 messages instead of 1000 — an **8× reduction** with no perceptible UI lag. At `flushAtChars: 64` you get ~62 messages, but punctuation/whitespace can cause perceived stuttering. 32 is a good default.
250
+
251
+ ---
252
+
253
+ ## 7. Types reference
254
+
255
+ | Type | Where | Purpose |
256
+ |---|---|---|
257
+ | `CreateStreamThrottleOptions` | `@luckystack/sync` | Throttle constructor options. |
258
+ | `StreamThrottle` | `@luckystack/sync` | The handle returned by `createStreamThrottle`. |
259
+ | `HttpSyncStreamEvent` | `@luckystack/sync` | SSE event shape (alias of `Record<string, unknown>`). |
260
+ | `SyncRequestStreamEvent<T>` | `@luckystack/sync/client` | Payload shape passed to `onStream` (originator). |
261
+ | `SyncRouteStreamEvent<T>` | `@luckystack/sync/client` | Payload shape passed to `upsertSyncEventStreamCallback`. |
262
+ | `StreamPayload` | `@luckystack/core` | Base constraint — `Record<string, unknown>`-compatible. |
263
+
264
+ ---
265
+
266
+ ## 8. Cancellation (`abortSignal`) and backpressure (`flushPressure`)
267
+
268
+ Both helpers are injected into every `_server_v{N}.ts` handler alongside `stream` / `broadcastStream` / `streamTo`. Older handlers that do not destructure them still work — destructuring an extra key from a JS function param is a no-op, so the change is fully backwards compatible.
269
+
270
+ ### `abortSignal: AbortSignal`
271
+
272
+ Aborts when **either** the originating client emits `syncCancel` (e.g. consumer wrote `syncRequest({ ..., signal })` and called `controller.abort()`) **or** the originator's socket disconnects.
273
+
274
+ Effects:
275
+
276
+ - Every emit through the framework's `stream` / `broadcastStream` / `streamTo` callbacks is short-circuited automatically once the signal aborts. Already-on-the-wire chunks are not unsent — there's no way to do that in Socket.io — but no new chunks will be queued.
277
+ - `flushPressure()` resolves immediately when the signal is aborted.
278
+ - The handler itself should check `abortSignal.aborted` inside long-running loops (LLM generation, DB cursor walks, file streaming) and break out — only the *emit* gate is wired automatically; the handler's own CPU work keeps going until you read the flag.
279
+
280
+ ```ts
281
+ export const main = async ({ broadcastStream, abortSignal, flushPressure }: SyncParams) => {
282
+ for await (const piece of yourLlmStream(prompt)) {
283
+ if (abortSignal.aborted) break; // bail on client cancel
284
+ broadcastStream({ chunk: piece.text });
285
+ }
286
+ return { status: 'success' };
287
+ };
288
+ ```
289
+
290
+ On the client side:
291
+
292
+ ```ts
293
+ const controller = new AbortController();
294
+ const promise = syncRequest({
295
+ name: 'chat/sendMessage',
296
+ version: 'v1',
297
+ data: { prompt: '...' },
298
+ receiver: roomCode,
299
+ signal: controller.signal, // ← optional opt-in
300
+ });
301
+
302
+ // later — user clicked Stop:
303
+ controller.abort();
304
+ // `promise` resolves with { status: 'error', errorCode: 'request.aborted' }.
305
+ ```
306
+
307
+ Same opt-in surface exists for `apiRequest({ signal })`.
308
+
309
+ ### `flushPressure({ thresholdBytes? })`
310
+
311
+ Awaitable. Resolves when the worst-case Socket.io write buffer across the sockets you're streaming to drops below the threshold (default `1 MB`). Use it between large batches of small chunks so the Node.js write buffer doesn't balloon.
312
+
313
+ ```ts
314
+ export const main = async ({ broadcastStream, flushPressure, abortSignal }: SyncParams) => {
315
+ let i = 0;
316
+ for await (const piece of yourLlmStream(prompt)) {
317
+ if (abortSignal.aborted) break;
318
+ broadcastStream({ chunk: piece.text });
319
+ i++;
320
+ //? Every 64 chunks, pause if the room is backed up. 1 MB default.
321
+ //? Pass `thresholdBytes` to tighten or loosen the watermark.
322
+ if (i % 64 === 0) await flushPressure();
323
+ }
324
+ return { status: 'success' };
325
+ };
326
+
327
+ // Tuning the threshold:
328
+ await flushPressure({ thresholdBytes: 256 * 1024 }); // pause if >= 256 KB pending
329
+ ```
330
+
331
+ Notes:
332
+
333
+ - For `broadcastStream` / `streamTo`, `flushPressure` samples up to the first **32** sockets in the affected room and waits on the worst-case writer. Rooms larger than 32 trade fairness for O(1) cost.
334
+ - For originator-only `stream(payload)`, `flushPressure` polls the originator socket's engine.io write buffer. HTTP/SSE transport has no equivalent — `flushPressure` is a no-op on HTTP, since SSE backpressure is the caller's responsibility (Node's `res.write` returns `false` when full).
335
+ - Polling interval is ~10 ms; no busy-loop, no `drain` event (engine.io doesn't expose one in the public Socket.io API).
336
+ - Resolves immediately if `abortSignal` is aborted.
337
+
338
+ The same `flushPressure({ thresholdBytes? })` is also injected into `_api/<name>_v{N}.ts` handler params. There it always measures the originator socket only.
339
+
340
+ ---
341
+
342
+ ## 9. Related
343
+
344
+ - Originator API + `onStream`: [`./sync-request.md`](./sync-request.md)
345
+ - Recipient subscription: [`./callback-registration.md`](./callback-registration.md)
346
+ - Handler authoring (where streams come from): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
347
+ - Stream reconstruction demo page: [`/docs/STREAMING_RECONSTRUCTION.md`](../../../docs/STREAMING_RECONSTRUCTION.md)
348
+ - Full architecture: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md#streaming)
349
+ - Throttle config: `projectConfig.sync.streamThrottle.*`