@luckystack/sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ # error-states
2
+
3
+ > Authoritative catalog of error codes emitted by `syncRequest` (client) and `handleSyncRequest` / `handleHttpSyncRequest` (server). Documents HTTP-status mapping, locale-aware message normalization, `errorParams` interpolation slots, the `rateLimitExceeded` hook payload, and Sentry capture behavior.
4
+
5
+ For the originator response envelope shape see [`./sync-request.md`](./sync-request.md). For the full lifecycle these errors slot into see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
6
+
7
+ ---
8
+
9
+ ## 1. Error envelope shape
10
+
11
+ Every sync error — whether produced client-side before sending or server-side at any stage — collapses to the same shape after `normalizeErrorResponse`:
12
+
13
+ ```ts
14
+ {
15
+ status: 'error',
16
+ message: string, // localized via preferredLocale / userLanguage
17
+ errorCode: string, // see catalog below
18
+ errorParams?: { key, value }[], // i18n interpolation slots
19
+ httpStatus?: number, // inferred via defaultHttpStatusForResponse
20
+ }
21
+ ```
22
+
23
+ `message` is **always** the localized string for `errorCode` (with `errorParams` interpolated). The default i18n catalog ships built-in keys for every framework code; project-defined codes use whatever the project i18n catalog defines.
24
+
25
+ ---
26
+
27
+ ## 2. Client-side error codes
28
+
29
+ Produced by `syncRequest` BEFORE the message ever hits the wire.
30
+
31
+ | `errorCode` | Trigger | `httpStatus` (default) | `errorParams` |
32
+ |---|---|---|---|
33
+ | `sync.invalidName` | `name` is missing or not a string | n/a (local) | — |
34
+ | `routing.invalidServiceRouteName` | `name` doesn't match `<page>/<route>` shape | n/a (local) | `[{ key: 'name', value: <input name> }]` |
35
+ | `sync.invalidVersion` | `version` is missing or not a string | n/a (local) | — |
36
+ | `sync.missingReceiver` | `receiver` is missing or empty after trim | n/a (local) | — |
37
+ | `sync.ioUnavailable` | Socket never initialized or `waitForSocket()` timed out | n/a (local) | — |
38
+ | `offline.queueFull` | Socket offline + queue full + `dropPolicy: 'reject'` | n/a (local) | — |
39
+ | `sync.failedRequest` | Catch-all fallback for malformed ack (used in dev notification) | n/a (local) | `[{ key: 'name', ...}, { key: 'message', ...}]` |
40
+ | `sync.invalidServerResponse` | Server ack missing `status: 'success'` and no recognized error code | inherited from server response (if any) | — |
41
+
42
+ Local errors never travel over the wire — they short-circuit inside `syncRequest`'s promise body and resolve immediately with the error envelope. `httpStatus` doesn't apply (there was no HTTP).
43
+
44
+ ---
45
+
46
+ ## 3. Server-side error codes (originator-visible)
47
+
48
+ Emitted by `handleSyncRequest` / `handleHttpSyncRequest` to the originator's ack channel (`buildSyncResponseEventName(responseIndex)` for socket, HTTP response body for HTTP).
49
+
50
+ | `errorCode` | Trigger | `httpStatus` | `errorParams` |
51
+ |---|---|---|---|
52
+ | `sync.invalidRequest` | `msg` is not an object, or `name`/`data` missing or wrong type | `400` | — |
53
+ | `routing.invalidServiceRouteName` | `parseTransportRouteName` failed | `400` | `[{ key: 'name', value: <input name> }]` |
54
+ | `sync.invalidCallback` | `cb` is missing or not a string | `400` | — |
55
+ | `sync.missingReceiver` | `receiver` is empty after trim | `400` | — |
56
+ | `sync.notFound` | Neither `_server_v{N}` nor `_client_v{N}` exists for this route | `404` | — |
57
+ | `auth.required` | `auth.login === true` on `_server` and no session resolved | `401` | — |
58
+ | `auth.forbidden` (or route-specific code from `validateRequest`) | `validateRequest(auth.additional)` rejected | `403` (default; some predicates override) | predicate-defined |
59
+ | `sync.rateLimitExceeded` | Per-route or per-IP rate-limit bucket rejected | `429` | `[{ key: 'seconds', value: <retry in> }]` |
60
+ | `sync.invalidInputType` | `validateInputByType` rejected (Zod schema mismatch) | `400` | `[{ key: 'message', value: <readable reason> }]` |
61
+ | `sync.serverExecutionFailed` | `_server`'s `main(...)` threw (caught by `tryCatch`) | `500` | — |
62
+ | `<route-supplied>` (e.g. `board.cardNotFound`) | `_server` returned `{ status: 'error', errorCode }` | `defaultHttpStatusForResponse` or as-provided | as-provided |
63
+ | `sync.invalidServerResponse` | `_server` returned a value with `status` other than `'success'` / `'error'` | `500` | — |
64
+ | `sync.noReceiversFound` | Resolved sockets is `undefined` or empty | `404` | — |
65
+ | `<hook-supplied>` | `preSyncAuthorize` / `preSyncFanout` stop signal | from hook | from hook |
66
+
67
+ The `preSyncAuthorize` and `preSyncFanout` hooks can stop with any `errorCode` they choose; the framework normalizes the message via `normalizeErrorResponse` using the originator's locale.
68
+
69
+ ---
70
+
71
+ ## 4. Server-side error codes (per-recipient, fanout step)
72
+
73
+ Emitted to a **single recipient** when `_client_v{N}.ts` execution fails for them only. These do NOT propagate back to the originator and do NOT abort the fanout loop.
74
+
75
+ | `errorCode` | Trigger | `httpStatus` | `errorParams` |
76
+ |---|---|---|---|
77
+ | `sync.clientExecutionFailed` | `_client`'s `main(...)` threw (caught by per-recipient `tryCatch`) | `500` | — |
78
+ | `<route-supplied>` (e.g. `chat.translationFailed`) | `_client` returned `{ status: 'error', errorCode }` | as-provided | as-provided |
79
+ | `sync.clientRejected` | `_client` returned `{ status: 'error' }` without an `errorCode` | `500` (default) | — |
80
+ | `sync.invalidClientResponse` | `_client` returned something other than `'success'` / `'error'` | `500` | — |
81
+
82
+ Recipients see these on their own `socketEventNames.sync` channel as if they were normal sync frames, just with `status: 'error'` set. Subscribers in `upsertSyncEventCallback` that branch on `status` will see the error variant.
83
+
84
+ ---
85
+
86
+ ## 5. HTTP-status mapping
87
+
88
+ `httpStatus` defaults via `defaultHttpStatusForResponse(errorCode)` in `@luckystack/core`. Default rules (paraphrased — see core for exact source):
89
+
90
+ - `auth.required` -> `401`
91
+ - `auth.forbidden` and similar role/policy codes -> `403`
92
+ - Validation failures (`*.invalidRequest`, `*.invalidInputType`, `*.invalidName`, `routing.invalidServiceRouteName`) -> `400`
93
+ - Not-found (`*.notFound`, `sync.noReceiversFound`) -> `404`
94
+ - Rate limit (`*.rateLimitExceeded`) -> `429`
95
+ - Execution failures (`*.ExecutionFailed`, `*.invalid*Response`) -> `500`
96
+
97
+ A hook or route can override the default by returning `httpStatus: <number>` in its stop signal or error payload. Custom codes inherit the fallback `500` unless `defaultHttpStatusForResponse` is extended in core.
98
+
99
+ ---
100
+
101
+ ## 6. `errorParams` localization slots
102
+
103
+ `errorParams` is an array of `{ key, value }` pairs interpolated into the i18n message template. Framework codes use:
104
+
105
+ - `sync.rateLimitExceeded` -> `[{ key: 'seconds', value: <number> }]` — the seconds until the next request is allowed. Template: "Rate limited, retry in {{seconds}}s".
106
+ - `sync.invalidInputType` -> `[{ key: 'message', value: <Zod reason> }]` — the Zod error message. Template: "Input validation failed: {{message}}".
107
+ - `sync.failedRequest` (dev notification) -> `[{ key: 'name', ...}, { key: 'message', ...}]`. Template: "Sync {{name}} failed: {{message}}".
108
+ - `routing.invalidServiceRouteName` -> `[{ key: 'name', value: <bad input> }]`. Template: "Invalid service route name: {{name}}".
109
+
110
+ Project codes can carry whatever slots their templates declare. The normalizer doesn't enforce a schema — it interpolates blindly.
111
+
112
+ ---
113
+
114
+ ## 7. `rateLimitExceeded` hook payload
115
+
116
+ Fired whenever a rate-limit bucket rejects a sync request. The payload differs by `scope`:
117
+
118
+ ### Scope `'user'` or `'route'` (per-route bucket, identified by token or by IP)
119
+
120
+ ```ts
121
+ {
122
+ scope: 'user' | 'route',
123
+ key: string, // 'token:<token>:sync:<routeName>' or 'ip:<ip>:sync:<routeName>'
124
+ limit: number, // projectConfig.rateLimiting.defaultApiLimit
125
+ windowMs: number, // projectConfig.rateLimiting.windowMs
126
+ count: number, // limit + 1 (the request that tipped over)
127
+ route: string, // e.g. 'board/moveCard/v1'
128
+ userId: string | undefined,
129
+ }
130
+ ```
131
+
132
+ `scope: 'user'` when the request carried a session token; `scope: 'route'` when it was anonymous (only an IP).
133
+
134
+ ### Scope `'ip'` (global per-IP bucket, across all sync routes)
135
+
136
+ ```ts
137
+ {
138
+ scope: 'ip',
139
+ key: string, // 'ip:<ip>:sync:all'
140
+ limit: number, // projectConfig.rateLimiting.defaultIpLimit
141
+ windowMs: number,
142
+ count: number,
143
+ ip: string,
144
+ }
145
+ ```
146
+
147
+ The hook is observation-only — there is no stop signal. The fanout has already been aborted by the time the hook fires.
148
+
149
+ Typical consumer: a mitigation system that bans the offending IP / user after N hook fires within a window.
150
+
151
+ ---
152
+
153
+ ## 8. Locale resolution
154
+
155
+ Both transports localize error messages through the same chain:
156
+
157
+ 1. **`preferredLocale`** — first non-empty match from headers:
158
+ - Socket: `socket.handshake.headers['x-language']` -> `socket.handshake.headers['accept-language']`.
159
+ - HTTP: `xLanguageHeader` argument -> `acceptLanguageHeader` argument.
160
+ 2. **`userLanguage`** — `user.language` from the resolved session.
161
+ 3. **Fallback** — `projectConfig.defaultLanguage`.
162
+
163
+ `extractLanguageFromHeader` accepts either a string or `string[]` (Node's headers can be either), trims whitespace, and picks the first listed language. The chain doesn't combine locales — first non-empty wins.
164
+
165
+ Recipients (per-recipient errors during fanout) use **their own** headers, not the sender's. That's why `handleSyncRequest`'s per-recipient error path re-extracts `tempSocket.handshake.headers` for each recipient.
166
+
167
+ ---
168
+
169
+ ## 9. Sentry capture via `tryCatch`
170
+
171
+ Every `_server` and `_client` execution is wrapped in `tryCatch(..., undefined, { handler, sync, stage, userId, ... })`. The fourth argument is the breadcrumb context that gets attached when `tryCatch` captures an exception.
172
+
173
+ Captured automatically:
174
+
175
+ - `_server` thrown errors -> Sentry breadcrumb: `{ handler: 'handleSyncRequest', sync: <route>, stage: 'server', userId, receiver, transport: 'socket' | 'http' }`.
176
+ - `_client` thrown errors -> Sentry breadcrumb: `{ handler: 'handleSyncRequest', sync: <route>, stage: 'client', sourceUserId, targetToken, receiver, transport }`.
177
+
178
+ NOT captured (returned as `status: 'error'` envelopes only):
179
+
180
+ - Validation failures (`sync.invalidInputType`) — these are caller errors, not server bugs.
181
+ - Auth rejects — these are policy outcomes.
182
+ - Rate-limit rejects — these are noise.
183
+ - `_server` / `_client` returning `{ status: 'error' }` voluntarily — the handler made a domain decision; nothing threw.
184
+
185
+ If you need to also Sentry-capture a voluntary error (e.g. "this code path should never trigger in production"), do it explicitly inside the handler via `@luckystack/error-tracking`'s `captureException`.
186
+
187
+ ---
188
+
189
+ ## 10. Offline queue overflow flow
190
+
191
+ When the socket is offline AND `dropPolicy: 'reject'` is active:
192
+
193
+ ```
194
+ syncRequest({ ..., offlineDropPolicy: 'reject' })
195
+ -> canSendNow(socket) === false
196
+ -> enqueueSyncRequest({ id, key, run, createdAt, dropPolicy: 'reject' })
197
+ -> queue full
198
+ -> enqueueSyncRequest returns false
199
+ -> resolve({
200
+ status: 'error',
201
+ errorCode: 'offline.queueFull',
202
+ message: <localized>,
203
+ httpStatus: undefined,
204
+ })
205
+ ```
206
+
207
+ With `dropPolicy: 'drop-oldest'` or `'drop-newest'`, the queue policy handles eviction silently and the caller's promise stays pending until the network comes back (then resolves on replay).
208
+
209
+ `offline.queueFull` is the only error code that fires when there is no server interaction at all — it's purely a client-side admission of "we cannot promise to deliver this if we accept it".
210
+
211
+ ---
212
+
213
+ ## 11. Dev-only side effects
214
+
215
+ When `projectConfig.logging.devLogs === true`:
216
+
217
+ - Local validation failures (`sync.invalidName`, `sync.missingReceiver`, etc.) log via `getLogger().error(...)`.
218
+ - Server returning `{ status: 'error' }` logs the normalized message at `getLogger().error(...)`.
219
+ - Auth rejects, validation rejects, etc., warn-log at `getLogger().warn(...)`.
220
+
221
+ When `projectConfig.logging.devNotifications === true` AND on the client:
222
+
223
+ - Most local validation failures call `notify.error({ key: '<errorCode>' })`.
224
+
225
+ These are dev hints, not production behavior. Toggle both to `false` for production to avoid leaking diagnostic info into user-facing notifications.
226
+
227
+ ---
228
+
229
+ ## 12. Quick lookup by symptom
230
+
231
+ | Symptom | Most likely code | Where to look |
232
+ |---|---|---|
233
+ | `syncRequest` returns immediately with error | `sync.missingReceiver`, `sync.invalidVersion`, `sync.ioUnavailable` | Client-side validation, §2 |
234
+ | Request reached server but auth rejected | `auth.required`, `auth.forbidden` | `_server`'s `auth` export, [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md) |
235
+ | 429 rate-limit responses | `sync.rateLimitExceeded` | `projectConfig.rateLimiting` |
236
+ | Server-side schema fail | `sync.invalidInputType` | `_server`'s `SyncParams.clientInput` interface + generated Zod schema |
237
+ | Handler threw | `sync.serverExecutionFailed` / `sync.clientExecutionFailed` | Sentry breadcrumb |
238
+ | Domain logic rejected | `<route-supplied errorCode>` | The `_server` or `_client` file's return path |
239
+ | Empty room | `sync.noReceiversFound` | `receiver` argument; check room membership |
240
+ | Offline + caller didn't await | `offline.queueFull` | Bump `offlineQueue.maxSize` or switch `dropPolicy` |
241
+
242
+ ---
243
+
244
+ ## 13. Related
245
+
246
+ - Originator response envelope: [`./sync-request.md`](./sync-request.md)
247
+ - Pipeline / lifecycle: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
248
+ - Rate limiting hook + buckets: [`./room-fanout.md`](./room-fanout.md) §5
249
+ - Hook payload shapes: `@luckystack/core` `HookPayloads`
250
+ - Status-code mapping source: `@luckystack/core` `defaultHttpStatusForResponse`
251
+ - Locale extraction: `@luckystack/core` `extractLanguageFromHeader`, `normalizeErrorResponse`
252
+ - Project config: `projectConfig.rateLimiting`, `projectConfig.offlineQueue`, `projectConfig.logging`
@@ -0,0 +1,162 @@
1
+ # ignore-self
2
+
3
+ > `ignoreSelf: true` on `syncRequest` tells `handleSyncRequest` to skip every recipient socket whose session token matches the sender's. The check is **token-based, not socket-id-based** — multi-tab users skip ALL of their own tabs. The originator's ack response still arrives.
4
+
5
+ For the full `syncRequest` signature see [`./sync-request.md`](./sync-request.md). For the fanout loop's surrounding mechanics see [`./room-fanout.md`](./room-fanout.md).
6
+
7
+ ---
8
+
9
+ ## 1. What the flag actually does
10
+
11
+ The originator passes `ignoreSelf: true`. Inside the per-recipient loop:
12
+
13
+ ```ts
14
+ const tempToken = extractTokenFromSocket(tempSocket);
15
+
16
+ if (ignoreSelf && typeof ignoreSelf === 'boolean' && token === tempToken) {
17
+ continue;
18
+ }
19
+
20
+ recipientCount++;
21
+ ```
22
+
23
+ `token` is the sender's session token (resolved from their socket at the top of `handleSyncRequest`). `tempToken` is the recipient's session token (extracted from THAT socket via `extractTokenFromSocket`).
24
+
25
+ When the two match:
26
+
27
+ - That recipient is **not** emitted to.
28
+ - That recipient does **not** count toward `recipientCount` (so `postSyncFanout` sees a number that reflects actual sends).
29
+ - The fanout loop continues immediately to the next socket.
30
+
31
+ The originator still receives:
32
+
33
+ - The normal ack via `buildSyncResponseEventName(responseIndex)` (the success/error envelope with `serverOutput`).
34
+ - Any chunks emitted via the originator-targeted `stream(payload)` (those flow on the originator's unicast progress channel, not the broadcast room).
35
+
36
+ What the originator does NOT receive when `ignoreSelf: true`:
37
+
38
+ - The room frame (the `{ serverOutput, clientOutput, ... status: 'success' }` payload that `upsertSyncEventCallback` consumes).
39
+ - `broadcastStream` and `streamTo` chunks targeted at their own token room.
40
+
41
+ ---
42
+
43
+ ## 2. When to use `ignoreSelf: true`
44
+
45
+ The canonical case: **optimistic UI**. The sender already applied the local change before calling `syncRequest`, so receiving the broadcast back just causes a no-op re-render (and risks visual flicker if the local optimistic state and the broadcast payload differ subtly).
46
+
47
+ ```ts
48
+ // Local update (optimistic):
49
+ setCards(prev => moveCardLocally(prev, cardId, toLane));
50
+
51
+ // Tell everyone else:
52
+ await syncRequest({
53
+ name: 'board/moveCard',
54
+ version: 'v1',
55
+ data: { cardId, toLane },
56
+ receiver: roomCode,
57
+ ignoreSelf: true,
58
+ });
59
+ ```
60
+
61
+ Other use cases:
62
+
63
+ - **Drift-tolerant counters / cursor positions / typing indicators** — the sender's state is authoritative locally; rebroadcasting to them is pure waste.
64
+ - **Chat message sends** — the sender already rendered "you said …" in the input box; the broadcast is for everyone else.
65
+ - **Acknowledgment-pattern sends** — the sender awaits the ack to know it persisted; they don't need a separate "your message arrived" frame.
66
+
67
+ ---
68
+
69
+ ## 3. When to leave `ignoreSelf: false` (or omit it)
70
+
71
+ Default to `false` (or just don't set it) when **the sender needs to receive the server's authoritative version** to reconcile their optimistic guess.
72
+
73
+ - **Server-assigned IDs** — the optimistic version had a temp UUID, the broadcast payload has the real DB primary key. The sender needs to listen.
74
+ - **Server-computed derived fields** — timestamps (`createdAt`), running totals (`balance`), search scores, anything the client could not have known.
75
+ - **Cross-validation refresh** — the sender wants to confirm "yes, my mutation actually landed in the shape I expected" via the broadcast.
76
+ - **Recipients are different users** — when the sender is NOT a member of the room they're sending to (e.g. an admin pushing an announcement), `ignoreSelf` is irrelevant. Leave it false.
77
+
78
+ If you're unsure, leave it `false`. Receiving an extra broadcast frame is cheap; **missing one is expensive** because you can't easily detect the absence.
79
+
80
+ ---
81
+
82
+ ## 4. Edge case: multiple sockets per user (tabs, devices)
83
+
84
+ `ignoreSelf` compares **session tokens**, not socket IDs. If a user has 3 tabs open:
85
+
86
+ - Tab A initiates `syncRequest({ ignoreSelf: true })`.
87
+ - Tabs A, B, and C are all in the room (they all auto-joined the `<sessionToken>` room AND any shared room they're members of).
88
+ - All three tabs share the same session token.
89
+
90
+ Result: **all three tabs skip the broadcast**, not just Tab A.
91
+
92
+ This is usually what you want — when you optimistically update a piece of state in any one tab, the framework's `SessionProvider` typically broadcasts the change to all tabs of the same user via the per-token room separately. But it's worth being aware of: `ignoreSelf` is a per-user filter, not a per-socket filter.
93
+
94
+ If you ever genuinely want "skip Tab A but notify Tabs B and C", you have to model that explicitly — either with separate logical session tokens per tab (atypical) or by leaving `ignoreSelf: false` and de-duplicating client-side.
95
+
96
+ ---
97
+
98
+ ## 5. Interaction with `recipientCount`
99
+
100
+ `recipientCount` is what `postSyncFanout` reports. Skipped recipients (whether via `ignoreSelf` or because the socket disappeared mid-fanout) are NOT counted.
101
+
102
+ Sample timeline:
103
+
104
+ - Room has 5 sockets, 3 of which belong to the sender's session token.
105
+ - `syncRequest({ ignoreSelf: true })` from one of the sender's sockets.
106
+ - Fanout skips all 3 of the sender's sockets.
107
+ - `recipientCount === 2`.
108
+ - `postSyncFanout` payload: `{ ..., recipientCount: 2 }`.
109
+
110
+ This makes metrics observe "actual sends", not "room size" — useful for "how many viewers did this update actually reach".
111
+
112
+ ---
113
+
114
+ ## 6. Interaction with streaming
115
+
116
+ `broadcastStream` and `streamTo` happen **inside** `_server`'s `main()`, BEFORE the fanout loop reaches the skip step. The skip is per-final-emit only.
117
+
118
+ Implication: a `broadcastStream` call sends chunks to **every socket in the room INCLUDING the sender's own sockets**, regardless of whether the final ack-step fanout will skip them.
119
+
120
+ If you need to exclude the sender from broadcast streaming too, use `streamTo` with an explicit list that excludes the sender's token:
121
+
122
+ ```ts
123
+ // inside _server_v1.ts main()
124
+ const others = (await functions.session.getRoomMembers(roomCode)).filter(t => t !== user.id);
125
+ streamTo(others, { chunk: piece });
126
+ ```
127
+
128
+ This isn't a common need — typically the sender genuinely does want to see their own LLM tokens stream because they initiated the conversation. But it's available.
129
+
130
+ ---
131
+
132
+ ## 7. Why the originator still gets the ack with `ignoreSelf: true`
133
+
134
+ The ack response (`buildSyncResponseEventName(responseIndex)`) is the protocol-level "your request succeeded / failed" envelope. It is **never** a fanout payload — it's a direct unicast `socket.emit` to the originator's responseIndex channel, and it carries `serverOutput` so the originator can reconcile their optimistic state with the server's authoritative version.
135
+
136
+ `ignoreSelf` only affects the **room fanout step**. The ack is wired through a separate channel that bypasses the fanout entirely.
137
+
138
+ This is critical: the originator's `syncRequest` `await` would never resolve if `ignoreSelf` suppressed the ack. The split between "broadcast payload" and "originator ack" is what makes the optimistic-UI pattern usable.
139
+
140
+ ---
141
+
142
+ ## 8. Quick reference
143
+
144
+ | Behavior with `ignoreSelf: true` | |
145
+ |---|---|
146
+ | Sender's tabs skip the broadcast frame | yes |
147
+ | Sender receives ack with `serverOutput` | yes |
148
+ | Sender receives originator-targeted `stream(...)` chunks | yes |
149
+ | Sender receives `broadcastStream(...)` chunks | **yes — broadcastStream runs in `_server` before skip** |
150
+ | Sender receives `streamTo(...)` chunks (when token included) | **yes — same reason** |
151
+ | Other tabs of same user skipped too | yes — token match, not socket-id match |
152
+ | `recipientCount` counts skipped sockets | no |
153
+ | Same flag works on HTTP transport | yes — `handleHttpSyncRequest` has the same skip block |
154
+
155
+ ---
156
+
157
+ ## 9. Related
158
+
159
+ - Originator API: [`./sync-request.md`](./sync-request.md)
160
+ - Fanout mechanics: [`./room-fanout.md`](./room-fanout.md)
161
+ - Stream audience matrix: [`./streaming.md`](./streaming.md)
162
+ - Server handler params (`user.id` vs sender's token): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
@@ -0,0 +1,233 @@
1
+ # room-fanout
2
+
3
+ > How `handleSyncRequest` resolves the `receiver` string to a set of Socket.io sockets, iterates them, and notifies hook consumers along the way. Covers the `'all'` sentinel, the per-token room convention, event-loop yielding for giant fanouts, the `preSyncFanout` / `postSyncFanout` hooks, and Redis-backed cross-instance fanout.
4
+
5
+ For the originator's `receiver` argument see [`./sync-request.md`](./sync-request.md). For per-recipient handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
6
+
7
+ ---
8
+
9
+ ## 1. How `receiver` resolves to sockets
10
+
11
+ `handleSyncRequest` branches on `receiver`:
12
+
13
+ ```ts
14
+ const sockets = receiver === 'all'
15
+ ? ioInstance.sockets.sockets // Map<socketId, Socket>
16
+ : ioInstance.sockets.adapter.rooms.get(receiver); // Set<socketId> | undefined
17
+ ```
18
+
19
+ - `receiver === 'all'` -> every connected socket on **this instance** (production with Redis adapter: every socket on every instance, see §6).
20
+ - Any other string -> the Socket.io room with that name. If the room does not exist or is empty, `sockets` is `undefined` and the fanout fails with `sync.noReceiversFound`.
21
+
22
+ There is no "broadcast to all but yourself" sentinel — pass `ignoreSelf: true` to skip the originator's own sockets. See [`./ignore-self.md`](./ignore-self.md).
23
+
24
+ ---
25
+
26
+ ## 2. Room conventions
27
+
28
+ Rooms are managed by `@luckystack/core` (client) via `joinRoom(code)` / `leaveRoom(code)`. Conventions in production code:
29
+
30
+ | Room name | Membership | Used for |
31
+ |---|---|---|
32
+ | `<sessionToken>` (auto-joined) | Every socket of one user | `streamTo(token, payload)` reaches all that user's devices. |
33
+ | `<sharedCode>` (manual join) | Multiple users by app logic | Collab editors, multiplayer rooms, chat. |
34
+ | `'all'` (sentinel, not a real room) | Every connected socket | Global broadcasts. Avoid in production. |
35
+
36
+ ### Why every socket auto-joins a room named after its session token
37
+
38
+ `@luckystack/server` joins each socket to a room with that socket's session token at connect time. This is what makes `streamTo(['userToken1', 'userToken2'], payload)` work — without it the framework would have to maintain a separate `tokenToSockets` map.
39
+
40
+ Side effect: if a user is connected from three devices (three sockets, three different `socket.id`s, one session token), `streamTo` reaches all three because they all live in the same per-token room.
41
+
42
+ ---
43
+
44
+ ## 3. The fanout loop
45
+
46
+ After auth, rate-limit, validation, and `_server` execution succeed, `handleSyncRequest` enters the per-recipient loop:
47
+
48
+ ```
49
+ preSyncFanout (stop signal aborts before any recipient is touched)
50
+ |
51
+ v
52
+ for each socket in <resolved sockets>:
53
+ yield every N (configurable)
54
+ skip if ignoreSelf && token === recipientToken
55
+ recipientCount++
56
+ if _client exists:
57
+ run clientHandler
58
+ emit per-recipient result (success or normalized error)
59
+ else:
60
+ emit { serverOutput, clientOutput: {}, status: 'success', cb, fullName }
61
+ |
62
+ v
63
+ postSyncFanout({ recipientCount, ...payload })
64
+ ```
65
+
66
+ Per-recipient errors do **not** abort the loop. A single recipient failing `_client` execution receives a `sync.clientExecutionFailed` frame; everyone else still receives their merged payload.
67
+
68
+ ---
69
+
70
+ ## 4. Event-loop yielding (`sync.fanoutYieldEvery`, `sync.fanoutYieldMs`)
71
+
72
+ ```ts
73
+ const { fanoutYieldEvery, fanoutYieldMs } = getProjectConfig().sync;
74
+ let tempCount = 1;
75
+ for (const socketEntry of sockets) {
76
+ tempCount++;
77
+ if (tempCount % fanoutYieldEvery === 0) {
78
+ await new Promise(resolve => setTimeout(resolve, fanoutYieldMs));
79
+ }
80
+ // ... fanout to this recipient ...
81
+ }
82
+ ```
83
+
84
+ Why: a `receiver: 'all'` fanout against thousands of sockets would otherwise block the event loop for the whole iteration. Yielding every `fanoutYieldEvery` recipients (default: see `projectConfig.sync.fanoutYieldEvery`) for `fanoutYieldMs` (default: a few ms) lets other socket events, API requests, and the heartbeat handlers run.
85
+
86
+ Tuning:
87
+
88
+ - **Higher `fanoutYieldEvery` + lower `fanoutYieldMs`** = faster fanout, less responsiveness for other requests.
89
+ - **Lower `fanoutYieldEvery` + higher `fanoutYieldMs`** = smoother for concurrent traffic but slower fanout.
90
+ - For typical room sizes (<100 recipients), the yield never triggers — defaults are tuned for `receiver: 'all'` worst cases.
91
+
92
+ This loop only exists on the socket path (`handleSyncRequest`). The HTTP path (`handleHttpSyncRequest`) does not yield — HTTP requests are inherently isolated per Node.js handler invocation, and the per-instance fanout sits inside a single async block anyway.
93
+
94
+ ---
95
+
96
+ ## 5. Hooks dispatched during fanout
97
+
98
+ ### `preSyncFanout`
99
+
100
+ ```ts
101
+ {
102
+ routeName: string, // e.g. 'board/moveCard/v1'
103
+ data: Record<string, unknown>, // clientInput
104
+ user: SessionLayout | null, // sender's session
105
+ receiver: string, // resolved roomCode or 'all'
106
+ serverOutput: unknown, // what _server returned (minus status)
107
+ }
108
+ ```
109
+
110
+ Fires **after** `_server` runs successfully and the recipient set is resolved, **before** any recipient receives the payload. Stop signal converts to an originator-side error envelope with the hook's `errorCode` / `httpStatus`. Use for:
111
+
112
+ - "Don't fanout this mutation to a degraded region while we drain traffic."
113
+ - "Throttle fanout-heavy routes during peak load."
114
+ - "Inject a cross-room replication hop before the room receives the payload."
115
+
116
+ ### `postSyncFanout`
117
+
118
+ ```ts
119
+ {
120
+ routeName: string,
121
+ data: Record<string, unknown>,
122
+ user: SessionLayout | null,
123
+ receiver: string,
124
+ serverOutput: unknown,
125
+ recipientCount: number, // actual number of sockets emitted to (NOT room size)
126
+ }
127
+ ```
128
+
129
+ Fires after the last recipient's emit. Observation-only — there is no stop signal because the fanout has already happened. Use for:
130
+
131
+ - Audit logs ("this mutation reached N viewers").
132
+ - Metrics (`fanout_size_histogram.observe(recipientCount)`).
133
+ - Cross-region eventual-consistency markers.
134
+
135
+ ### `rateLimitExceeded`
136
+
137
+ ```ts
138
+ // Scope 'user' or 'route' (per-token / per-IP per-route bucket):
139
+ {
140
+ scope: 'user' | 'route',
141
+ key: string, // 'token:<token>:sync:<route>' or 'ip:<ip>:sync:<route>'
142
+ limit: number,
143
+ windowMs: number,
144
+ count: number,
145
+ route: string,
146
+ userId: string | undefined,
147
+ }
148
+
149
+ // Scope 'ip' (global per-IP cross-route bucket):
150
+ {
151
+ scope: 'ip',
152
+ key: string, // 'ip:<ip>:sync:all'
153
+ limit: number,
154
+ windowMs: number,
155
+ count: number,
156
+ ip: string,
157
+ }
158
+ ```
159
+
160
+ Fires before fanout begins, when either bucket rejects. Used to surface abusive senders and feed automated mitigation.
161
+
162
+ ---
163
+
164
+ ## 6. `recipientCount` vs raw room size
165
+
166
+ `recipientCount` differs from `sockets.size` in three cases:
167
+
168
+ 1. **`ignoreSelf: true`** — every socket whose extracted token matches the sender's is skipped. If a user has 3 sockets in the room and triggered the sync themselves, `recipientCount` is `size - 3`.
169
+ 2. **Sockets disappearing mid-fanout** — `ioInstance.sockets.sockets.get(socketId)` can return `undefined` between resolving the room set and emitting (the socket disconnected). Those iterations `continue` without bumping `recipientCount`.
170
+ 3. **HTTP transport** — the HTTP path's `_client` execution `continue`s without bumping `recipientCount` only for per-recipient failures it could not recover from; the socket path always bumps for handled (success or error) recipients and only skips disappeared sockets.
171
+
172
+ This is why the hook payload exposes `recipientCount` instead of a raw room size — observers want the count that actually saw the payload.
173
+
174
+ ---
175
+
176
+ ## 7. Cross-instance fanout via Redis adapter
177
+
178
+ Single-instance fanout is built into Socket.io. **Cross-instance fanout requires the Redis adapter.** Without it, a `broadcastStream` on instance A reaches sockets on instance A only; sockets on instance B (same room name, different Node process) get nothing.
179
+
180
+ `@luckystack/server` wires the Redis adapter when `REDIS_URL` is set and the Socket.io Redis adapter peer is installed. With it:
181
+
182
+ - `io.to(roomCode).emit(...)` reaches every socket in the room **across every instance**.
183
+ - `ioInstance.sockets.adapter.rooms.get(roomCode)` still only sees the local instance's members — the Redis adapter handles the cross-instance fanout transparently at the emit layer.
184
+
185
+ This means `handleSyncRequest`'s **per-recipient `_client` execution only runs against local sockets**. If you need a `_client` handler to execute on every recipient regardless of which instance they're connected to, either:
186
+
187
+ 1. Pin sticky sessions so a given room's sockets all land on the same instance (simplest), OR
188
+ 2. Wire your own cross-instance per-recipient runner (rare, advanced).
189
+
190
+ For the common case (broadcast a single `serverOutput` to everyone in the room), the Redis adapter is sufficient. See [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md) for the adapter setup.
191
+
192
+ ---
193
+
194
+ ## 8. `sync.noReceiversFound`
195
+
196
+ Triggered when the resolved `sockets` is `undefined` or falsy:
197
+
198
+ - The room name was misspelled.
199
+ - Every member already disconnected before the request reached the fanout step.
200
+ - A bug had the client `joinRoom`-ing under a different name than the sender's `receiver` argument.
201
+ - `receiver: 'all'` while no sockets are connected at all (development edge case).
202
+
203
+ Surfaced to the originator as:
204
+
205
+ ```ts
206
+ { status: 'error', errorCode: 'sync.noReceiversFound', message: '<localized>', httpStatus: 404 }
207
+ ```
208
+
209
+ Default `httpStatus` mapping for this code comes from `defaultHttpStatusForResponse` in `@luckystack/core`.
210
+
211
+ This is not necessarily a bug — sending to an empty room is legal if the sender doesn't yet know the room is empty. UI typically treats `sync.noReceiversFound` as "your mutation succeeded server-side (the `_server` already ran and persisted state); just nobody happened to be listening". `_server`'s mutations are NOT rolled back when there are zero recipients.
212
+
213
+ ---
214
+
215
+ ## 9. Sanity-check checklist
216
+
217
+ - Are sockets joining the right room? `socket.rooms` on the recipient side lists every room they're in (including the auto-joined `<sessionToken>` room and `<socket.id>` self-room).
218
+ - Is the Redis adapter wired in production? `getIoInstance().of('/').adapter` should be `RedisAdapter`, not `Adapter`.
219
+ - Are giant fanouts hot in the profiler? Bump `fanoutYieldEvery` higher OR move to per-token rooms instead of `receiver: 'all'`.
220
+ - Is `recipientCount` consistently below room size? Probably `ignoreSelf: true` + multi-tab usage. Expected; not a bug.
221
+
222
+ ---
223
+
224
+ ## 10. Related
225
+
226
+ - Originator API: [`./sync-request.md`](./sync-request.md)
227
+ - Handler authoring: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
228
+ - Skip-self semantics: [`./ignore-self.md`](./ignore-self.md)
229
+ - Streaming fanout: [`./streaming.md`](./streaming.md)
230
+ - Error catalog (including `sync.noReceiversFound`): [`./error-states.md`](./error-states.md)
231
+ - Socket.io + Redis adapter: [`/docs/ARCHITECTURE_SOCKET.md`](../../../docs/ARCHITECTURE_SOCKET.md)
232
+ - Hook payload shapes: `@luckystack/core` `HookPayloads`
233
+ - Config: `projectConfig.sync.fanoutYieldEvery`, `projectConfig.sync.fanoutYieldMs`