@wibly/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/package.json +34 -0
- package/src/client.ts +650 -0
- package/src/consent.ts +78 -0
- package/src/control.ts +44 -0
- package/src/errors.ts +88 -0
- package/src/events.ts +129 -0
- package/src/index.ts +121 -0
- package/src/inference.ts +140 -0
- package/src/lifecycle.ts +72 -0
- package/src/predictive.ts +51 -0
- package/src/react.ts +223 -0
- package/src/store.ts +318 -0
- package/src/submit.ts +92 -0
- package/src/time.ts +58 -0
- package/src/transport.ts +401 -0
- package/src/voice.ts +67 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predictive (optimistic) rendering helpers.
|
|
3
|
+
*
|
|
4
|
+
* Per chunk B7 build: "predictive.ts — optimistic update helpers for
|
|
5
|
+
* fast UI feedback. Apply a local projection, then reconcile against
|
|
6
|
+
* the next state_diff. Roll back if the server rejected the input."
|
|
7
|
+
*
|
|
8
|
+
* The flow is:
|
|
9
|
+
*
|
|
10
|
+
* 1. The caller invokes `session.submitWithPrediction(input,
|
|
11
|
+
* projection)`. The SDK stamps a fresh `msg_…` id, registers
|
|
12
|
+
* `projection` against that id in the store, and sends the
|
|
13
|
+
* `submit` envelope.
|
|
14
|
+
*
|
|
15
|
+
* 2. The server emits a matching `state_diff` (the side effect of
|
|
16
|
+
* the submit). The SDK removes the projection on receipt — the
|
|
17
|
+
* authoritative state now reflects the change.
|
|
18
|
+
*
|
|
19
|
+
* 3. If the server emits an `error` with `causeMessageId === id`,
|
|
20
|
+
* the SDK rolls back the projection (drops it without applying)
|
|
21
|
+
* and surfaces the error to the caller as `submit_rejected`.
|
|
22
|
+
*
|
|
23
|
+
* The chunk-B7 trap is "Don't try to invent predictive logic outside
|
|
24
|
+
* of `predictive.ts`." Anything that needs to know about pending
|
|
25
|
+
* projections (the React view layer, the testkit's envelope assertion)
|
|
26
|
+
* imports from this module.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { OptimisticProjection, SessionStore } from './store.js';
|
|
30
|
+
|
|
31
|
+
export type PendingProjectionHandle = {
|
|
32
|
+
/** Envelope id that the projection is tied to. */
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** Forced rollback (drop the projection without applying it). */
|
|
35
|
+
readonly rollback: () => void;
|
|
36
|
+
/** Forced commit (drop the projection — server confirmed). */
|
|
37
|
+
readonly commit: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const registerProjection = (
|
|
41
|
+
store: SessionStore,
|
|
42
|
+
id: string,
|
|
43
|
+
projection: OptimisticProjection,
|
|
44
|
+
): PendingProjectionHandle => {
|
|
45
|
+
store.addProjection(id, projection);
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
rollback: () => store.removeProjection(id),
|
|
49
|
+
commit: () => store.removeProjection(id),
|
|
50
|
+
};
|
|
51
|
+
};
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for the Studio SDK.
|
|
3
|
+
*
|
|
4
|
+
* Per chunk B7 build: "state.ts — reactive state subscription.
|
|
5
|
+
* Returns React hooks that read from a synchronised store: …
|
|
6
|
+
* `useSessionState()`, `useSessionEvent(eventType)`,
|
|
7
|
+
* `useConnectionState()`."
|
|
8
|
+
*
|
|
9
|
+
* The hooks are surface-thin — they use `useSyncExternalStore`
|
|
10
|
+
* against the session's store / event bus and otherwise stay out of
|
|
11
|
+
* the way. The chunk-B7 trap is "Don't try to invent predictive
|
|
12
|
+
* logic outside of `predictive.ts`. Don't let the AI inline state
|
|
13
|
+
* stores per call site." The hooks read from the session-owned
|
|
14
|
+
* store; they never construct their own.
|
|
15
|
+
*
|
|
16
|
+
* The `react` peer-dependency is **optional** (see `package.json`'s
|
|
17
|
+
* `peerDependenciesMeta`). Consumers using the SDK from a non-React
|
|
18
|
+
* shell (a node script, a CLI) can import everything from the SDK
|
|
19
|
+
* root without pulling React into the bundle.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createContext,
|
|
24
|
+
createElement,
|
|
25
|
+
useCallback,
|
|
26
|
+
useContext,
|
|
27
|
+
useEffect,
|
|
28
|
+
useMemo,
|
|
29
|
+
useState,
|
|
30
|
+
useSyncExternalStore,
|
|
31
|
+
type ReactNode,
|
|
32
|
+
} from 'react';
|
|
33
|
+
|
|
34
|
+
import type { EventPayload, LifecyclePayload } from '@wibly/internal-protocol';
|
|
35
|
+
|
|
36
|
+
import type { ConsentDecision, ConsentRequiredPayload } from './consent.js';
|
|
37
|
+
import type { Session, SessionState } from './client.js';
|
|
38
|
+
|
|
39
|
+
const SessionContext = createContext<Session | null>(null);
|
|
40
|
+
|
|
41
|
+
export type SessionProviderProps = {
|
|
42
|
+
readonly session: Session;
|
|
43
|
+
readonly children: ReactNode;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Provider that exposes a `Session` to the React tree. The host
|
|
48
|
+
* component creates the session via `createSession(...)` and wraps
|
|
49
|
+
* its tree once — every hook below reads from this provider.
|
|
50
|
+
*/
|
|
51
|
+
export const SessionProvider = (props: SessionProviderProps): ReactNode =>
|
|
52
|
+
createElement(
|
|
53
|
+
SessionContext.Provider,
|
|
54
|
+
{ value: props.session },
|
|
55
|
+
props.children,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Access the live `Session`. Throws if no provider is mounted (a
|
|
60
|
+
* deliberate fail-fast — the alternative is a silent null that
|
|
61
|
+
* makes every downstream hook misbehave).
|
|
62
|
+
*/
|
|
63
|
+
export const useSession = (): Session => {
|
|
64
|
+
const session = useContext(SessionContext);
|
|
65
|
+
if (session === null) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'useSession: no <SessionProvider> in the React tree. ' +
|
|
68
|
+
'Wrap the tree that uses SDK hooks in <SessionProvider session={...}>.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return session;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Subscribe to the session's reactive store. Returns the current
|
|
76
|
+
* `SessionState` snapshot (re-renders on each store change).
|
|
77
|
+
*/
|
|
78
|
+
export const useSessionState = (): SessionState => {
|
|
79
|
+
const session = useSession();
|
|
80
|
+
return useSyncExternalStore(
|
|
81
|
+
session.subscribe,
|
|
82
|
+
session.getState,
|
|
83
|
+
session.getState,
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Convenience hook: just the connection state. */
|
|
88
|
+
export const useConnectionState = (): SessionState['connectionState'] => {
|
|
89
|
+
const state = useSessionState();
|
|
90
|
+
return state.connectionState;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Subscribe to a server-emitted event by `eventType`. The handler
|
|
95
|
+
* fires on every matching `event` frame. Unsubscribes on unmount.
|
|
96
|
+
*
|
|
97
|
+
* Per the chunk-B7 trap "no `useEffect` for data fetching" — this
|
|
98
|
+
* hook only registers / disposes a listener; it never fetches.
|
|
99
|
+
*/
|
|
100
|
+
export const useSessionEvent = <P = unknown>(
|
|
101
|
+
eventType: string,
|
|
102
|
+
handler: (payload: EventPayload & { data: P }) => void,
|
|
103
|
+
): void => {
|
|
104
|
+
const session = useSession();
|
|
105
|
+
const ref = useEventCallback(handler);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
return session.events.onEvent<P>(eventType, ref);
|
|
108
|
+
}, [session, eventType, ref]);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Subscribe to a lifecycle transition by name. Same shape as
|
|
113
|
+
* `useSessionEvent` but reading the `lifecycle` channel.
|
|
114
|
+
*/
|
|
115
|
+
export const useSessionLifecycle = (
|
|
116
|
+
transition: string,
|
|
117
|
+
handler: (payload: LifecyclePayload) => void,
|
|
118
|
+
): void => {
|
|
119
|
+
const session = useSession();
|
|
120
|
+
const ref = useEventCallback(handler);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (transition === 'session.opened') {
|
|
123
|
+
return session.lifecycle.onSessionOpened(ref);
|
|
124
|
+
}
|
|
125
|
+
if (transition === 'session.closed') {
|
|
126
|
+
return session.lifecycle.onSessionClosed(ref);
|
|
127
|
+
}
|
|
128
|
+
if (transition.startsWith('phase.entered')) {
|
|
129
|
+
return session.lifecycle.onPhaseEntered(ref);
|
|
130
|
+
}
|
|
131
|
+
if (transition.startsWith('phase.exited')) {
|
|
132
|
+
return session.lifecycle.onPhaseExited(ref);
|
|
133
|
+
}
|
|
134
|
+
if (transition === 'host.reclaimed') {
|
|
135
|
+
return session.lifecycle.onHostReclaimed(ref);
|
|
136
|
+
}
|
|
137
|
+
// Fall back to the raw bus for unknown transitions.
|
|
138
|
+
return session.events.onAnyEvent(() => {
|
|
139
|
+
// Lifecycle frames go through `lifecycle.*`, not `events`.
|
|
140
|
+
// For unknown transitions, the consumer should use
|
|
141
|
+
// `session.lifecycle` directly.
|
|
142
|
+
});
|
|
143
|
+
}, [session, transition, ref]);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Subscribe to consent-required events as a React hook. Returns a
|
|
148
|
+
* tuple `[payload, decide]`. While `payload` is non-null, a prompt
|
|
149
|
+
* is pending; call `decide('accepted' | 'declined')` to resolve it.
|
|
150
|
+
*
|
|
151
|
+
* Equivalent to wiring `onConsentRequired` on `createSession`, but
|
|
152
|
+
* scoped to a component lifetime. Use this in the Player shell's
|
|
153
|
+
* consent bridge.
|
|
154
|
+
*/
|
|
155
|
+
export const useConsentPrompt = (): [
|
|
156
|
+
ConsentRequiredPayload | null,
|
|
157
|
+
(decision: ConsentDecision) => void,
|
|
158
|
+
] => {
|
|
159
|
+
const session = useSession();
|
|
160
|
+
const [state, setState] = useState<{
|
|
161
|
+
payload: ConsentRequiredPayload | null;
|
|
162
|
+
resolve: ((d: ConsentDecision) => void) | null;
|
|
163
|
+
}>({
|
|
164
|
+
payload: null,
|
|
165
|
+
resolve: null,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
useSessionEvent<ConsentRequiredPayload>('consent_required', (event) => {
|
|
169
|
+
setState({
|
|
170
|
+
payload: event.data,
|
|
171
|
+
resolve: (decision) => {
|
|
172
|
+
setState({ payload: null, resolve: null });
|
|
173
|
+
if (decision === 'accepted') {
|
|
174
|
+
void session.requestConsent(event.data).then((result) => {
|
|
175
|
+
session.emit({
|
|
176
|
+
eventType: 'consent.decision',
|
|
177
|
+
data: {
|
|
178
|
+
personaId: event.data.personaId,
|
|
179
|
+
scope: event.data.scope,
|
|
180
|
+
decision,
|
|
181
|
+
persistOk: result.ok,
|
|
182
|
+
persistError: result.ok ? null : result.error.kind,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
session.emit({
|
|
188
|
+
eventType: 'consent.decision',
|
|
189
|
+
data: {
|
|
190
|
+
personaId: event.data.personaId,
|
|
191
|
+
scope: event.data.scope,
|
|
192
|
+
decision,
|
|
193
|
+
persistOk: false,
|
|
194
|
+
persistError: null,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const decide = useCallback(
|
|
203
|
+
(decision: ConsentDecision) => {
|
|
204
|
+
state.resolve?.(decision);
|
|
205
|
+
},
|
|
206
|
+
[state],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return [state.payload, decide];
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Stable callback ref — the returned function never changes identity
|
|
214
|
+
* even though it always calls the latest passed-in `handler`. Used
|
|
215
|
+
* by event hooks so the effect doesn't re-subscribe on every render.
|
|
216
|
+
*/
|
|
217
|
+
const useEventCallback = <Args extends readonly unknown[], R>(
|
|
218
|
+
handler: (...args: Args) => R,
|
|
219
|
+
): ((...args: Args) => R) => {
|
|
220
|
+
const ref = useMemo(() => ({ current: handler }), []);
|
|
221
|
+
ref.current = handler;
|
|
222
|
+
return useCallback((...args: Args) => ref.current(...args), [ref]);
|
|
223
|
+
};
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive session store (per chunk B7 build: "state.ts — reactive
|
|
3
|
+
* state subscription. Returns React hooks that read from a
|
|
4
|
+
* synchronised store").
|
|
5
|
+
*
|
|
6
|
+
* The store holds the four pieces of session truth a client cares
|
|
7
|
+
* about: the connection state, the authoritative server projection
|
|
8
|
+
* (the `state` from `snapshot` plus any applied `state_diff` patches),
|
|
9
|
+
* the optimistic projections layered on top (predictive rendering),
|
|
10
|
+
* and the current `appliedSeq`. Consumers subscribe via `subscribe`
|
|
11
|
+
* (callback-style) or via the React hooks in `react.ts` (which use
|
|
12
|
+
* `useSyncExternalStore`).
|
|
13
|
+
*
|
|
14
|
+
* **One store per session.** Per the chunk-B7 trap "Do not let the AI
|
|
15
|
+
* inline state stores per call site. One store per session." The
|
|
16
|
+
* `createSession` factory owns exactly one store and never exposes
|
|
17
|
+
* the constructor; `useSessionState` and friends read from the
|
|
18
|
+
* session-attached store.
|
|
19
|
+
*
|
|
20
|
+
* **State shape.** The protocol is opaque about the projection's
|
|
21
|
+
* shape (`SnapshotPayload.state: unknown`). The store carries the
|
|
22
|
+
* raw value plus a thin per-recipient narrowing helper
|
|
23
|
+
* (`selectSlice`) so a React hook can pull `session`, `host`,
|
|
24
|
+
* `player.public`, `player.private`, or `team` without re-projecting
|
|
25
|
+
* the whole tree on every render. The per-Experience type narrowing
|
|
26
|
+
* lives at the consumer call-site (the SDK is type-agnostic).
|
|
27
|
+
*
|
|
28
|
+
* The store is implementation-detail to the SDK; nothing outside
|
|
29
|
+
* `client.ts` / `react.ts` / `transport.ts` constructs one directly.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { JsonPatchOp } from '@wibly/internal-protocol';
|
|
33
|
+
|
|
34
|
+
export type ConnectionState =
|
|
35
|
+
| 'idle'
|
|
36
|
+
| 'connecting'
|
|
37
|
+
| 'open'
|
|
38
|
+
| 'reconnecting'
|
|
39
|
+
| 'closed';
|
|
40
|
+
|
|
41
|
+
export type SessionStoreSnapshot = {
|
|
42
|
+
readonly connectionState: ConnectionState;
|
|
43
|
+
readonly state: unknown;
|
|
44
|
+
readonly phaseId: string | null;
|
|
45
|
+
readonly appliedSeq: number;
|
|
46
|
+
/**
|
|
47
|
+
* Identifiers of the optimistic projections currently applied on
|
|
48
|
+
* top of `state`. The actual projection functions are stored
|
|
49
|
+
* separately so the snapshot stays serialisable.
|
|
50
|
+
*/
|
|
51
|
+
readonly pendingProjectionIds: readonly string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type OptimisticProjection<T = unknown> = (state: T) => T;
|
|
55
|
+
|
|
56
|
+
export type SessionStore = {
|
|
57
|
+
/** Subscribe to store changes. Returns an unsubscribe function. */
|
|
58
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
59
|
+
/** Current store snapshot. Returns the same reference between mutations. */
|
|
60
|
+
readonly getSnapshot: () => SessionStoreSnapshot;
|
|
61
|
+
/** Internal mutators — not exposed past `createSession`. */
|
|
62
|
+
readonly setConnectionState: (next: ConnectionState) => void;
|
|
63
|
+
readonly applySnapshot: (state: unknown, phaseId: string, baseSeq: number) => void;
|
|
64
|
+
readonly applyDiff: (
|
|
65
|
+
patches: readonly JsonPatchOp[],
|
|
66
|
+
fromSeq: number,
|
|
67
|
+
toSeq: number,
|
|
68
|
+
) => 'applied' | 'resync_required';
|
|
69
|
+
readonly addProjection: (id: string, fn: OptimisticProjection) => void;
|
|
70
|
+
readonly removeProjection: (id: string) => void;
|
|
71
|
+
readonly hasProjection: (id: string) => boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Render the projected view: `state` with every active projection
|
|
74
|
+
* applied in registration order. Cheap because projections are
|
|
75
|
+
* typically a small handful per session.
|
|
76
|
+
*/
|
|
77
|
+
readonly getProjectedState: () => unknown;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const createSessionStore = (): SessionStore => {
|
|
81
|
+
const listeners = new Set<() => void>();
|
|
82
|
+
const projections = new Map<string, OptimisticProjection>();
|
|
83
|
+
let snapshot: SessionStoreSnapshot = {
|
|
84
|
+
connectionState: 'idle',
|
|
85
|
+
state: null,
|
|
86
|
+
phaseId: null,
|
|
87
|
+
appliedSeq: 0,
|
|
88
|
+
pendingProjectionIds: [],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const emit = (): void => {
|
|
92
|
+
for (const l of listeners) {
|
|
93
|
+
try {
|
|
94
|
+
l();
|
|
95
|
+
} catch {
|
|
96
|
+
// listeners must not throw; swallow and continue.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const refreshSnapshot = (partial: Partial<SessionStoreSnapshot>): void => {
|
|
102
|
+
snapshot = {
|
|
103
|
+
...snapshot,
|
|
104
|
+
...partial,
|
|
105
|
+
pendingProjectionIds:
|
|
106
|
+
partial.pendingProjectionIds ?? snapshot.pendingProjectionIds,
|
|
107
|
+
};
|
|
108
|
+
emit();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
subscribe: (listener) => {
|
|
113
|
+
listeners.add(listener);
|
|
114
|
+
return () => {
|
|
115
|
+
listeners.delete(listener);
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
getSnapshot: () => snapshot,
|
|
119
|
+
setConnectionState: (next) => {
|
|
120
|
+
if (next === snapshot.connectionState) return;
|
|
121
|
+
refreshSnapshot({ connectionState: next });
|
|
122
|
+
},
|
|
123
|
+
applySnapshot: (state, phaseId, baseSeq) => {
|
|
124
|
+
refreshSnapshot({ state, phaseId, appliedSeq: baseSeq });
|
|
125
|
+
},
|
|
126
|
+
applyDiff: (patches, fromSeq, toSeq) => {
|
|
127
|
+
if (fromSeq !== snapshot.appliedSeq) {
|
|
128
|
+
return 'resync_required';
|
|
129
|
+
}
|
|
130
|
+
const next = applyPatches(snapshot.state, patches);
|
|
131
|
+
refreshSnapshot({ state: next, appliedSeq: toSeq });
|
|
132
|
+
return 'applied';
|
|
133
|
+
},
|
|
134
|
+
addProjection: (id, fn) => {
|
|
135
|
+
projections.set(id, fn);
|
|
136
|
+
refreshSnapshot({
|
|
137
|
+
pendingProjectionIds: Array.from(projections.keys()),
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
removeProjection: (id) => {
|
|
141
|
+
if (!projections.has(id)) return;
|
|
142
|
+
projections.delete(id);
|
|
143
|
+
refreshSnapshot({
|
|
144
|
+
pendingProjectionIds: Array.from(projections.keys()),
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
hasProjection: (id) => projections.has(id),
|
|
148
|
+
getProjectedState: () => {
|
|
149
|
+
let out = snapshot.state;
|
|
150
|
+
for (const fn of projections.values()) {
|
|
151
|
+
try {
|
|
152
|
+
out = fn(out);
|
|
153
|
+
} catch {
|
|
154
|
+
// projection that throws is dropped silently — its
|
|
155
|
+
// confirming submit failed, but a tasteless caller can
|
|
156
|
+
// still inspect server state.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// JSON Patch application
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Apply a sequence of JSON-Patch operations to a JSON-shaped value.
|
|
170
|
+
*
|
|
171
|
+
* Supports the five operation kinds declared in `@platform/protocol`
|
|
172
|
+
* (`add` / `remove` / `replace` / `move` / `copy`). Paths use the
|
|
173
|
+
* RFC-6901 escaping (`/` becomes `~1`, `~` becomes `~0`); the empty
|
|
174
|
+
* path `''` targets the root document.
|
|
175
|
+
*
|
|
176
|
+
* Returns a NEW object tree — never mutates the input. The implementation
|
|
177
|
+
* is deliberately small (~100 lines) so the SDK doesn't pull in an
|
|
178
|
+
* external `fast-json-patch` dependency.
|
|
179
|
+
*/
|
|
180
|
+
export const applyPatches = (
|
|
181
|
+
root: unknown,
|
|
182
|
+
patches: readonly JsonPatchOp[],
|
|
183
|
+
): unknown => {
|
|
184
|
+
let next = root;
|
|
185
|
+
for (const op of patches) {
|
|
186
|
+
next = applyOne(next, op);
|
|
187
|
+
}
|
|
188
|
+
return next;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const applyOne = (root: unknown, op: JsonPatchOp): unknown => {
|
|
192
|
+
switch (op.op) {
|
|
193
|
+
case 'add':
|
|
194
|
+
return setAt(root, parsePath(op.path), op.value, 'add');
|
|
195
|
+
case 'remove':
|
|
196
|
+
return removeAt(root, parsePath(op.path));
|
|
197
|
+
case 'replace':
|
|
198
|
+
return setAt(root, parsePath(op.path), op.value, 'replace');
|
|
199
|
+
case 'copy': {
|
|
200
|
+
const src = getAt(root, parsePath(op.from));
|
|
201
|
+
return setAt(root, parsePath(op.path), structuredCloneShallow(src), 'add');
|
|
202
|
+
}
|
|
203
|
+
case 'move': {
|
|
204
|
+
const src = getAt(root, parsePath(op.from));
|
|
205
|
+
const removed = removeAt(root, parsePath(op.from));
|
|
206
|
+
return setAt(removed, parsePath(op.path), src, 'add');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const parsePath = (path: string): readonly string[] => {
|
|
212
|
+
if (path === '') return [];
|
|
213
|
+
if (!path.startsWith('/')) {
|
|
214
|
+
throw new Error(`json-patch: path must start with '/' (got '${path}')`);
|
|
215
|
+
}
|
|
216
|
+
return path
|
|
217
|
+
.slice(1)
|
|
218
|
+
.split('/')
|
|
219
|
+
.map((seg) => seg.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const getAt = (root: unknown, path: readonly string[]): unknown => {
|
|
223
|
+
let cur: unknown = root;
|
|
224
|
+
for (const seg of path) {
|
|
225
|
+
cur = step(cur, seg);
|
|
226
|
+
}
|
|
227
|
+
return cur;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const step = (cur: unknown, seg: string): unknown => {
|
|
231
|
+
if (Array.isArray(cur)) {
|
|
232
|
+
const idx = Number(seg);
|
|
233
|
+
return Number.isInteger(idx) ? cur[idx] : undefined;
|
|
234
|
+
}
|
|
235
|
+
if (cur && typeof cur === 'object') {
|
|
236
|
+
return (cur as Record<string, unknown>)[seg];
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const removeAt = (root: unknown, path: readonly string[]): unknown => {
|
|
242
|
+
if (path.length === 0) return null;
|
|
243
|
+
return reduceWithCopy(root, path, (parent, last) => {
|
|
244
|
+
if (Array.isArray(parent)) {
|
|
245
|
+
const idx = Number(last);
|
|
246
|
+
const copy = parent.slice();
|
|
247
|
+
copy.splice(idx, 1);
|
|
248
|
+
return copy;
|
|
249
|
+
}
|
|
250
|
+
if (parent && typeof parent === 'object') {
|
|
251
|
+
const { [last]: _drop, ...rest } = parent as Record<string, unknown>;
|
|
252
|
+
void _drop;
|
|
253
|
+
return rest;
|
|
254
|
+
}
|
|
255
|
+
return parent;
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const setAt = (
|
|
260
|
+
root: unknown,
|
|
261
|
+
path: readonly string[],
|
|
262
|
+
value: unknown,
|
|
263
|
+
mode: 'add' | 'replace',
|
|
264
|
+
): unknown => {
|
|
265
|
+
if (path.length === 0) return value;
|
|
266
|
+
return reduceWithCopy(root, path, (parent, last) => {
|
|
267
|
+
if (Array.isArray(parent)) {
|
|
268
|
+
const copy = parent.slice();
|
|
269
|
+
if (last === '-') {
|
|
270
|
+
copy.push(value);
|
|
271
|
+
} else {
|
|
272
|
+
const idx = Number(last);
|
|
273
|
+
if (mode === 'add') copy.splice(idx, 0, value);
|
|
274
|
+
else copy[idx] = value;
|
|
275
|
+
}
|
|
276
|
+
return copy;
|
|
277
|
+
}
|
|
278
|
+
if (parent && typeof parent === 'object') {
|
|
279
|
+
return { ...(parent as Record<string, unknown>), [last]: value };
|
|
280
|
+
}
|
|
281
|
+
// Setting under a primitive parent — create an object with the key.
|
|
282
|
+
return { [last]: value };
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const reduceWithCopy = (
|
|
287
|
+
root: unknown,
|
|
288
|
+
path: readonly string[],
|
|
289
|
+
mutate: (parent: unknown, last: string) => unknown,
|
|
290
|
+
): unknown => {
|
|
291
|
+
if (path.length === 0) {
|
|
292
|
+
throw new Error('json-patch: cannot mutate root with empty path');
|
|
293
|
+
}
|
|
294
|
+
const first = path[0];
|
|
295
|
+
if (first === undefined) {
|
|
296
|
+
throw new Error('json-patch: empty path segment');
|
|
297
|
+
}
|
|
298
|
+
const rest = path.slice(1);
|
|
299
|
+
if (rest.length === 0) {
|
|
300
|
+
return mutate(root, first);
|
|
301
|
+
}
|
|
302
|
+
const child = step(root, first);
|
|
303
|
+
const nextChild = reduceWithCopy(child, rest, mutate);
|
|
304
|
+
if (Array.isArray(root)) {
|
|
305
|
+
const copy = root.slice();
|
|
306
|
+
copy[Number(first)] = nextChild;
|
|
307
|
+
return copy;
|
|
308
|
+
}
|
|
309
|
+
if (root && typeof root === 'object') {
|
|
310
|
+
return { ...(root as Record<string, unknown>), [first]: nextChild };
|
|
311
|
+
}
|
|
312
|
+
return { [first]: nextChild };
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const structuredCloneShallow = (v: unknown): unknown => {
|
|
316
|
+
if (v === null || typeof v !== 'object') return v;
|
|
317
|
+
return structuredClone(v);
|
|
318
|
+
};
|
package/src/submit.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submit / emit helpers (per chunk B7 build: "submit.ts — typed
|
|
3
|
+
* submit helpers (session.submit(input)) with idempotent retry on
|
|
4
|
+
* disconnect").
|
|
5
|
+
*
|
|
6
|
+
* `submit` is the workhorse for turn-based input collection. Each
|
|
7
|
+
* call:
|
|
8
|
+
*
|
|
9
|
+
* 1. Generates a stable `msg_…` id (the transport stamps it on the
|
|
10
|
+
* envelope).
|
|
11
|
+
* 2. Optionally layers a predictive projection on the local store.
|
|
12
|
+
* 3. Sends a `submit` frame over the transport.
|
|
13
|
+
* 4. Returns a `PendingSubmit` handle the caller awaits or watches.
|
|
14
|
+
* The handle resolves once the server confirms (with a matching
|
|
15
|
+
* `state_diff` or `error`) — see `client.ts` for the wiring.
|
|
16
|
+
*
|
|
17
|
+
* The Runtime dedupes by envelope `id` (chunk B0). The transport
|
|
18
|
+
* re-queues the same frame on reconnect; the server collapses
|
|
19
|
+
* duplicates server-side.
|
|
20
|
+
*
|
|
21
|
+
* For chunk B7, this module exports the *typed* surface and the
|
|
22
|
+
* `PendingSubmit` shape; `client.ts` wires it up against the live
|
|
23
|
+
* transport.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { SubmitPayload, EmitPayload } from '@wibly/internal-protocol';
|
|
27
|
+
import type { Result } from '@wibly/internal-shared';
|
|
28
|
+
|
|
29
|
+
import type { SdkError } from './errors.js';
|
|
30
|
+
import type { OptimisticProjection } from './store.js';
|
|
31
|
+
|
|
32
|
+
export type SubmitArgs = {
|
|
33
|
+
readonly phaseId: string;
|
|
34
|
+
readonly inputType: string;
|
|
35
|
+
readonly data: unknown;
|
|
36
|
+
/**
|
|
37
|
+
* Optional predictive projection. The SDK applies the projection
|
|
38
|
+
* to the local store immediately (so the UI updates without
|
|
39
|
+
* waiting for the server's `state_diff`) and removes it on
|
|
40
|
+
* confirmation / rollback.
|
|
41
|
+
*/
|
|
42
|
+
readonly predictive?: OptimisticProjection;
|
|
43
|
+
/**
|
|
44
|
+
* How long to wait for the server's confirming `state_diff` /
|
|
45
|
+
* `error` before resolving with `Err({ kind: 'timeout' })`.
|
|
46
|
+
* Default 30s. A submit that times out is NOT rolled back — the
|
|
47
|
+
* server may still emit a confirming frame, in which case the
|
|
48
|
+
* store reconciles. Use `cancel()` to drop a pending submit
|
|
49
|
+
* explicitly.
|
|
50
|
+
*/
|
|
51
|
+
readonly timeoutMs?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type EmitArgs = {
|
|
55
|
+
readonly eventType: string;
|
|
56
|
+
readonly data: unknown;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type PendingSubmit = {
|
|
60
|
+
/** The envelope id the SDK generated for this submit. */
|
|
61
|
+
readonly id: string;
|
|
62
|
+
/** Resolves when the server confirms or the timeout fires. */
|
|
63
|
+
readonly promise: Promise<Result<void, SdkError>>;
|
|
64
|
+
/**
|
|
65
|
+
* Drop the predictive projection (if any) and stop waiting for
|
|
66
|
+
* confirmation. Does NOT cancel the server-side processing — the
|
|
67
|
+
* server may still emit a `state_diff` later, which the store
|
|
68
|
+
* reconciles normally.
|
|
69
|
+
*/
|
|
70
|
+
readonly cancel: () => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const buildSubmitPayload = (
|
|
74
|
+
sessionId: string,
|
|
75
|
+
args: SubmitArgs,
|
|
76
|
+
): SubmitPayload =>
|
|
77
|
+
({
|
|
78
|
+
sessionId,
|
|
79
|
+
phaseId: args.phaseId,
|
|
80
|
+
inputType: args.inputType,
|
|
81
|
+
data: args.data,
|
|
82
|
+
}) as SubmitPayload;
|
|
83
|
+
|
|
84
|
+
export const buildEmitPayload = (
|
|
85
|
+
sessionId: string,
|
|
86
|
+
args: EmitArgs,
|
|
87
|
+
): EmitPayload =>
|
|
88
|
+
({
|
|
89
|
+
sessionId,
|
|
90
|
+
eventType: args.eventType,
|
|
91
|
+
data: args.data,
|
|
92
|
+
}) as EmitPayload;
|