@luckystack/sync 0.1.1 → 0.1.3
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/CHANGELOG.md +14 -14
- package/docs/callback-registration.md +257 -257
- package/docs/error-states.md +252 -252
- package/docs/ignore-self.md +162 -162
- package/docs/room-fanout.md +233 -233
- package/docs/server-vs-client-handlers.md +321 -321
- package/docs/sync-request.md +284 -284
- package/docs/version-policy.md +362 -362
- package/package.json +4 -4
package/docs/sync-request.md
CHANGED
|
@@ -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)
|