@luckystack/sync 0.1.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.
@@ -0,0 +1,321 @@
1
+ # server-vs-client-handlers
2
+
3
+ > A sync route is **two files**: a mandatory `_server_v{N}.ts` that runs **once per request** to validate and produce `serverOutput`, and an **optional** `_client_v{N}.ts` that runs **once per recipient socket** for per-target filtering, per-client auth, or producing a custom `clientOutput`. This doc covers the contract of both files and the rules for when to skip the `_client` file entirely.
4
+
5
+ For the originator-side call signature see [`./sync-request.md`](./sync-request.md). For fanout mechanics see [`./room-fanout.md`](./room-fanout.md).
6
+
7
+ ---
8
+
9
+ ## 1. The two-file model
10
+
11
+ Given the file-based router (see `/docs/ARCHITECTURE_ROUTING.md`):
12
+
13
+ ```
14
+ src/board/_sync/moveCard_server_v1.ts <- REQUIRED: validates + produces serverOutput (runs once)
15
+ src/board/_sync/moveCard_client_v1.ts <- OPTIONAL: per-recipient logic (runs once per socket in the room)
16
+ ```
17
+
18
+ Route name: `board/moveCard` at version `v1`. The framework loads both files via `getRuntimeSyncMaps()` and keys them as `board/moveCard/v1_server` and `board/moveCard/v1_client`.
19
+
20
+ A route is valid if **at least one** of the two files exists. If neither exists, `handleSyncRequest` rejects with `sync.notFound`.
21
+
22
+ ---
23
+
24
+ ## 2. `_server_v{N}.ts` contract — runs ONCE per request
25
+
26
+ ```ts
27
+ import type { SessionLayout, Functions } from '@luckystack/core';
28
+ import type { AuthProps } from '@luckystack/login';
29
+
30
+ export const auth: AuthProps = { login: true };
31
+
32
+ export interface SyncParams {
33
+ clientInput: { /* validated against generated Zod schema */ };
34
+ user: SessionLayout | null; // null only when auth.login === false
35
+ functions: Functions; // tryCatch, db, redis, notify, ...
36
+ roomCode: string; // the `receiver` from syncRequest
37
+ stream: (payload?: Record<string, unknown>) => void; // originator only
38
+ broadcastStream: (payload?: Record<string, unknown>) => void; // entire room
39
+ streamTo: (tokens: string | string[], payload?: Record<string, unknown>) => void;
40
+ }
41
+
42
+ export const main = async ({ clientInput, user, functions, roomCode, broadcastStream }: SyncParams) => {
43
+ // 1. Mutate persistent state.
44
+ await functions.db.card.update({ where: { id: clientInput.cardId }, data: { laneId: clientInput.toLane } });
45
+
46
+ // 2. Optionally stream progress / tokens / diffs (broadcastStream goes to everyone in roomCode).
47
+ // 3. Return the success envelope. Everything except `status` becomes `serverOutput` for the recipients.
48
+ return { status: 'success', cardId: clientInput.cardId, movedBy: user!.id };
49
+ };
50
+ ```
51
+
52
+ Key rules:
53
+
54
+ - **`auth: AuthProps` is mandatory.** Drives the framework's auth gate (`auth.login` -> require session, `auth.additional` -> run `validateRequest` predicates). See `@luckystack/login` `AuthProps`.
55
+ - **Return must include `status: 'success' | 'error'`.** Anything else collapses to `sync.invalidServerResponse`. The rest of the object is forwarded as `serverOutput` (after stripping `status`).
56
+ - **Streaming primitives are received as params.** See [`./streaming.md`](./streaming.md) for the audience matrix.
57
+ - **Never `try/catch` manually.** Wrap async ops in `functions.tryCatch` (or the destructured `tryCatch` from `functions`) — the framework already wraps the whole `main()` in its own `tryCatch` for Sentry capture, but per-operation `tryCatch` lets you surface targeted error codes.
58
+
59
+ ---
60
+
61
+ ## 3. `_client_v{N}.ts` contract — runs ONCE per recipient socket
62
+
63
+ ```ts
64
+ import type { Functions } from '@luckystack/core';
65
+
66
+ export interface SyncClientParams {
67
+ clientInput: { /* same shape as _server */ };
68
+ token: string | null; // recipient's session token (NOT the sender's)
69
+ functions: Functions;
70
+ serverOutput: unknown; // exactly what _server returned (minus `status`)
71
+ roomCode: string;
72
+ stream: (payload?: Record<string, unknown>) => void; // per-recipient stream
73
+ }
74
+
75
+ export const main = async ({ clientInput, token, functions, serverOutput, roomCode }: SyncClientParams) => {
76
+ // 1. Optionally fetch this recipient's session — DO NOT receive `user`, only `token`.
77
+ // The framework deliberately does not pre-resolve sessions per recipient because
78
+ // most clients do not need them. Resolving N sessions for a 50-user room is wasteful.
79
+ const recipient = await functions.session.getSession(token);
80
+ if (!recipient) return { status: 'success' }; // anonymous viewer — leave as-is
81
+
82
+ // 2. Filter / brand / translate based on the recipient.
83
+ if (recipient.role === 'guest' && serverOutput.privateNotes) {
84
+ return { status: 'success', visibleNotes: null }; // strip the private field
85
+ }
86
+
87
+ // 3. Return a `clientOutput` envelope. Same status rules as _server.
88
+ return { status: 'success', visibleNotes: serverOutput.privateNotes };
89
+ };
90
+ ```
91
+
92
+ Key rules:
93
+
94
+ - **No `user` param.** Receives `token` instead. Call `functions.session.getSession(token)` only when you actually need the session — most `_client` files don't.
95
+ - **Cannot validate or reject the request.** Validation already happened in `_server`. `_client` only customizes per-recipient output. Its error path emits `sync.clientExecutionFailed` / `sync.invalidClientResponse` / `sync.clientRejected` to that specific recipient, but does NOT abort the fanout to the others.
96
+ - **No `broadcastStream` / `streamTo`.** A per-recipient handler cannot fanout to other recipients — that contract is server-scope only.
97
+
98
+ ---
99
+
100
+ ## 4. When to create `_client_v{N}.ts`
101
+
102
+ | Goal | Need `_client`? | Why |
103
+ |---|---|---|
104
+ | Mutate state, tell the room what changed | No | `serverOutput` reaches every recipient unchanged. |
105
+ | Hide a field from non-owner viewers | Yes | Per-recipient filter. |
106
+ | Brand the payload (translate strings, swap CDN host, inject feature flags) | Yes | Per-recipient customization. |
107
+ | Per-recipient auth (e.g. "guests get a redacted version") | Yes | Use `token` -> `getSession` -> branch. |
108
+ | Per-recipient stream (different chunks per viewer) | Yes | Only `_client` has the per-target `stream(...)` primitive. |
109
+ | Just return `{ status: 'success' }` | **No — delete the file** | Pure overhead: framework already emits success with `clientOutput: {}` when `_client` is absent. |
110
+
111
+ ### The "empty client" anti-pattern
112
+
113
+ ```ts
114
+ // BAD — adds avoidable per-recipient await + tryCatch + emit overhead for every socket in the room.
115
+ export const main = async () => ({ status: 'success' });
116
+ ```
117
+
118
+ If the file would only return `{ status: 'success' }`, leave it out. The framework's no-client branch emits:
119
+
120
+ ```ts
121
+ {
122
+ cb,
123
+ fullName: resolvedName,
124
+ serverOutput,
125
+ clientOutput: {},
126
+ message: `${resolvedName} sync success`,
127
+ status: 'success',
128
+ }
129
+ ```
130
+
131
+ …directly to each recipient with zero per-recipient handler invocation. That path is materially cheaper, especially when `receiver: 'all'`.
132
+
133
+ Rule of thumb: a `_client` file exists **only** when it makes a decision the server cannot make once.
134
+
135
+ ---
136
+
137
+ ## 5. Full lifecycle of `handleSyncRequest`
138
+
139
+ ```
140
+ incoming msg (Socket.io 'sync' event)
141
+ |
142
+ v
143
+ 1. validate msg shape -> sync.invalidRequest
144
+ 2. parseTransportRouteName(name) -> routing.invalidServiceRouteName
145
+ 3. validate cb -> sync.invalidCallback
146
+ 4. validate receiver -> sync.missingReceiver
147
+ 5. getSession(token) -> setSentryUser(...)
148
+ 6. getRuntimeSyncMaps()
149
+ no _server AND no _client -> sync.notFound
150
+ |
151
+ v
152
+ 7. If _server exists:
153
+ AuthProps gate (auth.login) -> auth.required
154
+ validateRequest(auth.additional) -> auth.forbidden
155
+ 8. dispatchHook('preSyncAuthorize') -> stop signal becomes error envelope
156
+ 9. applySyncRateLimits() -> sync.rateLimitExceeded
157
+ 10. If _server exists:
158
+ validateInputByType(clientInput) -> sync.invalidInputType
159
+ tryCatch(serverMain(...)) -> sync.serverExecutionFailed
160
+ status !== 'success' | 'error' -> sync.invalidServerResponse
161
+ status === 'error' -> server's errorCode (normalized)
162
+ status === 'success' -> serverOutput = result (minus status)
163
+ 11. Resolve recipients:
164
+ receiver === 'all' -> io.sockets.sockets (Map of every connected socket)
165
+ otherwise -> io.sockets.adapter.rooms.get(receiver) (Set of IDs)
166
+ no sockets found -> sync.noReceiversFound
167
+ 12. dispatchHook('preSyncFanout') -> stop signal becomes error envelope
168
+ 13. Per-recipient loop (with periodic event-loop yield):
169
+ if (ignoreSelf && token === recipientToken) continue
170
+ recipientCount++
171
+ if (_client exists):
172
+ tryCatch(clientHandler(...)) -> sync.clientExecutionFailed to that recipient
173
+ status='error' -> normalized error to that recipient
174
+ status='success' -> emit { serverOutput, clientOutput, ... }
175
+ else:
176
+ emit { serverOutput, clientOutput: {}, ... }
177
+ 14. dispatchHook('postSyncFanout', { recipientCount })
178
+ 15. ack originator: emit(buildSyncResponseEventName(responseIndex), { status, result: serverOutput })
179
+ ```
180
+
181
+ Per-recipient failures inside step 13 do **not** abort the fanout — every other recipient still receives the merged payload. The originator's ack always reflects the `_server` outcome, never the per-recipient outcomes.
182
+
183
+ ---
184
+
185
+ ## 6. Failure-mode matrix
186
+
187
+ | Stage | Trigger | Error code | Visibility |
188
+ |---|---|---|---|
189
+ | Msg shape | Non-object `msg` | `sync.invalidRequest` | originator ack |
190
+ | Routing | Bad `name` shape | `routing.invalidServiceRouteName` | originator ack |
191
+ | Routing | No `cb` | `sync.invalidCallback` | originator ack |
192
+ | Routing | No receiver | `sync.missingReceiver` | originator ack |
193
+ | Route lookup | No `_server` AND no `_client` | `sync.notFound` | originator ack |
194
+ | Auth | `auth.login` + no session | `auth.required` | originator ack |
195
+ | Auth | `validateRequest` reject | `auth.forbidden` (or specific predicate code) | originator ack |
196
+ | Auth | `preSyncAuthorize` stop | hook's `errorCode` | originator ack |
197
+ | Rate limit | Per-route or per-IP bucket | `sync.rateLimitExceeded` | originator ack |
198
+ | Validation | Zod fail | `sync.invalidInputType` | originator ack |
199
+ | Server execution | Thrown | `sync.serverExecutionFailed` | originator ack |
200
+ | Server return | `status: 'error'` from `_server` | route-supplied `errorCode` | originator ack |
201
+ | Server return | Anything other than `success`/`error` | `sync.invalidServerResponse` | originator ack |
202
+ | Fanout | No sockets in room | `sync.noReceiversFound` | originator ack |
203
+ | Fanout | `preSyncFanout` stop | hook's `errorCode` | originator ack |
204
+ | Per-recipient | `_client` thrown | `sync.clientExecutionFailed` | that recipient only |
205
+ | Per-recipient | `_client` returned `status: 'error'` | route code or `sync.clientRejected` | that recipient only |
206
+ | Per-recipient | `_client` returned non-success | `sync.invalidClientResponse` | that recipient only |
207
+
208
+ Full catalog including HTTP-status mapping in [`./error-states.md`](./error-states.md).
209
+
210
+ ---
211
+
212
+ ## 7. Example A — server-only sync (no `_client`)
213
+
214
+ ```ts
215
+ // src/notifications/_sync/markRead_server_v1.ts
216
+ export const auth = { login: true };
217
+
218
+ export interface SyncParams {
219
+ clientInput: { notificationId: string };
220
+ user: SessionLayout;
221
+ functions: Functions;
222
+ roomCode: string;
223
+ }
224
+
225
+ export const main = async ({ clientInput, user, functions }: SyncParams) => {
226
+ await functions.db.notification.update({
227
+ where: { id: clientInput.notificationId, userId: user.id },
228
+ data: { readAt: new Date() },
229
+ });
230
+ return { status: 'success', notificationId: clientInput.notificationId };
231
+ };
232
+ ```
233
+
234
+ No `_client` file. Every recipient in `roomCode` receives `{ serverOutput: { notificationId }, clientOutput: {} }`.
235
+
236
+ ---
237
+
238
+ ## 8. Example B — server + client per-recipient filter
239
+
240
+ ```ts
241
+ // src/board/_sync/showCard_server_v1.ts
242
+ export const auth = { login: true };
243
+
244
+ export const main = async ({ clientInput, functions }: SyncParams) => {
245
+ const card = await functions.db.card.findUnique({ where: { id: clientInput.cardId } });
246
+ if (!card) return { status: 'error', errorCode: 'board.cardNotFound' };
247
+ return { status: 'success', card };
248
+ };
249
+ ```
250
+
251
+ ```ts
252
+ // src/board/_sync/showCard_client_v1.ts — strip privateNotes from non-owners
253
+ export const main = async ({ token, serverOutput, functions }: SyncClientParams) => {
254
+ const recipient = await functions.session.getSession(token);
255
+ if (!recipient) return { status: 'success', card: { ...serverOutput.card, privateNotes: null } };
256
+
257
+ if (recipient.id !== serverOutput.card.ownerId) {
258
+ return { status: 'success', card: { ...serverOutput.card, privateNotes: null } };
259
+ }
260
+ return { status: 'success', card: serverOutput.card };
261
+ };
262
+ ```
263
+
264
+ Recipients see:
265
+
266
+ - Owner: `{ serverOutput, clientOutput: { card: { privateNotes: '...' } } }`
267
+ - Anyone else: `{ serverOutput, clientOutput: { card: { privateNotes: null } } }`
268
+
269
+ Note: `serverOutput` is the same payload for every recipient — only `clientOutput` differs. The client-side handler chooses which to render.
270
+
271
+ ---
272
+
273
+ ## 9. Example C — server + client custom `clientOutput` (translation)
274
+
275
+ ```ts
276
+ // src/chat/_sync/announce_server_v1.ts
277
+ export const main = async ({ clientInput }: SyncParams) => ({
278
+ status: 'success',
279
+ i18nKey: clientInput.i18nKey,
280
+ params: clientInput.params,
281
+ });
282
+ ```
283
+
284
+ ```ts
285
+ // src/chat/_sync/announce_client_v1.ts — pre-translate per recipient locale
286
+ export const main = async ({ token, serverOutput, functions }: SyncClientParams) => {
287
+ const recipient = await functions.session.getSession(token);
288
+ const locale = recipient?.language ?? 'en';
289
+ const text = functions.translator.translate({ key: serverOutput.i18nKey, params: serverOutput.params, locale });
290
+ return { status: 'success', text };
291
+ };
292
+ ```
293
+
294
+ This is the canonical reason to add `_client`: the translation cost is per-locale, the source key is shared, and centralizing it in `_client` keeps the room mutation single-source.
295
+
296
+ ---
297
+
298
+ ## 10. Related hooks
299
+
300
+ | Hook | Stage | Stop semantics |
301
+ |---|---|---|
302
+ | `preSyncAuthorize` | After `AuthProps` gate, before rate-limit | Stop -> originator error envelope with hook's `errorCode` |
303
+ | `preSyncFanout` | After `_server` runs, before any recipient receives | Stop -> originator error envelope; nobody gets the payload |
304
+ | `postSyncFanout` | After all recipients emitted | Observation-only; receives `recipientCount` |
305
+ | `rateLimitExceeded` | When per-route or per-IP bucket rejects | Observation-only |
306
+ | `preSyncStream` / `postSyncStream` | Per stream chunk emit | Observation-only |
307
+
308
+ Hook payload shapes live in `@luckystack/core` (`HookPayloads`).
309
+
310
+ ---
311
+
312
+ ## 11. Related
313
+
314
+ - Originator API: [`./sync-request.md`](./sync-request.md)
315
+ - Streaming primitives: [`./streaming.md`](./streaming.md)
316
+ - Room mechanics + fanout hooks: [`./room-fanout.md`](./room-fanout.md)
317
+ - Skip-self semantics: [`./ignore-self.md`](./ignore-self.md)
318
+ - Recipient subscription: [`./callback-registration.md`](./callback-registration.md)
319
+ - Version coexistence: [`./version-policy.md`](./version-policy.md)
320
+ - Full architecture: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md)
321
+ - Routing conventions: [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md)
@@ -0,0 +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.*`