@luckystack/sync 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,122 +1,122 @@
1
- # @luckystack/sync
2
-
3
- > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
-
5
- ## What this package does
6
-
7
- Real-time sync transport for LuckyStack. Provides type-safe, room-based fanout over Socket.io with an HTTP/SSE fallback. Each sync event is a file-based route with a mandatory `_server_v{N}.ts` (runs once, validates, produces `serverOutput`) and an optional `_client_v{N}.ts` (runs once per recipient socket for per-target filtering or auth). Streaming primitives (`stream`, `broadcastStream`, `streamTo`) support live LLM tokens, collab-editor diffs, and per-recipient progress. An offline queue with configurable drop policy keeps optimistic sends from being lost when the socket reconnects.
8
-
9
- ## When to USE this package
10
-
11
- - Real-time room-based fanout (collab editors, chat, multiplayer state)
12
- - Streaming AI/LLM tokens with throttled chunking
13
- - Per-recipient customization (filter / translate / brand) without a second round-trip
14
- - Optimistic offline sends that should replay when the socket reconnects
15
- - Server-validated mutations that must be broadcast to every peer in a room
16
-
17
- ## When to NOT suggest this (yet)
18
-
19
- - Request/response with no fanout — use `@luckystack/api` (`apiRequest`)
20
- - Pure presence/online-status broadcasts — use `@luckystack/presence`
21
- - Cross-instance fanout without a Redis adapter — wire the adapter first (see `/docs/ARCHITECTURE_SOCKET.md`)
22
- - One-off background jobs unrelated to a client room
23
- - HTTP-only environments without a long-lived connection AND no SSE — the package needs at minimum the HTTP/SSE fallback
24
-
25
- ## Function Index
26
-
27
- ### Server entry (`@luckystack/sync`)
28
-
29
- | Export | One-liner | Deep doc |
30
- |---|---|---|
31
- | `handleSyncRequest({ msg, socket, token })` | Socket.io sync entry — auth, rate-limit, validate, run `_server`, fanout, run `_client` per recipient, emit results, dispatch hooks. | → `docs/server-vs-client-handlers.md` |
32
- | `handleHttpSyncRequest(req, res)` | HTTP/SSE fallback for sync requests when websockets are blocked. | → `docs/sync-request.md` |
33
- | `createStreamThrottle({ flushEveryMs?, flushAtChars?, field? })` | Coalesce tiny stream pieces (LLM tokens) into bigger chunks before emit. | → `docs/streaming.md` |
34
- | Type: `HttpSyncStreamEvent` | SSE event shape emitted by the HTTP fallback. | → `docs/sync-request.md` |
35
- | Type: `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle handle + options. | → `docs/streaming.md` |
36
-
37
- ### Client entry (`@luckystack/sync/client`)
38
-
39
- | Export | One-liner | Deep doc |
40
- |---|---|---|
41
- | `syncRequest({ name, version, data?, receiver, ignoreSelf?, onStream?, offlineDropPolicy? })` | Fire a typed sync event into a room; resolves with the server result envelope. | → `docs/sync-request.md` |
42
- | `useSyncEvents()` | React hook returning `{ upsertSyncEventCallback, upsertSyncEventStreamCallback }` scoped to the component lifetime. | → `docs/callback-registration.md` |
43
- | `useSyncEventTrigger()` | React hook returning `{ triggerSyncEvent, triggerSyncStreamEvent }` for manually invoking registered callbacks (testing / local echo). | → `docs/callback-registration.md` |
44
- | `initSyncRequest({ setSocketStatus, sessionRef })` | One-time wiring of socket lifecycle handlers (connect/disconnect/reconnectAttempt/userAfk/userBack/connectError) into the socket-status provider. | → `docs/callback-registration.md` |
45
- | Type: `SyncRequestStreamEvent<T>` | Payload shape passed to `onStream` on the originator side. | → `docs/streaming.md` |
46
- | Type: `SyncRouteStreamEvent<T>` | Payload shape passed to `upsertSyncEventStreamCallback` on recipients. | → `docs/streaming.md` |
47
-
48
- ### Streaming primitives (received inside `_server_v{N}.ts` params)
49
-
50
- | Primitive | Audience | Deep doc |
51
- |---|---|---|
52
- | `stream(payload)` | Originator socket only (cheapest). | → `docs/streaming.md` |
53
- | `broadcastStream(payload)` | Every socket in `roomCode`, across all instances (`io.to(room).emit` via the Redis adapter). | → `docs/streaming.md` |
54
- | `streamTo(tokens, payload)` | Selective fanout to specific session tokens. | → `docs/streaming.md` |
55
- | `stream(payload)` in `_client_v{N}.ts` | Per-recipient, runs after `_server` finishes. | → `docs/streaming.md` |
56
-
57
- ### Hooks dispatched by the server handler
58
-
59
- | Hook | When | Deep doc |
60
- |---|---|---|
61
- | `preSocketMessage` | At the very top of the socket message handler (before auth/route resolution). Stop to reject the message. Transport-level seam mirroring `preHttpRequest`; `channel: 'sync'`. | → `docs/server-vs-client-handlers.md` |
62
- | `preSyncAuthorize` | After basic `AuthProps` check, before rate-limit + input validation. Stop to reject. | → `docs/server-vs-client-handlers.md` |
63
- | `postSyncAuthorize` | Observational — after the request passes auth + custom policy, before rate-limit + validation. | → `docs/server-vs-client-handlers.md` |
64
- | `preSyncValidate` | Before runtime input validation. Stop to reject (mirrors `preApiValidate`). | → `docs/server-vs-client-handlers.md` |
65
- | `postSyncValidate` | After validation, carrying `{ validation }` (mirrors `postApiValidate`). | → `docs/server-vs-client-handlers.md` |
66
- | `preSyncExecute` | Before the `_server` handler runs. Stop to short-circuit (mirrors `preApiExecute`). | → `docs/server-vs-client-handlers.md` |
67
- | `postSyncExecute` | After `_server` resolves OR throws, carrying `{ result, error, durationMs }`. Fires on the FAILURE path too (mirrors `postApiExecute`). | → `docs/server-vs-client-handlers.md` |
68
- | `preSyncFanout` | After `_server` runs, before any recipient receives the payload. Stop to abort fanout. | → `docs/room-fanout.md` |
69
- | `preSyncRecipient` | Per recipient, before that ONE socket receives the payload. Carries `{ routeName, receiver, recipientSocketId, recipientUserId, serverOutput }`. A stop signal SKIPS just that recipient (the loop continues; it is not counted as delivered) — it does NOT abort the whole fanout. `recipientUserId` is null on the hot path. | → `docs/room-fanout.md` |
70
- | `postSyncFanout` | After all recipients have been emitted to. Receives `recipientCount`. | → `docs/room-fanout.md` |
71
- | `preSyncStream` | Per stream chunk, before it is emitted (`stream` / `broadcastStream` / `streamTo`). Carries `{ routeName, chunk, recipient }` (`recipient` = `'originator'`, the room, or a token). Observational. | → `docs/streaming.md` |
72
- | `postSyncStream` | Per stream chunk, after emit. Adds a 1-based per-stream `chunkIndex` to the `preSyncStream` payload. Observational. | → `docs/streaming.md` |
73
- | `rateLimitExceeded` | When the per-route or per-IP bucket rejects a sync. | → `docs/error-states.md` |
74
-
75
- ## Config keys
76
-
77
- ### `registerProjectConfig({ sync, offlineQueue })`
78
-
79
- - `sync.streamThrottle.flushAtChars` — char threshold before a buffered throttle flushes. Default `32`.
80
- - `sync.streamThrottle.flushEveryMs` — timer-based flush interval; `false` disables the timer. Default `50`.
81
- - `sync.streamThrottle.field` — payload key carrying the buffered text. Default `'chunk'`.
82
- - `sync.fanoutYieldEvery` — yield to the event loop every N recipients during a giant fanout. Clamped to `>= 1` (a configured `0` would make the modulo `NaN` and never yield).
83
- - `sync.fanoutYieldMs` — duration of the yield `setTimeout`.
84
- - `sync.requestTimeoutMs` — client-side ack-timeout for `syncRequest` (CORE-06). After this elapses with no acknowledgement the promise settles with `{ status:'error', errorCode:'sync.requestTimeout', httpStatus:504 }` instead of hanging. `false` disables. Default `30000`.
85
- - `sync.allowClientReceiverAll` — when `false`, a client requesting the broadcast receiver `'all'` is rejected (`sync.receiverNotAllowed`, 403) on both transports (SYNC-07). **Default `false` (0.2.0 secure-default flip — was `true`); opt back into cluster-wide broadcast explicitly or approve via `preSyncAuthorize`.**
86
- - `sync.requireRoomMembership` — when `true`, a client may only target a room it has actually joined; an unjoined room is rejected (`sync.notRoomMember`, 403). Enforced on BOTH transports: the SOCKET path checks `socket.rooms`, the HTTP/SSE path derives membership from the session's persisted `roomCodes`. An anonymous HTTP caller (no session) has undeterminable membership and is rejected (fail-closed) — the flag is no longer silently bypassable over the HTTP fallback. **Default `true` (0.2.0 secure-default flip — was `false`); set `false` for the legacy any-room behavior.**
87
- - `sync.flushPressure.maxBufferedBytes` — default drain threshold for the `flushPressure` backpressure helper when no per-call `thresholdBytes` is given (SYNC-15). Default `5_242_880` (5 MiB).
88
- - `sync.flushPressure.highWaterMarkChunks` — upper bound (in packets) on the derived flush-pressure threshold. Default `1000`. (`lowWaterMarkChunks` is surfaced in core but full chunk-watermark hysteresis is a residual — see fix report.)
89
-
90
- ### Per-route `_server` exports
91
-
92
- - `export const rateLimit: number | false` — per-route rate limit (mirrors `@luckystack/api`). Overrides `rateLimiting.defaultApiLimit` for this sync route's per-requester bucket; `false` disables it (the global per-IP bucket still applies); omit to fall back to `defaultApiLimit`. Honored by both transports.
93
- - `offlineQueue.maxSize` — cap on the client-side offline queue.
94
- - `offlineQueue.dropPolicy` — `'reject'` (default — overflow returns `offline.queueFull`), `'drop-oldest'`, or `'drop-newest'`. Per-request override via `syncRequest({ offlineDropPolicy })`. SYNC-09: when a request that was already QUEUED is later evicted (drop-oldest by a newer enqueue, or age expiry) its awaiting promise now settles with `{ status:'error', errorCode:'offline.dropped' }` (was: hung forever).
95
-
96
- ### Logging toggles
97
-
98
- - `logging.devLogs`, `logging.devNotifications`, `logging.socketStatus`, `logging.stream` — drive the dev-only log lines in this package.
99
-
100
- ### Env vars
101
-
102
- None directly. Inherits socket transport config from `@luckystack/server` and Redis adapter config from `@luckystack/core`.
103
-
104
- ## Peer dependencies
105
-
106
- - **Required**: `@luckystack/core`, `@luckystack/error-tracking`. **`@luckystack/login` is NOT a dependency** (0.2.0 decoupling) — sessions resolve through core's `readSession` / session-provider registry (`handleSyncRequest` imports `readSession` from `@luckystack/core`), with login as the default *provider* (optional package), not a hard runtime dep.
107
- - **Peer (canonical ranges, 2026-05-07)**:
108
- - `@prisma/client@^6.19.0` (transitively required via `@luckystack/core`)
109
- - `react@^19.2.0` (only the `/client` subpath)
110
- - `socket.io@^4.8.0` (server entry)
111
- - `socket.io-client@^4.8.0` (client entry)
112
- - Redis adapter (required for cross-instance fanout) wired by `@luckystack/server`; see `/docs/ARCHITECTURE_SOCKET.md`.
113
-
114
- ## Related
115
-
116
- - Architecture deep-dive: `/docs/ARCHITECTURE_SYNC.md`
117
- - Socket setup + Redis adapter: `/docs/ARCHITECTURE_SOCKET.md`
118
- - Multi-instance model + pitfalls (regular `syncRequest` fan-out reaches across instances via `io.in(room).fetchSockets()` + `RemoteSocket.emit()`; streaming via `broadcastStream`/`streamTo`): `/docs/ARCHITECTURE_MULTI_INSTANCE.md`
119
- - File-based `_sync/` routing: `/docs/ARCHITECTURE_ROUTING.md`
120
- - Streaming primitives (`stream` / `streamTo` / `broadcastStream` / `_client`) + throttle: `/docs/ARCHITECTURE_SYNC.md#streaming`
121
- - README (consumer quickstart): `./README.md`
122
- - Sibling packages: `@luckystack/api`, `@luckystack/presence`, `@luckystack/server`
1
+ # @luckystack/sync
2
+
3
+ > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
+
5
+ ## What this package does
6
+
7
+ Real-time sync transport for LuckyStack. Provides type-safe, room-based fanout over Socket.io with an HTTP/SSE fallback. Each sync event is a file-based route with a mandatory `_server_v{N}.ts` (runs once, validates, produces `serverOutput`) and an optional `_client_v{N}.ts` (runs once per recipient socket for per-target filtering or auth). Streaming primitives (`stream`, `broadcastStream`, `streamTo`) support live LLM tokens, collab-editor diffs, and per-recipient progress. An offline queue with configurable drop policy keeps optimistic sends from being lost when the socket reconnects.
8
+
9
+ ## When to USE this package
10
+
11
+ - Real-time room-based fanout (collab editors, chat, multiplayer state)
12
+ - Streaming AI/LLM tokens with throttled chunking
13
+ - Per-recipient customization (filter / translate / brand) without a second round-trip
14
+ - Optimistic offline sends that should replay when the socket reconnects
15
+ - Server-validated mutations that must be broadcast to every peer in a room
16
+
17
+ ## When to NOT suggest this (yet)
18
+
19
+ - Request/response with no fanout — use `@luckystack/api` (`apiRequest`)
20
+ - Pure presence/online-status broadcasts — use `@luckystack/presence`
21
+ - Cross-instance fanout without a Redis adapter — wire the adapter first (see `/docs/ARCHITECTURE_SOCKET.md`)
22
+ - One-off background jobs unrelated to a client room
23
+ - HTTP-only environments without a long-lived connection AND no SSE — the package needs at minimum the HTTP/SSE fallback
24
+
25
+ ## Function Index
26
+
27
+ ### Server entry (`@luckystack/sync`)
28
+
29
+ | Export | One-liner | Deep doc |
30
+ |---|---|---|
31
+ | `handleSyncRequest({ msg, socket, token })` | Socket.io sync entry — auth, rate-limit, validate, run `_server`, fanout, run `_client` per recipient, emit results, dispatch hooks. | → `docs/server-vs-client-handlers.md` |
32
+ | `handleHttpSyncRequest(req, res)` | HTTP/SSE fallback for sync requests when websockets are blocked. | → `docs/sync-request.md` |
33
+ | `createStreamThrottle({ flushEveryMs?, flushAtChars?, field? })` | Coalesce tiny stream pieces (LLM tokens) into bigger chunks before emit. | → `docs/streaming.md` |
34
+ | Type: `HttpSyncStreamEvent` | SSE event shape emitted by the HTTP fallback. | → `docs/sync-request.md` |
35
+ | Type: `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle handle + options. | → `docs/streaming.md` |
36
+
37
+ ### Client entry (`@luckystack/sync/client`)
38
+
39
+ | Export | One-liner | Deep doc |
40
+ |---|---|---|
41
+ | `syncRequest({ name, version, data?, receiver, ignoreSelf?, onStream?, offlineDropPolicy? })` | Fire a typed sync event into a room; resolves with the server result envelope. | → `docs/sync-request.md` |
42
+ | `useSyncEvents()` | React hook returning `{ upsertSyncEventCallback, upsertSyncEventStreamCallback }` scoped to the component lifetime. | → `docs/callback-registration.md` |
43
+ | `useSyncEventTrigger()` | React hook returning `{ triggerSyncEvent, triggerSyncStreamEvent }` for manually invoking registered callbacks (testing / local echo). | → `docs/callback-registration.md` |
44
+ | `initSyncRequest({ setSocketStatus, sessionRef })` | One-time wiring of socket lifecycle handlers (connect/disconnect/reconnectAttempt/userAfk/userBack/connectError) into the socket-status provider. | → `docs/callback-registration.md` |
45
+ | Type: `SyncRequestStreamEvent<T>` | Payload shape passed to `onStream` on the originator side. | → `docs/streaming.md` |
46
+ | Type: `SyncRouteStreamEvent<T>` | Payload shape passed to `upsertSyncEventStreamCallback` on recipients. | → `docs/streaming.md` |
47
+
48
+ ### Streaming primitives (received inside `_server_v{N}.ts` params)
49
+
50
+ | Primitive | Audience | Deep doc |
51
+ |---|---|---|
52
+ | `stream(payload)` | Originator socket only (cheapest). | → `docs/streaming.md` |
53
+ | `broadcastStream(payload)` | Every socket in `roomCode`, across all instances (`io.to(room).emit` via the Redis adapter). | → `docs/streaming.md` |
54
+ | `streamTo(tokens, payload)` | Selective fanout to specific session tokens. | → `docs/streaming.md` |
55
+ | `stream(payload)` in `_client_v{N}.ts` | Per-recipient, runs after `_server` finishes. | → `docs/streaming.md` |
56
+
57
+ ### Hooks dispatched by the server handler
58
+
59
+ | Hook | When | Deep doc |
60
+ |---|---|---|
61
+ | `preSocketMessage` | At the very top of the socket message handler (before auth/route resolution). Stop to reject the message. Transport-level seam mirroring `preHttpRequest`; `channel: 'sync'`. | → `docs/server-vs-client-handlers.md` |
62
+ | `preSyncAuthorize` | After basic `AuthProps` check, before rate-limit + input validation. Stop to reject. | → `docs/server-vs-client-handlers.md` |
63
+ | `postSyncAuthorize` | Observational — after the request passes auth + custom policy, before rate-limit + validation. | → `docs/server-vs-client-handlers.md` |
64
+ | `preSyncValidate` | Before runtime input validation. Stop to reject (mirrors `preApiValidate`). | → `docs/server-vs-client-handlers.md` |
65
+ | `postSyncValidate` | After validation, carrying `{ validation }` (mirrors `postApiValidate`). | → `docs/server-vs-client-handlers.md` |
66
+ | `preSyncExecute` | Before the `_server` handler runs. Stop to short-circuit (mirrors `preApiExecute`). | → `docs/server-vs-client-handlers.md` |
67
+ | `postSyncExecute` | After `_server` resolves OR throws, carrying `{ result, error, durationMs }`. Fires on the FAILURE path too (mirrors `postApiExecute`). | → `docs/server-vs-client-handlers.md` |
68
+ | `preSyncFanout` | After `_server` runs, before any recipient receives the payload. Stop to abort fanout. | → `docs/room-fanout.md` |
69
+ | `preSyncRecipient` | Per recipient, before that ONE socket receives the payload. Carries `{ routeName, receiver, recipientSocketId, recipientUserId, serverOutput }`. A stop signal SKIPS just that recipient (the loop continues; it is not counted as delivered) — it does NOT abort the whole fanout. `recipientUserId` is null on the hot path. | → `docs/room-fanout.md` |
70
+ | `postSyncFanout` | After all recipients have been emitted to. Receives `recipientCount`. | → `docs/room-fanout.md` |
71
+ | `preSyncStream` | Per stream chunk, before it is emitted (`stream` / `broadcastStream` / `streamTo`). Carries `{ routeName, chunk, recipient }` (`recipient` = `'originator'`, the room, or a token). Observational. | → `docs/streaming.md` |
72
+ | `postSyncStream` | Per stream chunk, after emit. Adds a 1-based per-stream `chunkIndex` to the `preSyncStream` payload. Observational. | → `docs/streaming.md` |
73
+ | `rateLimitExceeded` | When the per-route or per-IP bucket rejects a sync. | → `docs/error-states.md` |
74
+
75
+ ## Config keys
76
+
77
+ ### `registerProjectConfig({ sync, offlineQueue })`
78
+
79
+ - `sync.streamThrottle.flushAtChars` — char threshold before a buffered throttle flushes. Default `32`.
80
+ - `sync.streamThrottle.flushEveryMs` — timer-based flush interval; `false` disables the timer. Default `50`.
81
+ - `sync.streamThrottle.field` — payload key carrying the buffered text. Default `'chunk'`.
82
+ - `sync.fanoutYieldEvery` — yield to the event loop every N recipients during a giant fanout. Clamped to `>= 1` (a configured `0` would make the modulo `NaN` and never yield).
83
+ - `sync.fanoutYieldMs` — duration of the yield `setTimeout`.
84
+ - `sync.requestTimeoutMs` — client-side ack-timeout for `syncRequest` (CORE-06). After this elapses with no acknowledgement the promise settles with `{ status:'error', errorCode:'sync.requestTimeout', httpStatus:504 }` instead of hanging. `false` disables. Default `30000`.
85
+ - `sync.allowClientReceiverAll` — when `false`, a client requesting the broadcast receiver `'all'` is rejected (`sync.receiverNotAllowed`, 403) on both transports (SYNC-07). **Default `false` (0.2.0 secure-default flip — was `true`); opt back into cluster-wide broadcast explicitly or approve via `preSyncAuthorize`.**
86
+ - `sync.requireRoomMembership` — when `true`, a client may only target a room it has actually joined; an unjoined room is rejected (`sync.notRoomMember`, 403). Enforced on BOTH transports: the SOCKET path checks `socket.rooms`, the HTTP/SSE path derives membership from the session's persisted `roomCodes`. An anonymous HTTP caller (no session) has undeterminable membership and is rejected (fail-closed) — the flag is no longer silently bypassable over the HTTP fallback. **Default `true` (0.2.0 secure-default flip — was `false`); set `false` for the legacy any-room behavior.**
87
+ - `sync.flushPressure.maxBufferedBytes` — default drain threshold for the `flushPressure` backpressure helper when no per-call `thresholdBytes` is given (SYNC-15). Default `5_242_880` (5 MiB).
88
+ - `sync.flushPressure.highWaterMarkChunks` — upper bound (in packets) on the derived flush-pressure threshold. Default `1000`. (`lowWaterMarkChunks` is surfaced in core but full chunk-watermark hysteresis is a residual — see fix report.)
89
+
90
+ ### Per-route `_server` exports
91
+
92
+ - `export const rateLimit: number | false` — per-route rate limit (mirrors `@luckystack/api`). Overrides `rateLimiting.defaultApiLimit` for this sync route's per-requester bucket; `false` disables it (the global per-IP bucket still applies); omit to fall back to `defaultApiLimit`. Honored by both transports.
93
+ - `offlineQueue.maxSize` — cap on the client-side offline queue.
94
+ - `offlineQueue.dropPolicy` — `'reject'` (default — overflow returns `offline.queueFull`), `'drop-oldest'`, or `'drop-newest'`. Per-request override via `syncRequest({ offlineDropPolicy })`. SYNC-09: when a request that was already QUEUED is later evicted (drop-oldest by a newer enqueue, or age expiry) its awaiting promise now settles with `{ status:'error', errorCode:'offline.dropped' }` (was: hung forever).
95
+
96
+ ### Logging toggles
97
+
98
+ - `logging.devLogs`, `logging.devNotifications`, `logging.socketStatus`, `logging.stream` — drive the dev-only log lines in this package.
99
+
100
+ ### Env vars
101
+
102
+ None directly. Inherits socket transport config from `@luckystack/server` and Redis adapter config from `@luckystack/core`.
103
+
104
+ ## Peer dependencies
105
+
106
+ - **Required**: `@luckystack/core`, `@luckystack/error-tracking`. **`@luckystack/login` is NOT a dependency** (0.2.0 decoupling) — sessions resolve through core's `readSession` / session-provider registry (`handleSyncRequest` imports `readSession` from `@luckystack/core`), with login as the default *provider* (optional package), not a hard runtime dep.
107
+ - **Peer (canonical ranges, 2026-05-07)**:
108
+ - `@prisma/client@^6.19.0` (transitively required via `@luckystack/core`)
109
+ - `react@^19.2.0` (only the `/client` subpath)
110
+ - `socket.io@^4.8.0` (server entry)
111
+ - `socket.io-client@^4.8.0` (client entry)
112
+ - Redis adapter (required for cross-instance fanout) wired by `@luckystack/server`; see `/docs/ARCHITECTURE_SOCKET.md`.
113
+
114
+ ## Related
115
+
116
+ - Architecture deep-dive: `/docs/ARCHITECTURE_SYNC.md`
117
+ - Socket setup + Redis adapter: `/docs/ARCHITECTURE_SOCKET.md`
118
+ - Multi-instance model + pitfalls (regular `syncRequest` fan-out reaches across instances via `io.in(room).fetchSockets()` + `RemoteSocket.emit()`; streaming via `broadcastStream`/`streamTo`): `/docs/ARCHITECTURE_MULTI_INSTANCE.md`
119
+ - File-based `_sync/` routing: `/docs/ARCHITECTURE_ROUTING.md`
120
+ - Streaming primitives (`stream` / `streamTo` / `broadcastStream` / `_client`) + throttle: `/docs/ARCHITECTURE_SYNC.md#streaming`
121
+ - README (consumer quickstart): `./README.md`
122
+ - Sibling packages: `@luckystack/api`, `@luckystack/presence`, `@luckystack/server`
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Mathijs van Melick
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathijs van Melick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,155 +1,155 @@
1
- # @luckystack/sync
2
-
3
- > Real-time sync transport for [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2). Type-safe room-based fanout, server + per-client validation, streaming, optimistic offline queue. Server entry plus a browser-safe `./client` subpath for React.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @luckystack/sync @luckystack/core @luckystack/login @luckystack/error-tracking react socket.io socket.io-client
9
- ```
10
-
11
- ## Quickstart
12
-
13
- A sync event is two files (one mandatory, one optional):
14
-
15
- ```ts
16
- // src/board/_sync/moveCard_server_v1.ts — runs ONCE per request, validates and produces serverOutput
17
- export const auth = { login: true };
18
-
19
- export interface SyncParams {
20
- data: { cardId: string; toLane: string };
21
- user: SessionLayout;
22
- receiver: string; // room code
23
- functions: Functions;
24
- }
25
-
26
- export const main = async ({ data, user, receiver }: SyncParams) => {
27
- await prisma.card.update({ where: { id: data.cardId }, data: { laneId: data.toLane } });
28
- return { status: 'success', serverOutput: { cardId: data.cardId, movedBy: user.id } };
29
- };
30
- ```
31
-
32
- Add a `_client_v1.ts` only when you need per-client filtering, per-target auth, or a custom `clientOutput`. If it would just `return { status: 'success' }`, leave it out.
33
-
34
- ### Client side
35
-
36
- ```tsx
37
- import { syncRequest, upsertSyncEventCallback } from '@luckystack/sync/client';
38
-
39
- upsertSyncEventCallback({
40
- name: 'board/moveCard',
41
- version: 'v1',
42
- callback: ({ serverOutput, clientOutput }) => {
43
- if (serverOutput.status !== 'success') return;
44
- setCards(prev => moveCardLocally(prev, serverOutput.cardId));
45
- },
46
- });
47
-
48
- await syncRequest({
49
- name: 'board/moveCard',
50
- version: 'v1',
51
- data: { cardId, toLane },
52
- receiver: roomCode,
53
- ignoreSelf: true,
54
- });
55
- ```
56
-
57
- ## Subpaths
58
-
59
- - `@luckystack/sync` — server-only transport adapters (`handleSyncRequest`, `handleHttpSyncRequest`). Wired by `@luckystack/server`.
60
- - `@luckystack/sync/client` — browser-safe hooks (`syncRequest`, `useSyncEvents`, `upsertSyncEventCallback`). React 19 required.
61
-
62
- ## How it integrates
63
-
64
- 1. **Validates** server payload, then runs `_server_v{N}.ts` once.
65
- 2. **Dispatches** the `preSyncFanout` hook (may abort).
66
- 3. **Resolves** the room receiver list, optionally running `_client_v{N}.ts` once per recipient socket for per-client filtering or auth.
67
- 4. **Emits** the merged `{ serverOutput, clientOutput }` payload to each socket.
68
- 5. **Dispatches** `postSyncFanout` with the recipient count.
69
-
70
- ## Streaming
71
-
72
- Sync handlers receive **four** stream primitives in their `_server` params, each picking a different audience and cost profile:
73
-
74
- | Primitive | Audience | Use when |
75
- | --- | --- | --- |
76
- | `stream(payload)` | Originator only (cheapest) | Per-user progress nobody else cares about |
77
- | `broadcastStream(payload)` | Everyone in `roomCode`, across all instances (Redis adapter) | Live AI chat tokens, collab editor diffs |
78
- | `streamTo(tokens, payload)` | Specific session tokens | Selective subscribers (admin viewers, etc.) |
79
- | `_client_v{N}.ts` `stream(...)` | Per-recipient (after `_server` finishes) | Per-target customization (filter / translate / brand) |
80
-
81
- Plus `createStreamThrottle({ flushEveryMs, flushAtChars })` for coalescing tiny LLM tokens into bigger chunks — cuts message count by 10–100× without losing the "live" feel.
82
-
83
- ```ts
84
- // src/chat/_sync/sendMessage_server_v1.ts — AI chat with live broadcast
85
- import { createStreamThrottle } from '@luckystack/sync';
86
-
87
- export const main = async ({ clientInput, broadcastStream }: SyncParams) => {
88
- const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
89
-
90
- let full = '';
91
- for await (const piece of openaiStream) {
92
- full += piece.text;
93
- throttle.push(piece.text, broadcastStream);
94
- }
95
- throttle.flush(broadcastStream);
96
-
97
- return { status: 'success', message: full };
98
- };
99
- ```
100
-
101
- Recipients consume both `broadcastStream` and `streamTo` chunks via the same `upsertSyncEventCallback` they already use:
102
-
103
- ```ts
104
- upsertSyncEventCallback({
105
- name: 'chat/sendMessage',
106
- version: 'v1',
107
- callback: ({ stream, status }) => {
108
- if (status === 'stream' && stream?.chunk) appendToken(stream.chunk);
109
- },
110
- });
111
- ```
112
-
113
- Full decision tree, performance notes, and additional examples live in [`docs/ARCHITECTURE_SYNC.md`](../../docs/ARCHITECTURE_SYNC.md#streaming).
114
-
115
- ## Public API
116
-
117
- Server entry (`@luckystack/sync`):
118
-
119
- | Export | Purpose |
120
- | --- | --- |
121
- | `handleSyncRequest(socket, msg, ack)` | Socket.io sync handler (default export). |
122
- | `handleHttpSyncRequest(req, res)` | HTTP/SSE fallback. |
123
- | `createStreamThrottle(options)` | Coalesce small stream pieces into bigger chunks (LLM-token-friendly). |
124
- | Type: `HttpSyncStreamEvent` | SSE event shape. |
125
- | Type: `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle helper types. |
126
-
127
- Configure stream throttling and offline-queue policy via `registerProjectConfig({ sync, offlineQueue })`. The shapes are exported from `@luckystack/core` as **`SyncConfig`** (with nested `SyncStreamThrottleConfig`) and **`OfflineQueueConfig`** — they cover the throttle defaults, fanout iteration tuning, and the queue's max-size + drop policy (`'reject'` triggers the `offline.queueFull` error code on overflow).
128
-
129
- Client entry (`@luckystack/sync/client`):
130
-
131
- | Export | Purpose |
132
- | --- | --- |
133
- | `syncRequest(opts)` | Fire a typed sync event, optionally with `ignoreSelf` and `receiver`. |
134
- | `upsertSyncEventCallback({ name, version, callback })` | Subscribe to inbound sync payloads. |
135
- | `useSyncEvents(...)` | React hook for component-scoped subscriptions. |
136
-
137
- ## Related architecture docs
138
-
139
- - [`docs/ARCHITECTURE_SYNC.md`](../../docs/ARCHITECTURE_SYNC.md) — full sync lifecycle, streaming decision tree, performance notes.
140
- - [`docs/ARCHITECTURE_SOCKET.md`](../../docs/ARCHITECTURE_SOCKET.md) — Socket.io + Redis adapter (required for cross-instance fanout).
141
- - [`docs/ARCHITECTURE_ROUTING.md`](../../docs/ARCHITECTURE_ROUTING.md) — `_sync/` file conventions and `_server` / `_client` split.
142
- - [`docs/ARCHITECTURE_SYNC.md#streaming`](../../docs/ARCHITECTURE_SYNC.md#streaming) — the streaming primitives (`stream` / `streamTo` / `broadcastStream` / `_client`), `createStreamThrottle`, and SSE.
143
-
144
- ## Dependencies
145
-
146
- - Runtime: `@luckystack/core`, `@luckystack/login`, `@luckystack/error-tracking`
147
- - Peer (canonical ranges, standardized 2026-05-07):
148
- - `@prisma/client@^6.19.0` (transitively required via `@luckystack/core`)
149
- - `react@^19.2.0` (`/client` entry only)
150
- - `socket.io@^4.8.0`
151
- - `socket.io-client@^4.8.0`
152
-
153
- ## License
154
-
155
- MIT — see [LICENSE](../../LICENSE).
1
+ # @luckystack/sync
2
+
3
+ > Real-time sync transport for [LuckyStack](https://github.com/ItsLucky23/LuckyStack-v2). Type-safe room-based fanout, server + per-client validation, streaming, optimistic offline queue. Server entry plus a browser-safe `./client` subpath for React.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @luckystack/sync @luckystack/core @luckystack/login @luckystack/error-tracking react socket.io socket.io-client
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ A sync event is two files (one mandatory, one optional):
14
+
15
+ ```ts
16
+ // src/board/_sync/moveCard_server_v1.ts — runs ONCE per request, validates and produces serverOutput
17
+ export const auth = { login: true };
18
+
19
+ export interface SyncParams {
20
+ data: { cardId: string; toLane: string };
21
+ user: SessionLayout;
22
+ receiver: string; // room code
23
+ functions: Functions;
24
+ }
25
+
26
+ export const main = async ({ data, user, receiver }: SyncParams) => {
27
+ await prisma.card.update({ where: { id: data.cardId }, data: { laneId: data.toLane } });
28
+ return { status: 'success', serverOutput: { cardId: data.cardId, movedBy: user.id } };
29
+ };
30
+ ```
31
+
32
+ Add a `_client_v1.ts` only when you need per-client filtering, per-target auth, or a custom `clientOutput`. If it would just `return { status: 'success' }`, leave it out.
33
+
34
+ ### Client side
35
+
36
+ ```tsx
37
+ import { syncRequest, upsertSyncEventCallback } from '@luckystack/sync/client';
38
+
39
+ upsertSyncEventCallback({
40
+ name: 'board/moveCard',
41
+ version: 'v1',
42
+ callback: ({ serverOutput, clientOutput }) => {
43
+ if (serverOutput.status !== 'success') return;
44
+ setCards(prev => moveCardLocally(prev, serverOutput.cardId));
45
+ },
46
+ });
47
+
48
+ await syncRequest({
49
+ name: 'board/moveCard',
50
+ version: 'v1',
51
+ data: { cardId, toLane },
52
+ receiver: roomCode,
53
+ ignoreSelf: true,
54
+ });
55
+ ```
56
+
57
+ ## Subpaths
58
+
59
+ - `@luckystack/sync` — server-only transport adapters (`handleSyncRequest`, `handleHttpSyncRequest`). Wired by `@luckystack/server`.
60
+ - `@luckystack/sync/client` — browser-safe hooks (`syncRequest`, `useSyncEvents`, `upsertSyncEventCallback`). React 19 required.
61
+
62
+ ## How it integrates
63
+
64
+ 1. **Validates** server payload, then runs `_server_v{N}.ts` once.
65
+ 2. **Dispatches** the `preSyncFanout` hook (may abort).
66
+ 3. **Resolves** the room receiver list, optionally running `_client_v{N}.ts` once per recipient socket for per-client filtering or auth.
67
+ 4. **Emits** the merged `{ serverOutput, clientOutput }` payload to each socket.
68
+ 5. **Dispatches** `postSyncFanout` with the recipient count.
69
+
70
+ ## Streaming
71
+
72
+ Sync handlers receive **four** stream primitives in their `_server` params, each picking a different audience and cost profile:
73
+
74
+ | Primitive | Audience | Use when |
75
+ | --- | --- | --- |
76
+ | `stream(payload)` | Originator only (cheapest) | Per-user progress nobody else cares about |
77
+ | `broadcastStream(payload)` | Everyone in `roomCode`, across all instances (Redis adapter) | Live AI chat tokens, collab editor diffs |
78
+ | `streamTo(tokens, payload)` | Specific session tokens | Selective subscribers (admin viewers, etc.) |
79
+ | `_client_v{N}.ts` `stream(...)` | Per-recipient (after `_server` finishes) | Per-target customization (filter / translate / brand) |
80
+
81
+ Plus `createStreamThrottle({ flushEveryMs, flushAtChars })` for coalescing tiny LLM tokens into bigger chunks — cuts message count by 10–100× without losing the "live" feel.
82
+
83
+ ```ts
84
+ // src/chat/_sync/sendMessage_server_v1.ts — AI chat with live broadcast
85
+ import { createStreamThrottle } from '@luckystack/sync';
86
+
87
+ export const main = async ({ clientInput, broadcastStream }: SyncParams) => {
88
+ const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
89
+
90
+ let full = '';
91
+ for await (const piece of openaiStream) {
92
+ full += piece.text;
93
+ throttle.push(piece.text, broadcastStream);
94
+ }
95
+ throttle.flush(broadcastStream);
96
+
97
+ return { status: 'success', message: full };
98
+ };
99
+ ```
100
+
101
+ Recipients consume both `broadcastStream` and `streamTo` chunks via the same `upsertSyncEventCallback` they already use:
102
+
103
+ ```ts
104
+ upsertSyncEventCallback({
105
+ name: 'chat/sendMessage',
106
+ version: 'v1',
107
+ callback: ({ stream, status }) => {
108
+ if (status === 'stream' && stream?.chunk) appendToken(stream.chunk);
109
+ },
110
+ });
111
+ ```
112
+
113
+ Full decision tree, performance notes, and additional examples live in [`docs/ARCHITECTURE_SYNC.md`](../../docs/ARCHITECTURE_SYNC.md#streaming).
114
+
115
+ ## Public API
116
+
117
+ Server entry (`@luckystack/sync`):
118
+
119
+ | Export | Purpose |
120
+ | --- | --- |
121
+ | `handleSyncRequest(socket, msg, ack)` | Socket.io sync handler (default export). |
122
+ | `handleHttpSyncRequest(req, res)` | HTTP/SSE fallback. |
123
+ | `createStreamThrottle(options)` | Coalesce small stream pieces into bigger chunks (LLM-token-friendly). |
124
+ | Type: `HttpSyncStreamEvent` | SSE event shape. |
125
+ | Type: `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle helper types. |
126
+
127
+ Configure stream throttling and offline-queue policy via `registerProjectConfig({ sync, offlineQueue })`. The shapes are exported from `@luckystack/core` as **`SyncConfig`** (with nested `SyncStreamThrottleConfig`) and **`OfflineQueueConfig`** — they cover the throttle defaults, fanout iteration tuning, and the queue's max-size + drop policy (`'reject'` triggers the `offline.queueFull` error code on overflow).
128
+
129
+ Client entry (`@luckystack/sync/client`):
130
+
131
+ | Export | Purpose |
132
+ | --- | --- |
133
+ | `syncRequest(opts)` | Fire a typed sync event, optionally with `ignoreSelf` and `receiver`. |
134
+ | `upsertSyncEventCallback({ name, version, callback })` | Subscribe to inbound sync payloads. |
135
+ | `useSyncEvents(...)` | React hook for component-scoped subscriptions. |
136
+
137
+ ## Related architecture docs
138
+
139
+ - [`docs/ARCHITECTURE_SYNC.md`](../../docs/ARCHITECTURE_SYNC.md) — full sync lifecycle, streaming decision tree, performance notes.
140
+ - [`docs/ARCHITECTURE_SOCKET.md`](../../docs/ARCHITECTURE_SOCKET.md) — Socket.io + Redis adapter (required for cross-instance fanout).
141
+ - [`docs/ARCHITECTURE_ROUTING.md`](../../docs/ARCHITECTURE_ROUTING.md) — `_sync/` file conventions and `_server` / `_client` split.
142
+ - [`docs/ARCHITECTURE_SYNC.md#streaming`](../../docs/ARCHITECTURE_SYNC.md#streaming) — the streaming primitives (`stream` / `streamTo` / `broadcastStream` / `_client`), `createStreamThrottle`, and SSE.
143
+
144
+ ## Dependencies
145
+
146
+ - Runtime: `@luckystack/core`, `@luckystack/login`, `@luckystack/error-tracking`
147
+ - Peer (canonical ranges, standardized 2026-05-07):
148
+ - `@prisma/client@^6.19.0` (transitively required via `@luckystack/core`)
149
+ - `react@^19.2.0` (`/client` entry only)
150
+ - `socket.io@^4.8.0`
151
+ - `socket.io-client@^4.8.0`
152
+
153
+ ## License
154
+
155
+ MIT — see [LICENSE](../../LICENSE).