@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 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
- > 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.
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
- ## Install
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,2 @@
1
+ export declare const shouldLogDev: () => boolean;
2
+ export declare const shouldLogStream: () => boolean;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { RuntimeSyncServerEntry } from './syncTypes.js';
2
+ export declare const resolveSyncValidationMode: (validation: RuntimeSyncServerEntry['validation']) => 'strict' | 'relaxed';
package/dist/client.d.ts CHANGED
@@ -1,129 +1,4 @@
1
- import { StreamPayload, statusContent, BaseSessionLayout, SyncTypeMap } from '@luckystack/core/client';
2
- import { Dispatch, SetStateAction, RefObject } from 'react';
3
- import { Socket } from 'socket.io-client';
4
- export { S as SYNC_ERROR_CODES, a as SyncErrorCode } from './errorCodes-B8GoTenr.js';
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 {};
@@ -0,0 +1,7 @@
1
+ import type { syncMessage } from "@luckystack/core";
2
+ import { Socket } from "socket.io";
3
+ export default function handleSyncRequest(args: {
4
+ msg: syncMessage;
5
+ socket: Socket;
6
+ token: string | null;
7
+ }): Promise<void>;