@luckystack/sync 0.10.2 → 0.12.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 +13 -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 +5 -5
- package/CLAUDE.md +0 -128
- package/dist/errorCodes-B8GoTenr.d.ts +0 -5
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface CreateStreamThrottleOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Flush buffered text once it crosses this many characters. Default: 32.
|
|
4
|
+
* Lower = more updates, more network traffic.
|
|
5
|
+
* Higher = fewer updates, choppier UI.
|
|
6
|
+
*/
|
|
7
|
+
flushAtChars?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Flush buffered text after this many milliseconds even if the char
|
|
10
|
+
* threshold hasn't been hit. Default: 50ms — fast enough that the user
|
|
11
|
+
* perceives "live typing", slow enough that 50–100 tokens batch into a
|
|
12
|
+
* single message on a fast LLM stream.
|
|
13
|
+
*
|
|
14
|
+
* Set to `false` to disable the timer (only flush at char threshold or
|
|
15
|
+
* on explicit `flush()`).
|
|
16
|
+
*/
|
|
17
|
+
flushEveryMs?: number | false;
|
|
18
|
+
/**
|
|
19
|
+
* Field name on the emitted payload that carries the buffered text.
|
|
20
|
+
* Default: `'chunk'`. Override if your stream payload uses a different
|
|
21
|
+
* key (e.g. `'text'`, `'delta'`).
|
|
22
|
+
*/
|
|
23
|
+
field?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface StreamThrottle {
|
|
26
|
+
/** Append text to the buffer. May trigger a flush. */
|
|
27
|
+
push: (text: string, emit: (payload: Record<string, unknown>) => void) => void;
|
|
28
|
+
/** Force-flush whatever is in the buffer right now. Call after the source loop ends. */
|
|
29
|
+
flush: (emit: (payload: Record<string, unknown>) => void) => void;
|
|
30
|
+
/** Drop the buffered text without emitting. Useful on abort. */
|
|
31
|
+
reset: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build a chunk throttle for streaming use cases. Designed for LLM token
|
|
35
|
+
* streams where the provider yields very small pieces (3–10 chars) and you
|
|
36
|
+
* don't want to send a separate socket message for each one.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const throttle = createStreamThrottle({ flushEveryMs: 50, flushAtChars: 32 });
|
|
40
|
+
* for await (const chunk of openaiStream) {
|
|
41
|
+
* throttle.push(chunk.text, broadcastStream);
|
|
42
|
+
* }
|
|
43
|
+
* throttle.flush(broadcastStream);
|
|
44
|
+
*/
|
|
45
|
+
export declare const createStreamThrottle: (options?: CreateStreamThrottleOptions) => StreamThrottle;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { BaseSessionLayout as SessionLayout, StreamPayload, SyncTypeMap, statusContent } from "@luckystack/core/client";
|
|
2
|
+
import { Dispatch, RefObject, SetStateAction } from "react";
|
|
3
|
+
import { Socket } from "socket.io-client";
|
|
4
|
+
export type SyncRequestStreamEvent<T extends StreamPayload = StreamPayload> = T;
|
|
5
|
+
export type SyncRouteStreamEvent<T extends StreamPayload = StreamPayload> = T;
|
|
6
|
+
type DataRequired<T> = Record<string, never> extends T ? false : true;
|
|
7
|
+
type UnionToIntersection<U> = (U extends unknown ? (arg: U) => void : never) extends ((arg: infer I) => void) ? I : never;
|
|
8
|
+
type Prettify<T> = {
|
|
9
|
+
[K in keyof T]: T[K];
|
|
10
|
+
} & {};
|
|
11
|
+
type SyncRouteRecord = UnionToIntersection<{
|
|
12
|
+
[P in keyof SyncTypeMap]: {
|
|
13
|
+
[N in keyof SyncTypeMap[P] as P extends 'root' ? `system/${Extract<N, string>}` : `${Extract<P, string>}/${Extract<N, string>}`]: SyncTypeMap[P][N];
|
|
14
|
+
};
|
|
15
|
+
}[keyof SyncTypeMap]>;
|
|
16
|
+
type SyncFullName = Extract<keyof SyncRouteRecord, string>;
|
|
17
|
+
type VersionsForFullName<F extends SyncFullName> = Extract<keyof SyncRouteRecord[F], string>;
|
|
18
|
+
type ClientInputForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
19
|
+
clientInput: infer I;
|
|
20
|
+
} ? I : never;
|
|
21
|
+
type ServerOutputForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
22
|
+
serverOutput: infer O;
|
|
23
|
+
} ? O : never;
|
|
24
|
+
type ClientOutputForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
25
|
+
clientOutput: infer O;
|
|
26
|
+
} ? O : never;
|
|
27
|
+
type ServerStreamForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
28
|
+
serverStream: infer O;
|
|
29
|
+
} ? O : never;
|
|
30
|
+
type ClientStreamForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncRouteRecord[F][V] extends {
|
|
31
|
+
clientStream: infer O;
|
|
32
|
+
} ? O : never;
|
|
33
|
+
type SyncRequestStreamCallbackForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = [
|
|
34
|
+
ServerStreamForFullName<F, V>
|
|
35
|
+
] extends [never] ? never : (event: SyncRequestStreamEvent<Prettify<ServerStreamForFullName<F, V> extends StreamPayload ? ServerStreamForFullName<F, V> : StreamPayload>>) => void;
|
|
36
|
+
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>);
|
|
37
|
+
type SyncRouteStreamCallbackForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = [
|
|
38
|
+
CombinedRouteStream<F, V>
|
|
39
|
+
] extends [never] ? never : (params: {
|
|
40
|
+
stream: SyncRouteStreamEvent<Prettify<CombinedRouteStream<F, V> extends StreamPayload ? CombinedRouteStream<F, V> : StreamPayload>>;
|
|
41
|
+
}) => void;
|
|
42
|
+
type SyncParamsForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = DataRequired<ClientInputForFullName<F, V>> extends true ? {
|
|
43
|
+
name: F;
|
|
44
|
+
version: V;
|
|
45
|
+
data: ClientInputForFullName<F, V>;
|
|
46
|
+
receiver: string;
|
|
47
|
+
ignoreSelf?: boolean;
|
|
48
|
+
onStream?: SyncRequestStreamCallbackForFullName<F, V>;
|
|
49
|
+
/**
|
|
50
|
+
* Per-request override of `projectConfig.offlineQueue.dropPolicy`. Lets a
|
|
51
|
+
* specific sync ("editor cursor move") pick `'drop-oldest'` while the
|
|
52
|
+
* app default stays `'reject'` for safer sends. When omitted, falls back
|
|
53
|
+
* to the global config.
|
|
54
|
+
*/
|
|
55
|
+
offlineDropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
|
|
56
|
+
/**
|
|
57
|
+
* Optional AbortSignal. When aborted the client emits `syncCancel { cb }`
|
|
58
|
+
* to the server and resolves locally with
|
|
59
|
+
* `{ status: 'error', errorCode: 'request.aborted' }`.
|
|
60
|
+
*/
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
} : {
|
|
63
|
+
name: F;
|
|
64
|
+
version: V;
|
|
65
|
+
data?: ClientInputForFullName<F, V>;
|
|
66
|
+
receiver: string;
|
|
67
|
+
ignoreSelf?: boolean;
|
|
68
|
+
onStream?: SyncRequestStreamCallbackForFullName<F, V>;
|
|
69
|
+
/** Per-request override (see typed branch). */
|
|
70
|
+
offlineDropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
|
|
71
|
+
/** Optional AbortSignal — see typed branch. */
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
};
|
|
74
|
+
interface SyncErrorParam {
|
|
75
|
+
key: string;
|
|
76
|
+
value: string | number | boolean;
|
|
77
|
+
}
|
|
78
|
+
interface SyncResponseError {
|
|
79
|
+
status: 'error';
|
|
80
|
+
message: string;
|
|
81
|
+
errorCode: string;
|
|
82
|
+
errorParams?: SyncErrorParam[];
|
|
83
|
+
httpStatus?: number;
|
|
84
|
+
}
|
|
85
|
+
type SyncResultForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = [
|
|
86
|
+
ServerOutputForFullName<F, V>
|
|
87
|
+
] extends [never] ? Record<string, never> : ServerOutputForFullName<F, V>;
|
|
88
|
+
type SyncRequestResponseForFullName<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncResponseError | {
|
|
89
|
+
status: 'success';
|
|
90
|
+
message: string;
|
|
91
|
+
result: SyncResultForFullName<F, V>;
|
|
92
|
+
};
|
|
93
|
+
type SyncRequestParamsWithOptions<F extends SyncFullName, V extends VersionsForFullName<F>> = SyncParamsForFullName<F, V>;
|
|
94
|
+
export declare function syncRequest<F extends SyncFullName, V extends VersionsForFullName<F>>(params: SyncRequestParamsWithOptions<F, V>): Promise<Prettify<SyncRequestResponseForFullName<F, V>>>;
|
|
95
|
+
interface TypedCallbackParams<F extends SyncFullName, V extends VersionsForFullName<F>> {
|
|
96
|
+
clientOutput: ClientOutputForFullName<F, V>;
|
|
97
|
+
serverOutput: ServerOutputForFullName<F, V>;
|
|
98
|
+
}
|
|
99
|
+
interface UpsertParams<F extends SyncFullName, V extends VersionsForFullName<F>> {
|
|
100
|
+
name: F;
|
|
101
|
+
version: V;
|
|
102
|
+
callback: (params: TypedCallbackParams<F, V>) => void;
|
|
103
|
+
}
|
|
104
|
+
interface UpsertStreamParams<F extends SyncFullName, V extends VersionsForFullName<F>> {
|
|
105
|
+
name: F;
|
|
106
|
+
version: V;
|
|
107
|
+
callback: SyncRouteStreamCallbackForFullName<F, V>;
|
|
108
|
+
}
|
|
109
|
+
export declare const useSyncEvents: () => {
|
|
110
|
+
upsertSyncEventCallback: <F extends SyncFullName, V extends VersionsForFullName<F>>(params: UpsertParams<F, V>) => (() => void);
|
|
111
|
+
upsertSyncEventStreamCallback: <F extends SyncFullName, V extends VersionsForFullName<F>>(params: UpsertStreamParams<F, V>) => (() => void);
|
|
112
|
+
};
|
|
113
|
+
export declare const useSyncEventTrigger: () => {
|
|
114
|
+
triggerSyncEvent: (name: string, clientOutput?: unknown, serverOutput?: unknown) => void;
|
|
115
|
+
triggerSyncStreamEvent: (name: string, stream: SyncRouteStreamEvent) => void;
|
|
116
|
+
};
|
|
117
|
+
export declare const attachSyncReceiver: (socketInstance: Socket) => void;
|
|
118
|
+
type SocketStatusSetter = Dispatch<SetStateAction<{
|
|
119
|
+
self: statusContent;
|
|
120
|
+
[userId: string]: statusContent;
|
|
121
|
+
}>>;
|
|
122
|
+
export declare const initSyncRequest: ({ setSocketStatus, sessionRef }: {
|
|
123
|
+
setSocketStatus: SocketStatusSetter;
|
|
124
|
+
sessionRef: RefObject<SessionLayout | null> | null;
|
|
125
|
+
}) => Promise<void>;
|
|
126
|
+
export {};
|
|
@@ -1,257 +1,5 @@
|
|
|
1
|
-
# callback
|
|
1
|
+
# Sync callback registration
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Component-scoped subscriptions should clean up only their own callback, not another component's listener for the same event. A session reference must be read at event time; closing over an old session could attribute notifications to a logged-out user. See `../src/client.ts` and `../../core/src/client.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. `useSyncEvents()` — the component-scoped hook
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
import { useSyncEvents } from '@luckystack/sync/client';
|
|
13
|
-
|
|
14
|
-
const { upsertSyncEventCallback, upsertSyncEventStreamCallback } = useSyncEvents();
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Returns two stable (`useCallback`-wrapped) registrar functions. The hook also installs a cleanup `useEffect` that removes every callback registered through this hook instance on unmount.
|
|
18
|
-
|
|
19
|
-
Key properties:
|
|
20
|
-
|
|
21
|
-
- **Component-scoped local registry.** Each `useSyncEvents()` call holds its own `Map<fullName, Callback>` so the cleanup effect only removes callbacks added by that component, not callbacks other components registered for the same route.
|
|
22
|
-
- **Automatic deduplication of same-key re-registration.** Calling `upsertSyncEventCallback` twice with the same name+version from the same component replaces the previous callback (idempotent). Calling it from different components adds independent entries.
|
|
23
|
-
- **`useCallback`-stable identity.** The registrar functions don't change identity between renders, so dependency arrays (`useEffect(..., [upsertSyncEventCallback])`) don't churn.
|
|
24
|
-
|
|
25
|
-
### Typical usage
|
|
26
|
-
|
|
27
|
-
```tsx
|
|
28
|
-
import { useEffect } from 'react';
|
|
29
|
-
import { useSyncEvents } from '@luckystack/sync/client';
|
|
30
|
-
|
|
31
|
-
export const BoardSubscriber = () => {
|
|
32
|
-
const { upsertSyncEventCallback } = useSyncEvents();
|
|
33
|
-
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
const unsubscribe = upsertSyncEventCallback({
|
|
36
|
-
name: 'board/moveCard',
|
|
37
|
-
version: 'v1',
|
|
38
|
-
callback: ({ serverOutput, clientOutput }) => {
|
|
39
|
-
if (serverOutput.status !== 'success') return;
|
|
40
|
-
setCards(prev => moveCardLocally(prev, serverOutput.cardId));
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
return unsubscribe;
|
|
44
|
-
}, [upsertSyncEventCallback]);
|
|
45
|
-
|
|
46
|
-
return null;
|
|
47
|
-
};
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
The returned `unsubscribe` lets you cancel before the component unmounts — useful when the subscription is conditional on props or routing.
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## 2. `upsertSyncEventCallback({ name, version, callback })`
|
|
55
|
-
|
|
56
|
-
Subscribes to **non-stream** sync payloads — the `{ serverOutput, clientOutput, status: 'success' }` frames that `handleSyncRequest` emits at the end of the per-recipient loop.
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
upsertSyncEventCallback<F, V>(params: {
|
|
60
|
-
name: F; // SyncFullName literal, typed
|
|
61
|
-
version: V; // VersionsForFullName<F> literal
|
|
62
|
-
callback: ({
|
|
63
|
-
clientOutput: ClientOutputForFullName<F, V>,
|
|
64
|
-
serverOutput: ServerOutputForFullName<F, V>,
|
|
65
|
-
}) => void;
|
|
66
|
-
}): () => void // returns unsubscribe function
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Typed payloads (root contract rule 21, no casts)
|
|
70
|
-
|
|
71
|
-
Both `clientOutput` and `serverOutput` are typed via the generated `SyncTypeMap`. The framework's discriminated unions surface every possible status:
|
|
72
|
-
|
|
73
|
-
```ts
|
|
74
|
-
upsertSyncEventCallback({
|
|
75
|
-
name: 'board/moveCard',
|
|
76
|
-
version: 'v1',
|
|
77
|
-
callback: ({ serverOutput, clientOutput }) => {
|
|
78
|
-
// serverOutput is the typed union (success | error variants) from the generated map.
|
|
79
|
-
if (serverOutput.status !== 'success') return;
|
|
80
|
-
// After the narrowing, TS knows the success-shape fields are available.
|
|
81
|
-
console.log(serverOutput.cardId, serverOutput.movedBy);
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Never cast these to `unknown` or `any` (root `CLAUDE.md` rule 21). If inference fails, run `npm run generateArtifacts`; if generated output is wrong, fix the generator rather than casting around it.
|
|
87
|
-
|
|
88
|
-
### Validation rules
|
|
89
|
-
|
|
90
|
-
`upsertSyncEventCallback` rejects (returns the `noop` unsubscriber, logs in dev mode) when:
|
|
91
|
-
|
|
92
|
-
- `params.version` isn't a string -> `sync.invalidVersion`.
|
|
93
|
-
- `params.callback` isn't a function -> `sync.invalidCallback`.
|
|
94
|
-
- `params.name` isn't a valid service route name -> `routing.invalidServiceRouteName`.
|
|
95
|
-
|
|
96
|
-
These are dev-time guards. In production the runtime errors surface only if generated types are mis-applied at the call site.
|
|
97
|
-
|
|
98
|
-
### Multiple components subscribing to the same route
|
|
99
|
-
|
|
100
|
-
Supported and intentional. Each component's registrar pushes a separate callback into the route's array; `triggerSyncCallbacks` iterates and fires each. Cleanup is per-callback — unmounting Component A doesn't affect Component B's subscription.
|
|
101
|
-
|
|
102
|
-
### Duplicate callback handling (same reference)
|
|
103
|
-
|
|
104
|
-
If the **exact same callback function reference** is registered twice (rare — usually a bug where `useEffect` runs without dependency-checking), the registrar warns in dev mode and ignores the second add. The local registry still tracks the callback so the cleanup works. This guards against runaway callback arrays without breaking legitimate multi-component subscriptions.
|
|
105
|
-
|
|
106
|
-
---
|
|
107
|
-
|
|
108
|
-
## 3. `upsertSyncEventStreamCallback({ name, version, callback })`
|
|
109
|
-
|
|
110
|
-
Subscribes to **stream chunks** — the `{ status: 'stream', cb, fullName, ...payload }` frames emitted via `broadcastStream`, `streamTo`, or `_client.stream(...)`.
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
upsertSyncEventStreamCallback<F, V>(params: {
|
|
114
|
-
name: F;
|
|
115
|
-
version: V;
|
|
116
|
-
callback: ({ stream }: { stream: SyncRouteStreamEvent<...> }) => void;
|
|
117
|
-
}): () => void
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
The `stream` parameter type is the union of:
|
|
121
|
-
|
|
122
|
-
- The route's `serverStream` type (from `broadcastStream` and `streamTo` payloads).
|
|
123
|
-
- The route's `clientStream` type (from per-recipient `_client.stream(payload)`).
|
|
124
|
-
|
|
125
|
-
If neither side ever streams, the callback type collapses to `never` and TS rejects the registration call at compile time — a compile-time guarantee that you can't subscribe to a non-streaming route.
|
|
126
|
-
|
|
127
|
-
```ts
|
|
128
|
-
upsertSyncEventStreamCallback({
|
|
129
|
-
name: 'chat/sendMessage',
|
|
130
|
-
version: 'v1',
|
|
131
|
-
callback: ({ stream }) => {
|
|
132
|
-
if ('chunk' in stream) appendToken(stream.chunk);
|
|
133
|
-
else if (stream.status === 'started') showTypingIndicator(stream.author);
|
|
134
|
-
else if (stream.status === 'done') hideTypingIndicator();
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Stream callbacks run for **every chunk** of every fanout to this recipient — there is no per-request scoping. Use `onStream` on `syncRequest` instead when you want per-request scoping for originator-targeted chunks.
|
|
140
|
-
|
|
141
|
-
The same dedup / multi-subscriber / unsubscribe semantics from `upsertSyncEventCallback` apply.
|
|
142
|
-
|
|
143
|
-
---
|
|
144
|
-
|
|
145
|
-
## 4. `useSyncEventTrigger()` — manual local fire
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
const { triggerSyncEvent, triggerSyncStreamEvent } = useSyncEventTrigger();
|
|
149
|
-
|
|
150
|
-
triggerSyncEvent('sync/board/moveCard/v1', { /* clientOutput */ }, { /* serverOutput */ });
|
|
151
|
-
triggerSyncStreamEvent('sync/board/moveCard/v1', { chunk: 'partial' });
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Bypasses the wire entirely. The framework's internal `triggerSyncCallbacks` / `triggerSyncStreamCallbacks` are called directly, firing every registered subscriber as if the payload had arrived from the server.
|
|
155
|
-
|
|
156
|
-
Use cases:
|
|
157
|
-
|
|
158
|
-
- **Local testing** — fire a fake payload to verify your UI subscriber works without spinning up the server pipeline.
|
|
159
|
-
- **Optimistic local echo** — apply a payload to your own UI without sending it through the network (rare — usually optimistic UI just mutates local state directly, no need to round-trip the sync system).
|
|
160
|
-
- **Storybook / fixtures** — drive subscriber components with deterministic payloads.
|
|
161
|
-
|
|
162
|
-
The argument names are different from the wire format:
|
|
163
|
-
|
|
164
|
-
- First argument: the **full route name** (`sync/<page>/<name>/v<N>`). The `sync/` prefix is part of the internal routing key.
|
|
165
|
-
- Second argument: `clientOutput`.
|
|
166
|
-
- Third argument: `serverOutput`.
|
|
167
|
-
|
|
168
|
-
Note: the parameter order is `(name, clientOutput, serverOutput)` — opposite of what you might expect from reading the wire frame, which lists `serverOutput` first. The internal callback signature is `{ clientOutput, serverOutput }` (object) but the trigger helper passes positionally for ergonomics.
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## 5. `initSyncRequest({ setSocketStatus, sessionRef })`
|
|
173
|
-
|
|
174
|
-
One-time wiring of socket lifecycle events to a status setter. Called at app boot from `SocketStatusProvider`. Not consumed by app code directly.
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
initSyncRequest({
|
|
178
|
-
setSocketStatus, // Dispatch from useState<{ self, [userId]: statusContent }>
|
|
179
|
-
sessionRef, // RefObject<SessionLayout | null>
|
|
180
|
-
});
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
Wires the following Socket.io events:
|
|
184
|
-
|
|
185
|
-
| Socket event | Action |
|
|
186
|
-
|---|---|
|
|
187
|
-
| `connect` | `setSocketStatus(prev => ({ ...prev, self: { ...self, status: 'CONNECTED' } }))` |
|
|
188
|
-
| `disconnect` | `setSocketStatus(prev => ({ ...prev, self: { ...self, status: 'DISCONNECTED' } }))` |
|
|
189
|
-
| `reconnectAttempt(attempt)` | Status `'RECONNECTING'` with `reconnectAttempt` set to the attempt number. |
|
|
190
|
-
| `userAfk({ userId, endTime })` | If `userId === session.id`, mark `self` disconnected with `endTime`. Otherwise mark the user as disconnected in the per-user map. |
|
|
191
|
-
| `userBack({ userId })` | Mark the user as `'CONNECTED'`, clear `endTime`. |
|
|
192
|
-
| `connect_error(err)` | Mark `self` disconnected, log + notify in dev mode. |
|
|
193
|
-
|
|
194
|
-
Idempotent: if `initSyncRequest` is called again later, the previous handlers are removed before new ones are attached (the module holds `activeLifecycleHandlers` for this). Safe to call across hot-reloads.
|
|
195
|
-
|
|
196
|
-
`sessionRef` is a `RefObject` (not a value) because the handlers must always read the **current** session at event-firing time, not the session at handler-construction time. Without the ref, a logout followed by a `userAfk` event would still treat the AFK as if it belonged to the old user.
|
|
197
|
-
|
|
198
|
-
---
|
|
199
|
-
|
|
200
|
-
## 6. Why callbacks must never cast `serverOutput` / `clientOutput`
|
|
201
|
-
|
|
202
|
-
Rule 21 in the repository-root `CLAUDE.md`: generated types are mandatory; no `unknown` / `any` casts on typed transports.
|
|
203
|
-
|
|
204
|
-
Both `upsertSyncEventCallback` and `upsertSyncEventStreamCallback` give you fully typed payloads via the generated `SyncTypeMap`. The framework's discriminated unions handle the status branching cleanly:
|
|
205
|
-
|
|
206
|
-
```ts
|
|
207
|
-
// GOOD — uses the generated discriminated union
|
|
208
|
-
callback: ({ serverOutput }) => {
|
|
209
|
-
if (serverOutput.status !== 'success') return;
|
|
210
|
-
console.log(serverOutput.cardId); // typed
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// BAD — defeats inference, breaks contract evolution
|
|
214
|
-
callback: ({ serverOutput }: any) => {
|
|
215
|
-
console.log((serverOutput as { cardId: string }).cardId);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// BAD — same problem, different syntax
|
|
219
|
-
const data = serverOutput as unknown as { cardId: string };
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
When inference fails:
|
|
223
|
-
|
|
224
|
-
1. Regenerate the type map: `npm run ai:index`.
|
|
225
|
-
2. Verify the `_server_v{N}.ts` return type is well-typed (no `any` in the return path).
|
|
226
|
-
3. If you still hit a wall, fix the typing source — don't paper over with casts at the call site.
|
|
227
|
-
|
|
228
|
-
---
|
|
229
|
-
|
|
230
|
-
## 7. Subscription cleanup checklist
|
|
231
|
-
|
|
232
|
-
- `useSyncEvents()` auto-cleans on component unmount (via `useEffect` cleanup). For most cases, just call the registrars in a `useEffect` and return their unsubscribers. That's belt-and-suspenders but cheap.
|
|
233
|
-
- Calling `upsertSyncEventCallback` outside React (e.g. from a `_provider` module-level subscribe) means you own the cleanup. Save the returned function and call it on teardown.
|
|
234
|
-
- Re-registering with the same name+version from the same component (same `useSyncEvents()` instance) replaces the previous callback — no leak.
|
|
235
|
-
- Re-registering from a **different** component adds an independent entry — both fire.
|
|
236
|
-
|
|
237
|
-
---
|
|
238
|
-
|
|
239
|
-
## 8. Quick reference
|
|
240
|
-
|
|
241
|
-
| API | Purpose | Scope |
|
|
242
|
-
|---|---|---|
|
|
243
|
-
| `useSyncEvents()` | React hook returning `{ upsertSyncEventCallback, upsertSyncEventStreamCallback }` | Component lifetime — auto-clean on unmount |
|
|
244
|
-
| `upsertSyncEventCallback({ name, version, callback })` | Subscribe to `{ serverOutput, clientOutput }` frames | Returns unsubscribe |
|
|
245
|
-
| `upsertSyncEventStreamCallback({ name, version, callback })` | Subscribe to stream chunks (`broadcastStream`, `streamTo`, `_client.stream`) | Returns unsubscribe |
|
|
246
|
-
| `useSyncEventTrigger()` | React hook returning `{ triggerSyncEvent, triggerSyncStreamEvent }` for local fire | Component-stable identity |
|
|
247
|
-
| `initSyncRequest({ setSocketStatus, sessionRef })` | One-time wire-up of socket lifecycle to status provider | App boot |
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
## 9. Related
|
|
252
|
-
|
|
253
|
-
- Originator API: [`./sync-request.md`](./sync-request.md)
|
|
254
|
-
- Stream payload shapes: [`./streaming.md`](./streaming.md)
|
|
255
|
-
- Server / client handler contracts (what produces the payloads): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
|
|
256
|
-
- Type-generation contract (rule 21): repository-root [`CLAUDE.md`](../../../CLAUDE.md)
|
|
257
|
-
- `SocketStatusProvider` wiring: `src/_providers/SocketStatusProvider.tsx` (installer side)
|
|
5
|
+
Types for server and recipient output come from generated maps; do not cast payloads around a broken generator. Regenerate artifacts, then correct source definitions if inference still fails. Stream subscriptions exist only for routes that actually stream; keep progress delivery separate from the final acknowledgement. See `./streaming.md`, `../../core/AGENTS.md`.
|