@luckystack/sync 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -0
- package/CHANGELOG.md +7 -0
- package/README.md +2 -150
- package/dist/_shared/clientFanout.d.ts +39 -0
- package/dist/_shared/errorBuilders.d.ts +35 -0
- package/dist/_shared/errorCodes.d.ts +3 -0
- package/dist/_shared/logFlags.d.ts +2 -0
- package/dist/_shared/receiverAuth.d.ts +34 -0
- package/dist/_shared/redactToken.d.ts +10 -0
- package/dist/_shared/streamEmitters.d.ts +51 -0
- package/dist/_shared/syncTypes.d.ts +74 -0
- package/dist/_shared/validationMode.d.ts +2 -0
- package/dist/client.d.ts +4 -129
- package/dist/handleHttpSyncRequest.d.ts +33 -0
- package/dist/handleSyncRequest.d.ts +7 -0
- package/dist/index.d.ts +7 -91
- package/dist/index.js.map +1 -1
- package/dist/streamThrottle.d.ts +45 -0
- package/dist/syncRequest.d.ts +126 -0
- package/docs/callback-registration.md +3 -255
- package/docs/error-states.md +11 -251
- package/docs/ignore-self.md +3 -160
- package/docs/room-fanout.md +11 -247
- package/docs/server-vs-client-handlers.md +5 -321
- package/docs/streaming.md +3 -346
- package/docs/sync-request.md +3 -282
- package/docs/version-policy.md +3 -360
- package/package.json +4 -4
- package/CLAUDE.md +0 -128
- package/dist/errorCodes-B8GoTenr.d.ts +0 -5
package/AGENTS.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @luckystack/sync — design context
|
|
2
|
+
|
|
3
|
+
Sync exists for server-authoritative mutations followed by realtime delivery. The server handler runs for the request; the optional client handler is for recipient-specific filtering or presentation, not for authorizing the originator. A successful originator acknowledgement does not prove that every recipient accepted the event. Source: `src/handleSyncRequest.ts`, `src/handleHttpSyncRequest.ts`, `src/_shared/clientFanout.ts`.
|
|
4
|
+
|
|
5
|
+
A client-chosen receiver is an authorization boundary, not merely a room name. Keep the built-in all-room and membership restrictions and the `preSyncAuthorize` policy aligned across socket and HTTP/SSE. HTTP membership uses persisted session rooms. That source differs from the socket's on purpose, so do not unify the two checks (ADR 0059). Per-recipient filters must not replace sender-side authorization. Preserve server-side receiver validation before fanout and avoid leaking private data into shared `serverOutput`. Source: `src/_shared/receiverAuth.ts`, `src/_shared/receiverAuth.test.ts`, `src/handleSyncTransport.test.ts`.
|
|
6
|
+
|
|
7
|
+
Both transports must answer the same thing at the same point. Recipients resolve across instances before the server handler commits, so a delivery failure is never reported on a saved mutation (ADR 0063). An empty room is `sync.noReceiversFound` on every transport (ADR 0058). Fanout code never enumerates the per-instance socket maps (ADR 0061). Every `sync.*` code a transport can emit belongs in `src/_shared/errorCodes.ts`, which consumers' locale parity tests rely on.
|
|
8
|
+
|
|
9
|
+
Stream audiences differ; do not turn originator-only progress into broadcast or treat a client callback as a security filter. Use the browser-safe `/client` entry and generated types; never cast around a broken map. See `docs/server-vs-client-handlers.md`, `docs/room-fanout.md`, `docs/error-states.md`, `docs/streaming.md`, `docs/version-policy.md` and `../core/AGENTS.md`.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to `@luckystack/sync` are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.11.0] - 2026-10-14
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Type declarations are emitted per source file by TypeScript 7 (`tsc -b`) instead of one bundled `.d.ts` per entry, so the framework build no longer runs package by package. The exported types are unchanged; relative imports inside the declarations carry `.js`, so `moduleResolution: "node16"`/`"nodenext"` projects resolve them as before.
|
|
13
|
+
- Package design guidance now ships as `AGENTS.md`, not `CLAUDE.md`. Handwritten docs focus on rationale and safety; exact API contracts remain in installed declarations/source.
|
|
14
|
+
|
|
8
15
|
## [0.10.0] - 2026-09-03
|
|
9
16
|
|
|
10
17
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,153 +1,5 @@
|
|
|
1
1
|
# @luckystack/sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-authoritative, room-based realtime updates with socket and HTTP/SSE transports. Install with `npm install @luckystack/sync socket.io socket.io-client`; add optional React and Prisma peers only when those surfaces are used (see `package.json`). Browser callers import `@luckystack/sync/client`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install @luckystack/sync socket.io socket.io-client
|
|
9
|
-
# Optional React client helpers: npm install react
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
## Quickstart
|
|
13
|
-
|
|
14
|
-
A sync event is two files (one mandatory, one optional):
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
// src/board/_sync/moveCard_server_v1.ts — runs ONCE per request, validates and produces serverOutput
|
|
18
|
-
export const auth = { login: true };
|
|
19
|
-
|
|
20
|
-
export interface SyncParams {
|
|
21
|
-
data: { cardId: string; toLane: string };
|
|
22
|
-
user: SessionLayout;
|
|
23
|
-
receiver: string; // room code
|
|
24
|
-
functions: Functions;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const main = async ({ data, user, receiver }: SyncParams) => {
|
|
28
|
-
await prisma.card.update({ where: { id: data.cardId }, data: { laneId: data.toLane } });
|
|
29
|
-
return { status: 'success', serverOutput: { cardId: data.cardId, movedBy: user.id } };
|
|
30
|
-
};
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
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.
|
|
34
|
-
|
|
35
|
-
### Client side
|
|
36
|
-
|
|
37
|
-
```tsx
|
|
38
|
-
import { syncRequest, upsertSyncEventCallback } from '@luckystack/sync/client';
|
|
39
|
-
|
|
40
|
-
upsertSyncEventCallback({
|
|
41
|
-
name: 'board/moveCard',
|
|
42
|
-
version: 'v1',
|
|
43
|
-
callback: ({ serverOutput, clientOutput }) => {
|
|
44
|
-
if (serverOutput.status !== 'success') return;
|
|
45
|
-
setCards(prev => moveCardLocally(prev, serverOutput.cardId));
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
await syncRequest({
|
|
50
|
-
name: 'board/moveCard',
|
|
51
|
-
version: 'v1',
|
|
52
|
-
data: { cardId, toLane },
|
|
53
|
-
receiver: roomCode,
|
|
54
|
-
ignoreSelf: true,
|
|
55
|
-
});
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
## Subpaths
|
|
59
|
-
|
|
60
|
-
- `@luckystack/sync` — server-only transport adapters (`handleSyncRequest`, `handleHttpSyncRequest`). Wired by `@luckystack/server`.
|
|
61
|
-
- `@luckystack/sync/client` — browser-safe hooks (`syncRequest`, `useSyncEvents`, `upsertSyncEventCallback`). React 19 required.
|
|
62
|
-
|
|
63
|
-
## How it integrates
|
|
64
|
-
|
|
65
|
-
1. **Validates** server payload, then runs `_server_v{N}.ts` once.
|
|
66
|
-
2. **Dispatches** the `preSyncFanout` hook (may abort).
|
|
67
|
-
3. **Resolves** the room receiver list, optionally running `_client_v{N}.ts` once per recipient socket for per-client filtering or auth.
|
|
68
|
-
4. **Emits** the merged `{ serverOutput, clientOutput }` payload to each socket.
|
|
69
|
-
5. **Dispatches** `postSyncFanout` with the recipient count.
|
|
70
|
-
|
|
71
|
-
## Streaming
|
|
72
|
-
|
|
73
|
-
Sync handlers receive **four** stream primitives in their `_server` params, each picking a different audience and cost profile:
|
|
74
|
-
|
|
75
|
-
| Primitive | Audience | Use when |
|
|
76
|
-
| --- | --- | --- |
|
|
77
|
-
| `stream(payload)` | Originator only (cheapest) | Per-user progress nobody else cares about |
|
|
78
|
-
| `broadcastStream(payload)` | Everyone in `roomCode`, across all instances (Redis adapter) | Live AI chat tokens, collab editor diffs |
|
|
79
|
-
| `streamTo(tokens, payload)` | Specific session tokens | Selective subscribers (admin viewers, etc.) |
|
|
80
|
-
| `_client_v{N}.ts` `stream(...)` | Per-recipient (after `_server` finishes) | Per-target customization (filter / translate / brand) |
|
|
81
|
-
|
|
82
|
-
Plus `createStreamThrottle({ flushEveryMs, flushAtChars })` for coalescing tiny LLM tokens into bigger chunks — cuts message count by 10–100× without losing the "live" feel.
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
// src/chat/_sync/sendMessage_server_v1.ts — AI chat with live broadcast
|
|
86
|
-
import { createStreamThrottle } from '@luckystack/sync';
|
|
87
|
-
|
|
88
|
-
export const main = async ({ clientInput, broadcastStream }: SyncParams) => {
|
|
89
|
-
const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
|
|
90
|
-
|
|
91
|
-
let full = '';
|
|
92
|
-
for await (const piece of openaiStream) {
|
|
93
|
-
full += piece.text;
|
|
94
|
-
throttle.push(piece.text, broadcastStream);
|
|
95
|
-
}
|
|
96
|
-
throttle.flush(broadcastStream);
|
|
97
|
-
|
|
98
|
-
return { status: 'success', message: full };
|
|
99
|
-
};
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Recipients consume both `broadcastStream` and `streamTo` chunks via the same `upsertSyncEventCallback` they already use:
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
upsertSyncEventCallback({
|
|
106
|
-
name: 'chat/sendMessage',
|
|
107
|
-
version: 'v1',
|
|
108
|
-
callback: ({ stream, status }) => {
|
|
109
|
-
if (status === 'stream' && stream?.chunk) appendToken(stream.chunk);
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Full decision tree, performance notes, and additional examples live in [`docs/ARCHITECTURE_SYNC.md`](../../docs/ARCHITECTURE_SYNC.md#streaming).
|
|
115
|
-
|
|
116
|
-
## Public API
|
|
117
|
-
|
|
118
|
-
Server entry (`@luckystack/sync`):
|
|
119
|
-
|
|
120
|
-
| Export | Purpose |
|
|
121
|
-
| --- | --- |
|
|
122
|
-
| `handleSyncRequest(socket, msg, ack)` | Socket.io sync handler (default export). |
|
|
123
|
-
| `handleHttpSyncRequest(req, res)` | HTTP/SSE fallback. |
|
|
124
|
-
| `createStreamThrottle(options)` | Coalesce small stream pieces into bigger chunks (LLM-token-friendly). |
|
|
125
|
-
| Type: `HttpSyncStreamEvent` | SSE event shape. |
|
|
126
|
-
| Type: `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle helper types. |
|
|
127
|
-
|
|
128
|
-
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).
|
|
129
|
-
|
|
130
|
-
Client entry (`@luckystack/sync/client`):
|
|
131
|
-
|
|
132
|
-
| Export | Purpose |
|
|
133
|
-
| --- | --- |
|
|
134
|
-
| `syncRequest(opts)` | Fire a typed sync event, optionally with `ignoreSelf` and `receiver`. |
|
|
135
|
-
| `upsertSyncEventCallback({ name, version, callback })` | Subscribe to inbound sync payloads. |
|
|
136
|
-
| `useSyncEvents(...)` | React hook for component-scoped subscriptions. |
|
|
137
|
-
|
|
138
|
-
## Related architecture docs
|
|
139
|
-
|
|
140
|
-
- [`docs/ARCHITECTURE_SYNC.md`](../../docs/ARCHITECTURE_SYNC.md) — full sync lifecycle, streaming decision tree, performance notes.
|
|
141
|
-
- [`docs/ARCHITECTURE_SOCKET.md`](../../docs/ARCHITECTURE_SOCKET.md) — Socket.io + Redis adapter (required for cross-instance fanout).
|
|
142
|
-
- [`docs/ARCHITECTURE_ROUTING.md`](../../docs/ARCHITECTURE_ROUTING.md) — `_sync/` file conventions and `_server` / `_client` split.
|
|
143
|
-
- [`docs/ARCHITECTURE_SYNC.md#streaming`](../../docs/ARCHITECTURE_SYNC.md#streaming) — the streaming primitives (`stream` / `streamTo` / `broadcastStream` / `_client`), `createStreamThrottle`, and SSE.
|
|
144
|
-
|
|
145
|
-
## Dependencies
|
|
146
|
-
|
|
147
|
-
- Runtime: `@luckystack/core`, `@luckystack/error-tracking` (`@luckystack/login` is an optional session provider, not a runtime dependency)
|
|
148
|
-
- Required peers: `socket.io@^4.8.0`, `socket.io-client@^4.8.0`
|
|
149
|
-
- Optional peers: `@prisma/client@^6.19.0` (only for the default Prisma-backed session/user provider), `react@^19.2.0` (`/client` entry only).
|
|
150
|
-
|
|
151
|
-
## License
|
|
152
|
-
|
|
153
|
-
MIT — see [LICENSE](../../LICENSE).
|
|
5
|
+
Use a server handler for shared mutations and an optional recipient handler only when individual recipients need different output. Choosing a receiver is an authorization decision, not just routing; maintain sender-side checks even when recipient filtering exists. See [design context](./AGENTS.md), [server vs recipient](./docs/server-vs-client-handlers.md), [room authorization](./docs/room-fanout.md) and [version rationale](./docs/version-policy.md). Source and generated maps own the API surface.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RuntimeSyncClientHandler, SyncErrorEnvelopeInput } from './syncTypes.js';
|
|
2
|
+
interface FanoutRecipientSocket {
|
|
3
|
+
emit(event: string, ...args: unknown[]): unknown;
|
|
4
|
+
handshake: {
|
|
5
|
+
headers: Record<string, string | string[] | undefined>;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export interface ProcessClientSyncForRecipientArgs<TError extends object> {
|
|
9
|
+
tempSocket: FanoutRecipientSocket;
|
|
10
|
+
tempToken: string | null;
|
|
11
|
+
clientSyncHandler: RuntimeSyncClientHandler;
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
functionsObject: Record<string, unknown>;
|
|
14
|
+
serverOutput: unknown;
|
|
15
|
+
receiver: string;
|
|
16
|
+
resolvedName: string;
|
|
17
|
+
callbackKey: string;
|
|
18
|
+
transport: 'socket' | 'http';
|
|
19
|
+
handlerName: 'handleSyncRequest' | 'handleHttpSyncRequest';
|
|
20
|
+
logLabel: 'sync' | 'http sync';
|
|
21
|
+
shouldLogDev: () => boolean;
|
|
22
|
+
shouldLogStream: () => boolean;
|
|
23
|
+
buildSyncError: (args: {
|
|
24
|
+
response: SyncErrorEnvelopeInput;
|
|
25
|
+
preferred?: string | null;
|
|
26
|
+
userLanguage?: string | null;
|
|
27
|
+
}) => TError;
|
|
28
|
+
resolvePreferredLocale: (headers: Record<string, string | string[] | undefined>) => string | null | undefined;
|
|
29
|
+
sourceUserId?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run the optional `_client` handler for ONE recipient socket and emit the
|
|
33
|
+
* per-recipient `sync` envelope. When the route has no `_client` file the
|
|
34
|
+
* caller emits the server-only success envelope instead (kept at the call site
|
|
35
|
+
* because it does not run the handler). Behaviour-identical to the previously
|
|
36
|
+
* inlined loop body in both transports.
|
|
37
|
+
*/
|
|
38
|
+
export declare const processClientSyncForRecipient: <TError extends object>({ tempSocket, tempToken, clientSyncHandler, data, functionsObject, serverOutput, receiver, resolvedName, callbackKey, transport, handlerName, logLabel, shouldLogDev, shouldLogStream, buildSyncError, resolvePreferredLocale, sourceUserId, }: ProcessClientSyncForRecipientArgs<TError>) => Promise<void>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ErrorFormatter } from '@luckystack/core';
|
|
2
|
+
import type { SyncErrorEnvelopeInput } from './syncTypes.js';
|
|
3
|
+
export interface FormattedSyncErrorEnvelope {
|
|
4
|
+
status: 'error';
|
|
5
|
+
message: string;
|
|
6
|
+
errorCode: string;
|
|
7
|
+
errorParams?: {
|
|
8
|
+
key: string;
|
|
9
|
+
value: string | number | boolean;
|
|
10
|
+
}[];
|
|
11
|
+
httpStatus?: number;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface BuildFormattedErrorArgs {
|
|
15
|
+
response: SyncErrorEnvelopeInput;
|
|
16
|
+
preferred?: string | null;
|
|
17
|
+
userLanguage?: string | null;
|
|
18
|
+
routeName: string | undefined;
|
|
19
|
+
transport: 'socket' | 'http';
|
|
20
|
+
userId?: string | null;
|
|
21
|
+
perRouteFormatter?: ErrorFormatter | undefined | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Normalize an error envelope (i18n message + envelope shape), then run it
|
|
25
|
+
* through the per-route → global → identity formatter chain. Transport
|
|
26
|
+
* handlers pass the same logical inputs; only `transport`, `routeName`, and
|
|
27
|
+
* the per-route formatter differ. Returns a typed, formatted error envelope.
|
|
28
|
+
*/
|
|
29
|
+
export declare const buildFormattedError: ({ response, preferred, userLanguage, routeName, transport, userId, perRouteFormatter, }: BuildFormattedErrorArgs) => FormattedSyncErrorEnvelope;
|
|
30
|
+
/**
|
|
31
|
+
* Guarantee a client-rejection envelope always carries a non-empty
|
|
32
|
+
* `errorCode`, falling back to `sync.clientRejected`. Shared by both
|
|
33
|
+
* transports' per-recipient `_client` error path.
|
|
34
|
+
*/
|
|
35
|
+
export declare const ensureSyncErrorShape: (response: SyncErrorEnvelopeInput) => SyncErrorEnvelopeInput;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const SYNC_ERROR_CODES: readonly ['sync.invalidName', 'sync.invalidVersion', 'sync.missingReceiver', 'sync.ioUnavailable', 'sync.failedRequest', 'sync.invalidServerResponse', 'sync.invalidRequestFormat', 'sync.requestTimeout', 'sync.invalidRequest', 'sync.invalidCallback', 'sync.notFound', 'sync.rateLimitExceeded', 'sync.invalidInputType', 'sync.serverExecutionFailed', 'sync.receiverNotAllowed', 'sync.notRoomMember', 'sync.noReceiversFound', 'sync.clientExecutionFailed', 'sync.invalidClientResponse', 'sync.clientRejected', 'sync.disabled', 'sync.methodNotAllowed', 'sync.devToolsUnavailable'];
|
|
2
|
+
/** Union of every framework-emitted `sync.*` error code. */
|
|
3
|
+
export type SyncErrorCode = (typeof SYNC_ERROR_CODES)[number];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Decision returned by {@link authorizeSyncReceiver}. */
|
|
2
|
+
export type ReceiverAuthDecision = {
|
|
3
|
+
allowed: true;
|
|
4
|
+
} | {
|
|
5
|
+
allowed: false;
|
|
6
|
+
errorCode: 'sync.receiverNotAllowed' | 'sync.notRoomMember';
|
|
7
|
+
};
|
|
8
|
+
export interface AuthorizeSyncReceiverInput {
|
|
9
|
+
/** The (already-trimmed, non-empty) receiver the client requested. */
|
|
10
|
+
receiver: string;
|
|
11
|
+
/** `sync.allowClientReceiverAll` from project config. */
|
|
12
|
+
allowClientReceiverAll: boolean;
|
|
13
|
+
/** `sync.requireRoomMembership` from project config. */
|
|
14
|
+
requireRoomMembership: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Membership predicate for the requested room. Pass a function that reports
|
|
17
|
+
* whether the requester has actually joined `receiver` — on the socket
|
|
18
|
+
* transport this is `socket.rooms.has(receiver)`; on HTTP/SSE (no originator
|
|
19
|
+
* socket) derive it from the session's persisted `roomCodes`. Pass `null`
|
|
20
|
+
* ONLY when membership genuinely cannot be determined for this requester
|
|
21
|
+
* (e.g. an anonymous HTTP caller with no session). When `null` and
|
|
22
|
+
* `requireRoomMembership` is true, this helper FAILS CLOSED (rejects) rather
|
|
23
|
+
* than silently delegating the security default to the opt-in
|
|
24
|
+
* `preSyncAuthorize` hook.
|
|
25
|
+
*/
|
|
26
|
+
isMember: (() => boolean) | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve whether a client may target `receiver` for a sync fanout under the
|
|
30
|
+
* configured default policy. Returns `{ allowed: true }` when permitted, or a
|
|
31
|
+
* specific generic error code when rejected. The `'all'` check runs first
|
|
32
|
+
* (cluster-wide broadcasts are the riskiest), then room-membership.
|
|
33
|
+
*/
|
|
34
|
+
export declare const authorizeSyncReceiver: ({ receiver, allowClientReceiverAll, requireRoomMembership, isMember, }: AuthorizeSyncReceiverInput) => ReceiverAuthDecision;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate a session token to `<first 4 chars>…` for safe logging. Returns the
|
|
3
|
+
* value unchanged when it is null/empty (nothing to redact) or already shorter
|
|
4
|
+
* than the visible prefix.
|
|
5
|
+
*/
|
|
6
|
+
export declare const redactToken: (token: string | null) => string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Redact a list of tokens (e.g. the `streamTo` recipient list) for logging.
|
|
9
|
+
*/
|
|
10
|
+
export declare const redactTokens: (tokens: readonly string[]) => string[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Socket } from 'socket.io';
|
|
2
|
+
export type SyncStreamPayload = Record<string, unknown>;
|
|
3
|
+
export interface FlushPressureOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Drain threshold in bytes. Used as a packet-count approximation —
|
|
6
|
+
* we assume an average packet size of ~1024 bytes and resolve once the
|
|
7
|
+
* engine.io writeBuffer length is below `thresholdBytes / 1024`.
|
|
8
|
+
* When omitted, falls back to `projectConfig.sync.flushPressure.maxBufferedBytes`
|
|
9
|
+
* (default 5 MiB as configured in the framework defaults).
|
|
10
|
+
*/
|
|
11
|
+
thresholdBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
export type FlushPressure = (options?: FlushPressureOptions) => Promise<void>;
|
|
14
|
+
export interface SyncStreamEmitters {
|
|
15
|
+
emitServerSyncStream: (payload?: SyncStreamPayload) => void;
|
|
16
|
+
emitBroadcastSyncStream: (payload?: SyncStreamPayload) => void;
|
|
17
|
+
emitStreamToTokens: (tokens: string | string[], payload?: SyncStreamPayload) => void;
|
|
18
|
+
buildBroadcastFrame: (payload: SyncStreamPayload) => Record<string, unknown>;
|
|
19
|
+
flushPressure: FlushPressure;
|
|
20
|
+
}
|
|
21
|
+
interface BuildSyncStreamEmittersArgs {
|
|
22
|
+
cb: string | undefined;
|
|
23
|
+
receiver: string;
|
|
24
|
+
/**
|
|
25
|
+
* Session user id of the request originator (or null when anonymous). Threaded
|
|
26
|
+
* through so `broadcastStream` / `flushPressure` can route the receiver room
|
|
27
|
+
* through `formatRoomName` with the SAME `{ purpose: 'broadcast', userId }`
|
|
28
|
+
* context the fanout in `handleSyncRequest` / `handleHttpSyncRequest` uses —
|
|
29
|
+
* otherwise a non-identity `registerRoomNameFormatter` (multi-tenant) makes
|
|
30
|
+
* broadcast streams target the UNFORMATTED room and miss every recipient.
|
|
31
|
+
*/
|
|
32
|
+
userId?: string | null;
|
|
33
|
+
resolvedName: string;
|
|
34
|
+
emitOriginatorChunk: (payload: SyncStreamPayload) => void;
|
|
35
|
+
logLabel: string;
|
|
36
|
+
/**
|
|
37
|
+
* AbortSignal sourced from the per-request controller in `handleSyncRequest`
|
|
38
|
+
* / `handleHttpSyncRequest`. When aborted (client disconnect, explicit
|
|
39
|
+
* `syncCancel`), every emit is short-circuited with a single dev-log line
|
|
40
|
+
* and `flushPressure()` resolves immediately.
|
|
41
|
+
*/
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
/**
|
|
44
|
+
* Originator socket — used to drive `flushPressure` measurement when the
|
|
45
|
+
* stream targets only the originator. Optional because the HTTP/SSE
|
|
46
|
+
* transport has no originator socket (SSE response writer is the sink).
|
|
47
|
+
*/
|
|
48
|
+
originatorSocket?: Socket;
|
|
49
|
+
}
|
|
50
|
+
export declare const buildSyncStreamEmitters: ({ cb, receiver, userId, resolvedName, emitOriginatorChunk, logLabel, signal, originatorSocket, }: BuildSyncStreamEmittersArgs) => SyncStreamEmitters;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AuthProps, BaseSessionLayout as SessionLayout, ErrorFormatter } from '@luckystack/core';
|
|
2
|
+
import type { FlushPressure } from './streamEmitters.js';
|
|
3
|
+
export type SyncStreamPayload = Record<string, unknown>;
|
|
4
|
+
export interface RuntimeErrorResponse {
|
|
5
|
+
status: 'error';
|
|
6
|
+
errorCode?: string;
|
|
7
|
+
errorParams?: {
|
|
8
|
+
key: string;
|
|
9
|
+
value: string | number | boolean;
|
|
10
|
+
}[];
|
|
11
|
+
httpStatus?: number;
|
|
12
|
+
message?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface RuntimeSuccessResponse {
|
|
16
|
+
status: 'success';
|
|
17
|
+
message?: string;
|
|
18
|
+
httpStatus?: number;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
export type RuntimeSyncResponse = RuntimeSuccessResponse | RuntimeErrorResponse;
|
|
22
|
+
export type SyncBroadcastStream = (payload?: SyncStreamPayload) => void;
|
|
23
|
+
export type SyncStreamTo = (tokens: string | string[], payload?: SyncStreamPayload) => void;
|
|
24
|
+
export interface RuntimeSyncServerEntry {
|
|
25
|
+
auth: AuthProps;
|
|
26
|
+
main: (params: {
|
|
27
|
+
clientInput: Record<string, unknown>;
|
|
28
|
+
user: SessionLayout | null;
|
|
29
|
+
functions: Record<string, unknown>;
|
|
30
|
+
roomCode: string;
|
|
31
|
+
stream: (payload?: SyncStreamPayload) => void;
|
|
32
|
+
broadcastStream: SyncBroadcastStream;
|
|
33
|
+
streamTo: SyncStreamTo;
|
|
34
|
+
abortSignal: AbortSignal;
|
|
35
|
+
flushPressure: FlushPressure;
|
|
36
|
+
}) => Promise<RuntimeSyncResponse>;
|
|
37
|
+
inputType?: string;
|
|
38
|
+
inputTypeFilePath?: string;
|
|
39
|
+
validation?: 'strict' | 'relaxed' | {
|
|
40
|
+
input: 'skip' | 'strict';
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Per-route rate limit (mirrors `@luckystack/api`). Overrides
|
|
44
|
+
* `rateLimiting.defaultApiLimit` for this sync route's per-requester bucket;
|
|
45
|
+
* `false` disables it (the global per-IP bucket still applies); omit to fall
|
|
46
|
+
* back to `defaultApiLimit`. Honored by both transports (passed as
|
|
47
|
+
* `routeLimit` into `applySyncRateLimits` / `applyHttpSyncRateLimits`).
|
|
48
|
+
*/
|
|
49
|
+
rateLimit?: number | false;
|
|
50
|
+
/**
|
|
51
|
+
* Per-route error response formatter. Falls back to the global formatter
|
|
52
|
+
* from `registerErrorFormatter(...)`, then to the framework default
|
|
53
|
+
* `normalizeErrorResponse`. Same contract as the API handler — both
|
|
54
|
+
* transports honor the same `errorFormatter` export.
|
|
55
|
+
*/
|
|
56
|
+
errorFormatter?: ErrorFormatter;
|
|
57
|
+
}
|
|
58
|
+
export type RuntimeSyncClientHandler = (params: {
|
|
59
|
+
clientInput: Record<string, unknown>;
|
|
60
|
+
token: string | null;
|
|
61
|
+
functions: Record<string, unknown>;
|
|
62
|
+
serverOutput: unknown;
|
|
63
|
+
roomCode: string;
|
|
64
|
+
stream: (payload?: SyncStreamPayload) => void;
|
|
65
|
+
}) => Promise<RuntimeSyncResponse>;
|
|
66
|
+
export interface SyncErrorEnvelopeInput {
|
|
67
|
+
status: 'error';
|
|
68
|
+
errorCode?: string;
|
|
69
|
+
errorParams?: {
|
|
70
|
+
key: string;
|
|
71
|
+
value: string | number | boolean;
|
|
72
|
+
}[];
|
|
73
|
+
httpStatus?: number;
|
|
74
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,129 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
|
|
6
|
-
type SyncRequestStreamEvent<T extends StreamPayload = StreamPayload> = T;
|
|
7
|
-
type SyncRouteStreamEvent<T extends StreamPayload = StreamPayload> = T;
|
|
8
|
-
type DataRequired<T> = Record<string, never> extends T ? false : true;
|
|
9
|
-
type UnionToIntersection<U> = (U extends unknown ? (arg: U) => void : never) extends ((arg: infer I) => void) ? I : never;
|
|
10
|
-
type Prettify<T> = {
|
|
11
|
-
[K in keyof T]: T[K];
|
|
12
|
-
} & {};
|
|
13
|
-
type SyncRouteRecord = UnionToIntersection<{
|
|
14
|
-
[P in keyof SyncTypeMap]: {
|
|
15
|
-
[N in keyof SyncTypeMap[P] as P extends 'root' ? `system/${Extract<N, string>}` : `${Extract<P, string>}/${Extract<N, string>}`]: SyncTypeMap[P][N];
|
|
16
|
-
};
|
|
17
|
-
}[keyof SyncTypeMap]>;
|
|
18
|
-
type SyncFullName = Extract<keyof SyncRouteRecord, string>;
|
|
19
|
-
type VersionsForFullName<F extends SyncFullName> = Extract<keyof SyncRouteRecord[F], string>;
|
|
20
|
-
type ClientInputForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
21
|
-
clientInput: infer I;
|
|
22
|
-
} ? I : never;
|
|
23
|
-
type ServerOutputForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
24
|
-
serverOutput: infer O;
|
|
25
|
-
} ? O : never;
|
|
26
|
-
type ClientOutputForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
27
|
-
clientOutput: infer O;
|
|
28
|
-
} ? O : never;
|
|
29
|
-
type ServerStreamForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
30
|
-
serverStream: infer O;
|
|
31
|
-
} ? O : never;
|
|
32
|
-
type ClientStreamForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
33
|
-
clientStream: infer O;
|
|
34
|
-
} ? O : never;
|
|
35
|
-
type SyncRequestStreamCallbackForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = [
|
|
36
|
-
ServerStreamForFullName<F, V>
|
|
37
|
-
] extends [never] ? never : (event: SyncRequestStreamEvent<Prettify<ServerStreamForFullName<F, V> extends StreamPayload ? ServerStreamForFullName<F, V> : StreamPayload>>) => void;
|
|
38
|
-
type CombinedRouteStream<F extends SyncFullName, V extends VersionsForFullName<F>> = (ClientStreamForFullName<F, V> extends never ? never : ClientStreamForFullName<F, V>) | (ServerStreamForFullName<F, V> extends never ? never : ServerStreamForFullName<F, V>);
|
|
39
|
-
type SyncRouteStreamCallbackForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = [
|
|
40
|
-
CombinedRouteStream<F, V>
|
|
41
|
-
] extends [never] ? never : (params: {
|
|
42
|
-
stream: SyncRouteStreamEvent<Prettify<CombinedRouteStream<F, V> extends StreamPayload ? CombinedRouteStream<F, V> : StreamPayload>>;
|
|
43
|
-
}) => void;
|
|
44
|
-
type SyncParamsForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = DataRequired<ClientInputForFullName<F, V>> extends true ? {
|
|
45
|
-
name: F;
|
|
46
|
-
version: V;
|
|
47
|
-
data: ClientInputForFullName<F, V>;
|
|
48
|
-
receiver: string;
|
|
49
|
-
ignoreSelf?: boolean;
|
|
50
|
-
onStream?: SyncRequestStreamCallbackForFullName<F, V>;
|
|
51
|
-
/**
|
|
52
|
-
* Per-request override of `projectConfig.offlineQueue.dropPolicy`. Lets a
|
|
53
|
-
* specific sync ("editor cursor move") pick `'drop-oldest'` while the
|
|
54
|
-
* app default stays `'reject'` for safer sends. When omitted, falls back
|
|
55
|
-
* to the global config.
|
|
56
|
-
*/
|
|
57
|
-
offlineDropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
|
|
58
|
-
/**
|
|
59
|
-
* Optional AbortSignal. When aborted the client emits `syncCancel { cb }`
|
|
60
|
-
* to the server and resolves locally with
|
|
61
|
-
* `{ status: 'error', errorCode: 'request.aborted' }`.
|
|
62
|
-
*/
|
|
63
|
-
signal?: AbortSignal;
|
|
64
|
-
} : {
|
|
65
|
-
name: F;
|
|
66
|
-
version: V;
|
|
67
|
-
data?: ClientInputForFullName<F, V>;
|
|
68
|
-
receiver: string;
|
|
69
|
-
ignoreSelf?: boolean;
|
|
70
|
-
onStream?: SyncRequestStreamCallbackForFullName<F, V>;
|
|
71
|
-
/** Per-request override (see typed branch). */
|
|
72
|
-
offlineDropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
|
|
73
|
-
/** Optional AbortSignal — see typed branch. */
|
|
74
|
-
signal?: AbortSignal;
|
|
75
|
-
};
|
|
76
|
-
interface SyncErrorParam {
|
|
77
|
-
key: string;
|
|
78
|
-
value: string | number | boolean;
|
|
79
|
-
}
|
|
80
|
-
interface SyncResponseError {
|
|
81
|
-
status: 'error';
|
|
82
|
-
message: string;
|
|
83
|
-
errorCode: string;
|
|
84
|
-
errorParams?: SyncErrorParam[];
|
|
85
|
-
httpStatus?: number;
|
|
86
|
-
}
|
|
87
|
-
type SyncResultForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = [
|
|
88
|
-
ServerOutputForFullName<F, V>
|
|
89
|
-
] extends [never] ? Record<string, never> : ServerOutputForFullName<F, V>;
|
|
90
|
-
type SyncRequestResponseForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncResponseError | {
|
|
91
|
-
status: 'success';
|
|
92
|
-
message: string;
|
|
93
|
-
result: SyncResultForFullName<F, V>;
|
|
94
|
-
};
|
|
95
|
-
type SyncRequestParamsWithOptions<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncParamsForFullName<F, V>;
|
|
96
|
-
declare function syncRequest<F extends SyncFullName, V extends VersionsForFullName<F>>(params: SyncRequestParamsWithOptions<F, V>): Promise<Prettify<SyncRequestResponseForFullName<F, V>>>;
|
|
97
|
-
interface TypedCallbackParams<F extends SyncFullName, V extends VersionsForFullName<F>> {
|
|
98
|
-
clientOutput: ClientOutputForFullName<F, V>;
|
|
99
|
-
serverOutput: ServerOutputForFullName<F, V>;
|
|
100
|
-
}
|
|
101
|
-
interface UpsertParams<F extends SyncFullName, V extends VersionsForFullName<F>> {
|
|
102
|
-
name: F;
|
|
103
|
-
version: V;
|
|
104
|
-
callback: (params: TypedCallbackParams<F, V>) => void;
|
|
105
|
-
}
|
|
106
|
-
interface UpsertStreamParams<F extends SyncFullName, V extends VersionsForFullName<F>> {
|
|
107
|
-
name: F;
|
|
108
|
-
version: V;
|
|
109
|
-
callback: SyncRouteStreamCallbackForFullName<F, V>;
|
|
110
|
-
}
|
|
111
|
-
declare const useSyncEvents: () => {
|
|
112
|
-
upsertSyncEventCallback: <F extends SyncFullName, V extends VersionsForFullName<F>>(params: UpsertParams<F, V>) => (() => void);
|
|
113
|
-
upsertSyncEventStreamCallback: <F extends SyncFullName, V extends VersionsForFullName<F>>(params: UpsertStreamParams<F, V>) => (() => void);
|
|
114
|
-
};
|
|
115
|
-
declare const useSyncEventTrigger: () => {
|
|
116
|
-
triggerSyncEvent: (name: string, clientOutput?: unknown, serverOutput?: unknown) => void;
|
|
117
|
-
triggerSyncStreamEvent: (name: string, stream: SyncRouteStreamEvent) => void;
|
|
118
|
-
};
|
|
119
|
-
declare const attachSyncReceiver: (socketInstance: Socket) => void;
|
|
120
|
-
type SocketStatusSetter = Dispatch<SetStateAction<{
|
|
121
|
-
self: statusContent;
|
|
122
|
-
[userId: string]: statusContent;
|
|
123
|
-
}>>;
|
|
124
|
-
declare const initSyncRequest: ({ setSocketStatus, sessionRef }: {
|
|
125
|
-
setSocketStatus: SocketStatusSetter;
|
|
126
|
-
sessionRef: RefObject<BaseSessionLayout | null> | null;
|
|
127
|
-
}) => Promise<void>;
|
|
128
|
-
|
|
129
|
-
export { type SyncRequestStreamEvent, type SyncRouteStreamEvent, attachSyncReceiver, initSyncRequest, syncRequest, useSyncEventTrigger, useSyncEvents };
|
|
1
|
+
export { syncRequest, useSyncEvents, useSyncEventTrigger, initSyncRequest, attachSyncReceiver, } from './syncRequest.js';
|
|
2
|
+
export type { SyncRequestStreamEvent, SyncRouteStreamEvent } from './syncRequest.js';
|
|
3
|
+
export { SYNC_ERROR_CODES } from './_shared/errorCodes.js';
|
|
4
|
+
export type { SyncErrorCode } from './_shared/errorCodes.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SyncStreamPayload } from './_shared/syncTypes.js';
|
|
2
|
+
interface HttpSyncRequestParams {
|
|
3
|
+
name: string;
|
|
4
|
+
cb?: string;
|
|
5
|
+
data: Record<string, unknown>;
|
|
6
|
+
receiver: string;
|
|
7
|
+
ignoreSelf?: boolean;
|
|
8
|
+
token: string | null;
|
|
9
|
+
requesterIp?: string;
|
|
10
|
+
xLanguageHeader?: string | string[];
|
|
11
|
+
acceptLanguageHeader?: string | string[];
|
|
12
|
+
stream?: (payload: HttpSyncStreamEvent) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Optional AbortSignal. The HTTP server (`@luckystack/server`) wires this
|
|
15
|
+
* to `req.on('close', ...)` so a closed SSE connection aborts in-flight
|
|
16
|
+
* stream emits. Sync handlers receive it as `abortSignal` in params.
|
|
17
|
+
*/
|
|
18
|
+
abortSignal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
interface HttpSyncResponse {
|
|
21
|
+
status: 'success' | 'error';
|
|
22
|
+
message: string;
|
|
23
|
+
result?: unknown;
|
|
24
|
+
errorCode?: string;
|
|
25
|
+
errorParams?: {
|
|
26
|
+
key: string;
|
|
27
|
+
value: string | number | boolean;
|
|
28
|
+
}[];
|
|
29
|
+
httpStatus?: number;
|
|
30
|
+
}
|
|
31
|
+
export type HttpSyncStreamEvent = SyncStreamPayload;
|
|
32
|
+
export default function handleHttpSyncRequest(args: HttpSyncRequestParams): Promise<HttpSyncResponse>;
|
|
33
|
+
export {};
|