@luckystack/sync 0.10.2 → 0.11.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/AGENTS.md +9 -0
- package/CHANGELOG.md +7 -0
- package/README.md +2 -150
- package/dist/_shared/clientFanout.d.ts +39 -0
- package/dist/_shared/errorBuilders.d.ts +35 -0
- package/dist/_shared/errorCodes.d.ts +3 -0
- package/dist/_shared/logFlags.d.ts +2 -0
- package/dist/_shared/receiverAuth.d.ts +34 -0
- package/dist/_shared/redactToken.d.ts +10 -0
- package/dist/_shared/streamEmitters.d.ts +51 -0
- package/dist/_shared/syncTypes.d.ts +74 -0
- package/dist/_shared/validationMode.d.ts +2 -0
- package/dist/client.d.ts +4 -129
- package/dist/handleHttpSyncRequest.d.ts +33 -0
- package/dist/handleSyncRequest.d.ts +7 -0
- package/dist/index.d.ts +7 -91
- package/dist/index.js.map +1 -1
- package/dist/streamThrottle.d.ts +45 -0
- package/dist/syncRequest.d.ts +126 -0
- package/docs/callback-registration.md +3 -255
- package/docs/error-states.md +11 -251
- package/docs/ignore-self.md +3 -160
- package/docs/room-fanout.md +11 -247
- package/docs/server-vs-client-handlers.md +5 -321
- package/docs/streaming.md +3 -346
- package/docs/sync-request.md +3 -282
- package/docs/version-policy.md +3 -360
- package/package.json +4 -4
- package/CLAUDE.md +0 -128
- package/dist/errorCodes-B8GoTenr.d.ts +0 -5
package/docs/streaming.md
CHANGED
|
@@ -1,348 +1,5 @@
|
|
|
1
|
-
# streaming
|
|
1
|
+
# Sync streaming
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Streaming communicates progress, not a replacement for the final request result. An originator-targeted stream, a room broadcast and a selected-recipient stream have different audiences: do not substitute one for another, particularly for private data. Recipient-side streaming is an output concern, not permission to broadcast to other viewers. Source: `../src/_shared/streamEmitters.ts`, `../src/_shared/clientFanout.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
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 error-tracker 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
|
-
- Full architecture: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md#streaming)
|
|
348
|
-
- Throttle config: `projectConfig.sync.streamThrottle.*`
|
|
5
|
+
Delivery must tolerate slow connections without blocking unrelated requests; producer throttling and pressure handling protect memory and responsiveness. Stream hooks are observational so slow tracking cannot stall chunks; abort/reset must not flush stale partial content into a later request. The HTTP/SSE fallback needs the same auth/receiver checks even though it has a different originator transport. See `../src/streamThrottle.ts`, `../src/handleHttpSyncRequest.ts`.
|
package/docs/sync-request.md
CHANGED
|
@@ -1,284 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sync request
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The caller asks the server to apply a mutation and selects a receiver; the receiver name is not proof of permission. The request must pass authorization before mutation and fanout on both socket and HTTP/SSE transports. A successful acknowledgement reports the server result; it does not guarantee every recipient accepted the event. See `../src/syncRequest.ts`, `../src/handleSyncRequest.ts`, `../src/handleHttpSyncRequest.ts`, `./server-vs-client-handlers.md`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. Function signature
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
import { syncRequest } from '@luckystack/sync/client';
|
|
13
|
-
|
|
14
|
-
await syncRequest({
|
|
15
|
-
name, // typed string literal — see `SyncFullName`
|
|
16
|
-
version, // typed string literal — see `VersionsForFullName<name>`
|
|
17
|
-
data, // typed via generated `SyncTypeMap` — required iff non-empty
|
|
18
|
-
receiver, // room code, or 'all' (NOT recommended in production)
|
|
19
|
-
ignoreSelf, // optional, default false
|
|
20
|
-
onStream, // optional originator-side stream callback
|
|
21
|
-
offlineDropPolicy // optional per-request offline-queue policy
|
|
22
|
-
});
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
The signature is a type-driven discriminated union — under the hood `SyncParamsForFullName<F, V>` branches on `DataRequired<ClientInput>`:
|
|
26
|
-
|
|
27
|
-
- If the route's `clientInput` type allows `{}`, `data` is optional.
|
|
28
|
-
- If the route's `clientInput` carries any required key (or is a union without `{}`), `data` is required at compile time.
|
|
29
|
-
|
|
30
|
-
This is enforced through generated `SyncTypeMap` entries — no runtime check fills it in.
|
|
31
|
-
|
|
32
|
-
---
|
|
33
|
-
|
|
34
|
-
## 2. Argument breakdown
|
|
35
|
-
|
|
36
|
-
### `name: F extends SyncFullName`
|
|
37
|
-
|
|
38
|
-
`SyncFullName` is the union of every discovered sync route, computed from `SyncTypeMap`:
|
|
39
|
-
|
|
40
|
-
- `src/{page}/_sync/{name}_server_v{N}.ts` -> `'{page}/{name}'`
|
|
41
|
-
- `src/_sync/{name}_server_v{N}.ts` (root-level) -> `'system/{name}'`
|
|
42
|
-
|
|
43
|
-
Always pass the literal string. Any narrowing layer (`as any`, `as SyncFullName`, manual cast helpers) breaks inference and **violates rule 21** in the repository-root `CLAUDE.md` (no `unknown`/`any` casts on typed transports).
|
|
44
|
-
|
|
45
|
-
### `version: V extends VersionsForFullName<F>`
|
|
46
|
-
|
|
47
|
-
String-literal version key (`'v1'`, `'v2'`, ...). One version per source file. See [`./version-policy.md`](./version-policy.md) for the deprecation flow when you need to ship a `v2` while `v1` consumers still exist.
|
|
48
|
-
|
|
49
|
-
### `data: ClientInputForFullName<F, V>`
|
|
50
|
-
|
|
51
|
-
Whatever `clientInput` your `_server_v{N}.ts` declares. Goes through Zod validation server-side via `validateInputByType` against the schema generated by `@luckystack/devkit`.
|
|
52
|
-
|
|
53
|
-
Validation lifecycle (server side):
|
|
54
|
-
|
|
55
|
-
1. Empty / non-object payload -> `sync.invalidRequest`.
|
|
56
|
-
2. Schema generated but payload fails -> `sync.invalidInputType` with `errorParams: [{ key: 'message', value: <readable reason> }]`.
|
|
57
|
-
3. Route has no `inputType` (Zod schema not generated yet) -> validation skipped, dev-only warn-log fires on first miss.
|
|
58
|
-
|
|
59
|
-
### `receiver: string`
|
|
60
|
-
|
|
61
|
-
Identifies the fanout target.
|
|
62
|
-
|
|
63
|
-
- A **room code** (e.g. `'Ag2cg4'`, `'board-123'`, the session token of a specific user). Sockets join rooms via `joinRoom` / `leaveRoom` from `@luckystack/core` (client).
|
|
64
|
-
- The literal `'all'` -> every connected socket on the current instance. Avoid in production: every `getRuntimeSyncMaps()` lookup runs once but the per-recipient loop runs O(N). The framework yields to the event loop every `sync.fanoutYieldEvery` recipients to avoid starving other requests, but burning N sockets per write is still a smell.
|
|
65
|
-
- Empty / non-string -> `sync.missingReceiver`.
|
|
66
|
-
|
|
67
|
-
Every socket auto-joins a room named after its own session token at connect time. That is how `streamTo(token, ...)` reaches a specific user across all of their devices.
|
|
68
|
-
|
|
69
|
-
### `ignoreSelf?: boolean` (default `false`)
|
|
70
|
-
|
|
71
|
-
Skip the originator's own sockets during fanout. See [`./ignore-self.md`](./ignore-self.md) — short version: turn it on when the sender already applied the optimistic change locally and only needs other clients to be told.
|
|
72
|
-
|
|
73
|
-
### `onStream?: SyncRequestStreamCallback`
|
|
74
|
-
|
|
75
|
-
Originator-side stream listener. Subscribes to the `buildSyncProgressEventName(responseIndex)` channel for the lifetime of this single request, then unsubscribes the moment the final ack lands. Receives **only originator-targeted** chunks emitted via `stream(payload)` from the `_server` handler (not `broadcastStream` / `streamTo` — those flow through the normal `upsertSyncEventStreamCallback` registry).
|
|
76
|
-
|
|
77
|
-
Typed via `SyncRequestStreamEvent<ServerStream>` — if the route never streams to the originator, the callback type collapses to `never` and the TS compiler will reject the call site.
|
|
78
|
-
|
|
79
|
-
### `offlineDropPolicy?: 'reject' | 'drop-oldest' | 'drop-newest'`
|
|
80
|
-
|
|
81
|
-
Per-request override of `projectConfig.offlineQueue.dropPolicy`. When the socket is offline (disconnected or browser is offline), `syncRequest` does **not** reject — it queues the request in `enqueueSyncRequest` and replays on reconnect. If the queue is full:
|
|
82
|
-
|
|
83
|
-
- `'reject'` (default) — the request resolves with `errorCode: 'offline.queueFull'`.
|
|
84
|
-
- `'drop-oldest'` — the oldest queued request is evicted; this one joins the queue.
|
|
85
|
-
- `'drop-newest'` — this request is dropped at the door; the queue stays intact.
|
|
86
|
-
|
|
87
|
-
Per-request override exists so a noisy ephemeral event (`'editor cursor move'`) can pick `'drop-oldest'` while the global default stays `'reject'` for safer mutations.
|
|
88
|
-
|
|
89
|
-
---
|
|
90
|
-
|
|
91
|
-
## 3. Response envelope
|
|
92
|
-
|
|
93
|
-
`syncRequest` always resolves (never rejects). The promise type is `SyncRequestResponseForFullName<F, V>`:
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
type SyncRequestResponseForFullName<F, V> =
|
|
97
|
-
| { status: 'error'; message: string; errorCode: string; errorParams?: SyncErrorParam[]; httpStatus?: number }
|
|
98
|
-
| { status: 'success'; message: string; result: ServerOutputForFullName<F, V> };
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
### Success branch
|
|
102
|
-
|
|
103
|
-
```ts
|
|
104
|
-
{
|
|
105
|
-
status: 'success',
|
|
106
|
-
message: string, // server-provided or `sync ${name} success`
|
|
107
|
-
result: ServerOutput // exactly what _server_v{N}.ts returned, minus its `status` field
|
|
108
|
-
}
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
`result` is typed via `ServerOutputForFullName<F, V>` -> the same shape `_server_v{N}.ts`'s `main(...)` declared. **Never** narrow this with `as any` / `as unknown`; if inference fails the generated type map is wrong (regenerate via `npm run ai:index`).
|
|
112
|
-
|
|
113
|
-
### Error branch
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
{
|
|
117
|
-
status: 'error',
|
|
118
|
-
message: string, // localized via normalizeErrorResponse
|
|
119
|
-
errorCode: string, // see error-states.md for the full catalog
|
|
120
|
-
errorParams?: [{ key, value }], // i18n interpolation slots
|
|
121
|
-
httpStatus?: number // 401/403/429/500 typically
|
|
122
|
-
}
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Localization runs through `normalizeErrorResponse({ response, preferredLocale, userLanguage })` server-side, then again client-side through `normalizeErrorResponseCore`. Order of preference for the locale: `x-language` header -> `accept-language` header -> `user.language` from session.
|
|
126
|
-
|
|
127
|
-
Full catalog of `errorCode` values in [`./error-states.md`](./error-states.md).
|
|
128
|
-
|
|
129
|
-
---
|
|
130
|
-
|
|
131
|
-
## 4. Lifecycle (originator side)
|
|
132
|
-
|
|
133
|
-
```
|
|
134
|
-
syncRequest({ name, version, data, receiver, ... })
|
|
135
|
-
|
|
|
136
|
-
v
|
|
137
|
-
1. Validate arguments locally:
|
|
138
|
-
- name -> sync.invalidName / routing.invalidServiceRouteName
|
|
139
|
-
- version -> sync.invalidVersion
|
|
140
|
-
- receiver -> sync.missingReceiver
|
|
141
|
-
2. waitForSocket() — ensures the socket has booted
|
|
142
|
-
- times out -> sync.ioUnavailable
|
|
143
|
-
3. canSendNow(socket):
|
|
144
|
-
- false (offline / disconnected) -> enqueueSyncRequest({...})
|
|
145
|
-
- queue full + dropPolicy='reject' -> offline.queueFull
|
|
146
|
-
- otherwise: queued, replays on `connect`
|
|
147
|
-
4. responseIndex = incrementResponseIndex()
|
|
148
|
-
5. Subscribe to buildSyncProgressEventName(responseIndex) if onStream
|
|
149
|
-
6. socket.emit('sync', { name, data, cb, receiver, responseIndex, ignoreSelf })
|
|
150
|
-
7. socket.once(buildSyncResponseEventName(responseIndex)):
|
|
151
|
-
- status: 'error' -> normalizeSyncError -> resolve(errorEnvelope)
|
|
152
|
-
- status: 'success' -> resolve({ status: 'success', message, result })
|
|
153
|
-
- other -> sync.invalidServerResponse
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
The progress listener cleans itself up in the response handler. Even if the request errors out, `onStream` is unsubscribed before the promise resolves.
|
|
157
|
-
|
|
158
|
-
---
|
|
159
|
-
|
|
160
|
-
## 5. Concrete examples
|
|
161
|
-
|
|
162
|
-
### A. Mutation broadcast (the 90% case)
|
|
163
|
-
|
|
164
|
-
```ts
|
|
165
|
-
const response = await syncRequest({
|
|
166
|
-
name: 'board/moveCard',
|
|
167
|
-
version: 'v1',
|
|
168
|
-
data: { cardId, toLane },
|
|
169
|
-
receiver: roomCode,
|
|
170
|
-
ignoreSelf: true, // we already moved it optimistically
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
if (response.status !== 'success') {
|
|
174
|
-
notify.error({ key: response.errorCode });
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
// `response.result` is typed as ServerOutput for board/moveCard v1
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
### B. Optimistic send with offline tolerance
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
// Cursor moves are spammy and disposable — drop oldest if offline queue fills up.
|
|
184
|
-
await syncRequest({
|
|
185
|
-
name: 'editor/cursorMove',
|
|
186
|
-
version: 'v1',
|
|
187
|
-
data: { x, y },
|
|
188
|
-
receiver: documentId,
|
|
189
|
-
offlineDropPolicy: 'drop-oldest',
|
|
190
|
-
});
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
### C. Stream consumer (originator)
|
|
194
|
-
|
|
195
|
-
```ts
|
|
196
|
-
let buffer = '';
|
|
197
|
-
const response = await syncRequest({
|
|
198
|
-
name: 'chat/sendMessage',
|
|
199
|
-
version: 'v1',
|
|
200
|
-
data: { prompt },
|
|
201
|
-
receiver: chatRoomId,
|
|
202
|
-
onStream: ({ chunk }) => {
|
|
203
|
-
buffer += chunk;
|
|
204
|
-
setPreview(buffer);
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
if (response.status === 'success') {
|
|
209
|
-
commitMessage(response.result.messageId, buffer);
|
|
210
|
-
}
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
For broadcast streams (where everyone in the room — including the sender — sees the chunks) prefer `upsertSyncEventStreamCallback` instead of `onStream`. See [`./streaming.md`](./streaming.md).
|
|
214
|
-
|
|
215
|
-
---
|
|
216
|
-
|
|
217
|
-
## 6. HTTP / SSE fallback (`handleHttpSyncRequest`)
|
|
218
|
-
|
|
219
|
-
When the Socket.io transport is unavailable (corporate firewalls, mobile carriers blocking websockets), `@luckystack/server` exposes a POST endpoint that calls `handleHttpSyncRequest` directly. The pipeline is identical to the socket variant except for:
|
|
220
|
-
|
|
221
|
-
| Stage | Socket transport | HTTP transport |
|
|
222
|
-
|---|---|---|
|
|
223
|
-
| Originator chunks (`stream(payload)`) | `socket.emit(progressEventName, payload)` | `stream?.(payload)` injected callback → SSE writer |
|
|
224
|
-
| Recipients (`broadcastStream`, `streamTo`) | Socket.io fanout | Socket.io fanout (recipients still on sockets) |
|
|
225
|
-
| Auto-instrumented error-tracker span | none | `'sync.request.http'` |
|
|
226
|
-
| Final ack | `socket.emit(buildSyncResponseEventName(responseIndex), envelope)` | HTTP response body (`HttpSyncResponse`) |
|
|
227
|
-
|
|
228
|
-
Signature:
|
|
229
|
-
|
|
230
|
-
```ts
|
|
231
|
-
async function handleHttpSyncRequest({
|
|
232
|
-
name: string; // 'sync/<page>/<name>/v<N>' or '<page>/<name>/v<N>'
|
|
233
|
-
cb?: string; // callback handle used in recipient emits
|
|
234
|
-
data: Record<string, unknown>;
|
|
235
|
-
receiver: string;
|
|
236
|
-
ignoreSelf?: boolean;
|
|
237
|
-
token: string | null;
|
|
238
|
-
requesterIp?: string; // for IP rate-limit bucket
|
|
239
|
-
xLanguageHeader?: string | string[];
|
|
240
|
-
acceptLanguageHeader?: string | string[];
|
|
241
|
-
stream?: (payload: HttpSyncStreamEvent) => void;
|
|
242
|
-
}): Promise<HttpSyncResponse>
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
`HttpSyncStreamEvent` is an alias of `SyncStreamPayload` (`Record<string, unknown>`). The server side typically wires `stream` to a Server-Sent-Events writer that flushes each chunk as `event: stream\ndata: <json>\n\n`.
|
|
246
|
-
|
|
247
|
-
The client side ships with no built-in SSE adapter — `syncRequest` always uses Socket.io. Adding an HTTP-fallback client is a project-level decision; the server side already supports it.
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
## 7. Interaction with the offline queue
|
|
252
|
-
|
|
253
|
-
Queue identity:
|
|
254
|
-
|
|
255
|
-
- Each queued request gets a `queueId = ${Date.now()}-${Math.random()}` on first queue (not on retries).
|
|
256
|
-
- The queue key is the full sync route name (`sync/<page>/<name>/v<N>`) for ordering and (optionally) coalescing in `enqueueSyncRequest`.
|
|
257
|
-
- The `run` closure captures the original arguments — when the socket reconnects, the queued call replays through the same `runRequest(socket)` body that originally enqueued it.
|
|
258
|
-
|
|
259
|
-
Replay order is FIFO unless `dropPolicy` mutates the queue. A successful replay resolves the original `syncRequest` promise; the caller's `await` simply takes longer.
|
|
260
|
-
|
|
261
|
-
Failures during replay surface the same way as if the call had run online — the promise resolves with `status: 'error'`, the caller branches on `errorCode`.
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
## 8. Types reference
|
|
266
|
-
|
|
267
|
-
| Type | Purpose | Exported from |
|
|
268
|
-
|---|---|---|
|
|
269
|
-
| `SyncRequestStreamEvent<T>` | Payload shape passed to `onStream` (originator side) | `@luckystack/sync/client` |
|
|
270
|
-
| `SyncRouteStreamEvent<T>` | Payload shape passed to `upsertSyncEventStreamCallback` (any recipient) | `@luckystack/sync/client` |
|
|
271
|
-
| `HttpSyncStreamEvent` | SSE event shape emitted by the HTTP fallback | `@luckystack/sync` |
|
|
272
|
-
| `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle handle + options | `@luckystack/sync` |
|
|
273
|
-
|
|
274
|
-
---
|
|
275
|
-
|
|
276
|
-
## 9. Related
|
|
277
|
-
|
|
278
|
-
- Handler authoring: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
|
|
279
|
-
- Stream primitives: [`./streaming.md`](./streaming.md)
|
|
280
|
-
- Subscribing to results: [`./callback-registration.md`](./callback-registration.md)
|
|
281
|
-
- Error catalog: [`./error-states.md`](./error-states.md)
|
|
282
|
-
- Versioning: [`./version-policy.md`](./version-policy.md)
|
|
283
|
-
- Offline queue config: `@luckystack/core` `OfflineQueueConfig` (`projectConfig.offlineQueue`)
|
|
284
|
-
- Architecture deep-dive: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md)
|
|
5
|
+
Offline queuing is for transient transport loss, not a bypass of server validation or room membership on replay. Regenerate generated types rather than casting a call around a version mismatch. For request types, envelopes and retry policy consult `../src/syncRequest.ts` and `../../core/src/offlineQueue.ts`.
|