@luckystack/sync 0.4.1 → 0.5.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.
@@ -1,284 +1,284 @@
1
- # sync-request
2
-
3
- > Type-safe fire-and-broadcast call from the browser into a Socket.io room. Resolves with the server result envelope after the originator's ack lands; recipients receive the merged `{ serverOutput, clientOutput }` payload via `upsertSyncEventCallback`. The HTTP/SSE fallback (`handleHttpSyncRequest`) keeps the same pipeline alive when websockets are blocked.
4
-
5
- Documents the **caller-facing transport API**: how `syncRequest` is invoked, the response envelope, and the rare HTTP-fallback path. For handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md); for callback subscription see [`./callback-registration.md`](./callback-registration.md).
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 16** in the 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
- | Sentry span | `'sync.request'` | `'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)
1
+ # sync-request
2
+
3
+ > Type-safe fire-and-broadcast call from the browser into a Socket.io room. Resolves with the server result envelope after the originator's ack lands; recipients receive the merged `{ serverOutput, clientOutput }` payload via `upsertSyncEventCallback`. The HTTP/SSE fallback (`handleHttpSyncRequest`) keeps the same pipeline alive when websockets are blocked.
4
+
5
+ Documents the **caller-facing transport API**: how `syncRequest` is invoked, the response envelope, and the rare HTTP-fallback path. For handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md); for callback subscription see [`./callback-registration.md`](./callback-registration.md).
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 16** in the 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
+ | Sentry span | `'sync.request'` | `'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)