@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Studio SDK's `createSession` factory.
|
|
3
|
+
*
|
|
4
|
+
* Per chunk B7 build: "client.ts — `createSession({ wsUrl,
|
|
5
|
+
* sessionId, auth, onConsentRequired })` returns a `Session` with
|
|
6
|
+
* the typed API used by the Player shell (B6) and any future Studio
|
|
7
|
+
* SDK consumer."
|
|
8
|
+
*
|
|
9
|
+
* The factory composes the dependencies — transport, store,
|
|
10
|
+
* event bus, lifecycle bindings — into a single `Session` value and
|
|
11
|
+
* is the only place anything in the SDK constructs a transport or a
|
|
12
|
+
* store. Consumers never see the internals; they get a documented
|
|
13
|
+
* surface.
|
|
14
|
+
*
|
|
15
|
+
* **Single store per session.** The chunk-B7 trap is "do not let the
|
|
16
|
+
* AI inline state stores per call site". The factory enforces it:
|
|
17
|
+
* the returned `Session.store` is a getter that reads from the
|
|
18
|
+
* factory-owned store. React hooks (in `react.ts`) read from the
|
|
19
|
+
* same store via `useSyncExternalStore`.
|
|
20
|
+
*
|
|
21
|
+
* **Auth pass-through.** The `auth` field is the bearer token the
|
|
22
|
+
* Runtime expects on the WebSocket subprotocol (chunk B8a). It is
|
|
23
|
+
* threaded into the WebSocket URL's `Sec-WebSocket-Protocol` header
|
|
24
|
+
* the way `apps-shells/player-web` already does. The SDK never
|
|
25
|
+
* logs it.
|
|
26
|
+
*
|
|
27
|
+
* **Consent callback.** When the Runtime emits a `consent_required`
|
|
28
|
+
* event, the SDK calls the registered callback and (on
|
|
29
|
+
* `accepted`) calls `session.requestConsent(...)` to forward the
|
|
30
|
+
* grant to the User Portal endpoint (chunk B17a).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { ok, type Result } from '@wibly/internal-shared';
|
|
34
|
+
import type {
|
|
35
|
+
ErrorPayload,
|
|
36
|
+
EventPayload,
|
|
37
|
+
LifecyclePayload,
|
|
38
|
+
MessageId,
|
|
39
|
+
ServerMessage,
|
|
40
|
+
SessionId,
|
|
41
|
+
StateDiffPayload,
|
|
42
|
+
} from '@wibly/internal-protocol';
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
CONSENT_REQUIRED_EVENT_TYPE,
|
|
46
|
+
type ConsentDecision,
|
|
47
|
+
type ConsentRequiredCallback,
|
|
48
|
+
type ConsentRequiredPayload,
|
|
49
|
+
} from './consent.js';
|
|
50
|
+
import { createEventBus, type EventBus } from './events.js';
|
|
51
|
+
import {
|
|
52
|
+
createLifecycleBindings,
|
|
53
|
+
type LifecycleBindings,
|
|
54
|
+
} from './lifecycle.js';
|
|
55
|
+
import { createSessionStore, type SessionStore } from './store.js';
|
|
56
|
+
import {
|
|
57
|
+
buildEmitPayload,
|
|
58
|
+
buildSubmitPayload,
|
|
59
|
+
type EmitArgs,
|
|
60
|
+
type PendingSubmit,
|
|
61
|
+
type SubmitArgs,
|
|
62
|
+
} from './submit.js';
|
|
63
|
+
import { buildHostEmitPayload, HOST_EVENT_TYPES } from './control.js';
|
|
64
|
+
import {
|
|
65
|
+
buildInferenceRequest,
|
|
66
|
+
type InferenceCallInput,
|
|
67
|
+
type SessionInference,
|
|
68
|
+
INFERENCE_EVENT_PREFIX,
|
|
69
|
+
} from './inference.js';
|
|
70
|
+
import {
|
|
71
|
+
type SessionVoice,
|
|
72
|
+
type SpeakSuccess,
|
|
73
|
+
VOICE_EVENT_PREFIX,
|
|
74
|
+
} from './voice.js';
|
|
75
|
+
import {
|
|
76
|
+
createServerTimeSource,
|
|
77
|
+
type ServerTimeSource,
|
|
78
|
+
} from './time.js';
|
|
79
|
+
import {
|
|
80
|
+
createTransport,
|
|
81
|
+
type Transport,
|
|
82
|
+
type TransportConfig,
|
|
83
|
+
type WebSocketFactory,
|
|
84
|
+
} from './transport.js';
|
|
85
|
+
import { registerProjection } from './predictive.js';
|
|
86
|
+
import { formatSdkError, type SdkError } from './errors.js';
|
|
87
|
+
import type { ZodTypeAny } from 'zod';
|
|
88
|
+
import type { ConnectionState, OptimisticProjection } from './store.js';
|
|
89
|
+
import type {
|
|
90
|
+
InferenceCallSuccess,
|
|
91
|
+
} from './inference.js';
|
|
92
|
+
|
|
93
|
+
export type SessionConfig = {
|
|
94
|
+
readonly wsUrl: string;
|
|
95
|
+
readonly sessionId: SessionId;
|
|
96
|
+
readonly auth?: string;
|
|
97
|
+
readonly onConsentRequired?: ConsentRequiredCallback;
|
|
98
|
+
/** Test seam — pass a fake WebSocket constructor. */
|
|
99
|
+
readonly factory?: WebSocketFactory;
|
|
100
|
+
/** Test seam for transport timing. */
|
|
101
|
+
readonly schedule?: TransportConfig['schedule'];
|
|
102
|
+
/** Test seam for `Math.random` (used in backoff jitter). */
|
|
103
|
+
readonly random?: () => number;
|
|
104
|
+
/** Test seam for `Date.now`. */
|
|
105
|
+
readonly now?: () => number;
|
|
106
|
+
/**
|
|
107
|
+
* Submit confirmation timeout. Default 30s. See `submit.ts`.
|
|
108
|
+
*/
|
|
109
|
+
readonly submitTimeoutMs?: number;
|
|
110
|
+
/**
|
|
111
|
+
* Persistence endpoint for accepted consents. When the player
|
|
112
|
+
* accepts a consent prompt, the SDK invokes `persistConsent` to
|
|
113
|
+
* write the grant. Chunk B17a wires the real implementation
|
|
114
|
+
* against the User Portal; MVP leaves it `undefined` and
|
|
115
|
+
* `requestConsent` returns `consent_persistence_not_wired`.
|
|
116
|
+
*/
|
|
117
|
+
readonly persistConsent?: (
|
|
118
|
+
payload: ConsentRequiredPayload,
|
|
119
|
+
) => Promise<Result<void, SdkError>>;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type SessionState = {
|
|
123
|
+
readonly connectionState: ConnectionState;
|
|
124
|
+
/** Authoritative server projection. */
|
|
125
|
+
readonly state: unknown;
|
|
126
|
+
/** Projected state — server + active optimistic projections. */
|
|
127
|
+
readonly projectedState: unknown;
|
|
128
|
+
/** Last applied server seq. */
|
|
129
|
+
readonly appliedSeq: number;
|
|
130
|
+
/** Current phase id from the most recent snapshot. */
|
|
131
|
+
readonly phaseId: string | null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type Session = {
|
|
135
|
+
readonly sessionId: SessionId;
|
|
136
|
+
/** Current snapshot — calling getter triggers no work. */
|
|
137
|
+
readonly getState: () => SessionState;
|
|
138
|
+
/** Reactive subscription. The callback fires on store changes. */
|
|
139
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
140
|
+
/** Submit an Active input. */
|
|
141
|
+
readonly submit: (args: SubmitArgs) => PendingSubmit;
|
|
142
|
+
/** Emit a non-Active wire event. */
|
|
143
|
+
readonly emit: (args: EmitArgs) => Result<{ readonly id: string }, SdkError>;
|
|
144
|
+
/** Host-only control verbs. The Runtime gates host privilege. */
|
|
145
|
+
readonly host: {
|
|
146
|
+
readonly pause: () => Result<{ readonly id: string }, SdkError>;
|
|
147
|
+
readonly resume: () => Result<{ readonly id: string }, SdkError>;
|
|
148
|
+
readonly advancePhase: (
|
|
149
|
+
detail?: unknown,
|
|
150
|
+
) => Result<{ readonly id: string }, SdkError>;
|
|
151
|
+
readonly reclaim: () => Result<{ readonly id: string }, SdkError>;
|
|
152
|
+
};
|
|
153
|
+
readonly inference: SessionInference;
|
|
154
|
+
readonly voice: SessionVoice;
|
|
155
|
+
readonly events: Pick<EventBus, 'onEvent' | 'onAnyEvent'>;
|
|
156
|
+
readonly lifecycle: LifecycleBindings;
|
|
157
|
+
/** Server-time helper. See `time.ts`. */
|
|
158
|
+
readonly time: Pick<ServerTimeSource, 'serverNow' | 'recordEvent'>;
|
|
159
|
+
/** Internal — the live store; exported for `react.ts`. */
|
|
160
|
+
readonly store: SessionStore;
|
|
161
|
+
/**
|
|
162
|
+
* Forward an accepted consent decision to the persistence path.
|
|
163
|
+
* Returns `consent_persistence_not_wired` until chunk B17a binds
|
|
164
|
+
* the User Portal endpoint.
|
|
165
|
+
*/
|
|
166
|
+
readonly requestConsent: (
|
|
167
|
+
payload: ConsentRequiredPayload,
|
|
168
|
+
) => Promise<Result<void, SdkError>>;
|
|
169
|
+
/** Close the underlying WebSocket and dispose subscriptions. */
|
|
170
|
+
readonly close: () => void;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
type PendingRecord = {
|
|
174
|
+
readonly id: MessageId;
|
|
175
|
+
readonly resolve: (r: Result<void, SdkError>) => void;
|
|
176
|
+
readonly projectionId?: string;
|
|
177
|
+
readonly timer?: () => void;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const createSession = (config: SessionConfig): Session => {
|
|
181
|
+
const store = createSessionStore();
|
|
182
|
+
const bus = createEventBus();
|
|
183
|
+
const time = createServerTimeSource({ now: config.now });
|
|
184
|
+
const lifecycle = createLifecycleBindings(bus);
|
|
185
|
+
const pendingSubmits = new Map<MessageId, PendingRecord>();
|
|
186
|
+
const pendingInference = new Map<
|
|
187
|
+
MessageId,
|
|
188
|
+
(r: Result<unknown, SdkError>) => void
|
|
189
|
+
>();
|
|
190
|
+
const pendingVoice = new Map<
|
|
191
|
+
MessageId,
|
|
192
|
+
(r: Result<unknown, SdkError>) => void
|
|
193
|
+
>();
|
|
194
|
+
|
|
195
|
+
const handleStateDiff = (payload: StateDiffPayload): void => {
|
|
196
|
+
const action = store.applyDiff(
|
|
197
|
+
payload.patches,
|
|
198
|
+
payload.fromSeq,
|
|
199
|
+
payload.toSeq,
|
|
200
|
+
);
|
|
201
|
+
if (action === 'resync_required') {
|
|
202
|
+
// The seq window slipped — ask the Runtime for a fresh
|
|
203
|
+
// snapshot. The transport's `resubscribe` reuses the current
|
|
204
|
+
// resumeFromSeq; the server falls back to `snapshot` when it
|
|
205
|
+
// can't satisfy the resume from its buffer.
|
|
206
|
+
transport.resubscribe();
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleEvent = (payload: EventPayload): void => {
|
|
211
|
+
bus.dispatchEvent(payload);
|
|
212
|
+
if (payload.eventType.startsWith(INFERENCE_EVENT_PREFIX)) {
|
|
213
|
+
maybeResolveInference(payload);
|
|
214
|
+
} else if (payload.eventType.startsWith(VOICE_EVENT_PREFIX)) {
|
|
215
|
+
maybeResolveVoice(payload);
|
|
216
|
+
} else if (payload.eventType === CONSENT_REQUIRED_EVENT_TYPE) {
|
|
217
|
+
maybeHandleConsent(payload);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const handleLifecycle = (payload: LifecyclePayload): void => {
|
|
222
|
+
bus.dispatchLifecycle(payload);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleError = (payload: ErrorPayload): void => {
|
|
226
|
+
if (payload.causeMessageId === undefined) return;
|
|
227
|
+
const causeId = payload.causeMessageId as unknown as MessageId;
|
|
228
|
+
const submit = pendingSubmits.get(causeId);
|
|
229
|
+
if (submit !== undefined) {
|
|
230
|
+
if (submit.projectionId !== undefined) {
|
|
231
|
+
store.removeProjection(submit.projectionId);
|
|
232
|
+
}
|
|
233
|
+
submit.timer?.();
|
|
234
|
+
pendingSubmits.delete(causeId);
|
|
235
|
+
submit.resolve({
|
|
236
|
+
ok: false,
|
|
237
|
+
error: {
|
|
238
|
+
kind: 'submit_rejected',
|
|
239
|
+
code: payload.code,
|
|
240
|
+
message: payload.message,
|
|
241
|
+
causeMessageId: causeId,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
transport.confirmSend(causeId);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const infer = pendingInference.get(causeId);
|
|
248
|
+
if (infer !== undefined) {
|
|
249
|
+
pendingInference.delete(causeId);
|
|
250
|
+
transport.confirmSend(causeId);
|
|
251
|
+
infer({
|
|
252
|
+
ok: false,
|
|
253
|
+
error: {
|
|
254
|
+
kind: 'submit_rejected',
|
|
255
|
+
code: payload.code,
|
|
256
|
+
message: payload.message,
|
|
257
|
+
causeMessageId: causeId,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const voice = pendingVoice.get(causeId);
|
|
263
|
+
if (voice !== undefined) {
|
|
264
|
+
pendingVoice.delete(causeId);
|
|
265
|
+
transport.confirmSend(causeId);
|
|
266
|
+
voice({
|
|
267
|
+
ok: false,
|
|
268
|
+
error: {
|
|
269
|
+
kind: 'submit_rejected',
|
|
270
|
+
code: payload.code,
|
|
271
|
+
message: payload.message,
|
|
272
|
+
causeMessageId: causeId,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const transport: Transport = createTransport(
|
|
279
|
+
{
|
|
280
|
+
url: buildUrl(config),
|
|
281
|
+
sessionId: config.sessionId,
|
|
282
|
+
...(config.factory ? { factory: config.factory } : {}),
|
|
283
|
+
...(config.schedule ? { schedule: config.schedule } : {}),
|
|
284
|
+
...(config.random ? { random: config.random } : {}),
|
|
285
|
+
...(config.now ? { now: config.now } : {}),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
onServerMessage: (msg: ServerMessage) => {
|
|
289
|
+
switch (msg.kind) {
|
|
290
|
+
case 'snapshot':
|
|
291
|
+
store.applySnapshot(
|
|
292
|
+
msg.payload.state,
|
|
293
|
+
msg.payload.phaseId,
|
|
294
|
+
msg.payload.baseSeq,
|
|
295
|
+
);
|
|
296
|
+
break;
|
|
297
|
+
case 'state_diff':
|
|
298
|
+
handleStateDiff(msg.payload);
|
|
299
|
+
// A state_diff implicitly confirms every pending submit
|
|
300
|
+
// whose id <= toSeq's caused-by set (which we can't see
|
|
301
|
+
// from this side). For MVP we resolve the *oldest*
|
|
302
|
+
// pending submit on each diff; the chunk B8a Runtime
|
|
303
|
+
// emits a dedicated `submit.confirmed` event on the same
|
|
304
|
+
// wire kind that supersedes this heuristic once it ships.
|
|
305
|
+
confirmOldestPendingSubmit();
|
|
306
|
+
break;
|
|
307
|
+
case 'event':
|
|
308
|
+
handleEvent(msg.payload);
|
|
309
|
+
break;
|
|
310
|
+
case 'lifecycle':
|
|
311
|
+
handleLifecycle(msg.payload);
|
|
312
|
+
break;
|
|
313
|
+
case 'error':
|
|
314
|
+
handleError(msg.payload);
|
|
315
|
+
break;
|
|
316
|
+
case 'pong': {
|
|
317
|
+
const echo = msg.payload.echoTs;
|
|
318
|
+
const now = (config.now ?? Date.now)();
|
|
319
|
+
// Clock skew estimate: midpoint of round trip.
|
|
320
|
+
time.updateSkew(Math.round(msg.ts - (now + echo) / 2));
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
onDecodeError: () => {
|
|
326
|
+
// Drop silently. The Runtime emits well-formed frames; a
|
|
327
|
+
// decode failure here is either a corrupt buffer or a
|
|
328
|
+
// forward-compat artifact. Either way the SDK can't
|
|
329
|
+
// recover from a malformed frame.
|
|
330
|
+
},
|
|
331
|
+
onConnectionState: (next) => {
|
|
332
|
+
store.setConnectionState(next);
|
|
333
|
+
},
|
|
334
|
+
onReconnectReady: () => {
|
|
335
|
+
// The transport's onopen handler already sent the subscribe.
|
|
336
|
+
// Hook reserved for future bootstrap; intentionally empty.
|
|
337
|
+
},
|
|
338
|
+
getResumeFromSeq: () => {
|
|
339
|
+
const snap = store.getSnapshot();
|
|
340
|
+
return snap.appliedSeq > 0 ? snap.appliedSeq : undefined;
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const confirmOldestPendingSubmit = (): void => {
|
|
346
|
+
const next = pendingSubmits.entries().next();
|
|
347
|
+
if (next.done) return;
|
|
348
|
+
const [id, record] = next.value;
|
|
349
|
+
if (record.projectionId !== undefined) {
|
|
350
|
+
store.removeProjection(record.projectionId);
|
|
351
|
+
}
|
|
352
|
+
record.timer?.();
|
|
353
|
+
pendingSubmits.delete(id);
|
|
354
|
+
transport.confirmSend(id);
|
|
355
|
+
record.resolve(ok(undefined));
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const maybeResolveInference = (payload: EventPayload): void => {
|
|
359
|
+
const data = payload.data as { causeMessageId?: string } | null;
|
|
360
|
+
if (!data || typeof data.causeMessageId !== 'string') return;
|
|
361
|
+
const causeId = data.causeMessageId as unknown as MessageId;
|
|
362
|
+
const cb = pendingInference.get(causeId);
|
|
363
|
+
if (cb === undefined) return;
|
|
364
|
+
pendingInference.delete(causeId);
|
|
365
|
+
transport.confirmSend(causeId);
|
|
366
|
+
cb(ok(payload.data));
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const maybeResolveVoice = (payload: EventPayload): void => {
|
|
370
|
+
const data = payload.data as { causeMessageId?: string } | null;
|
|
371
|
+
if (!data || typeof data.causeMessageId !== 'string') return;
|
|
372
|
+
const causeId = data.causeMessageId as unknown as MessageId;
|
|
373
|
+
const cb = pendingVoice.get(causeId);
|
|
374
|
+
if (cb === undefined) return;
|
|
375
|
+
pendingVoice.delete(causeId);
|
|
376
|
+
transport.confirmSend(causeId);
|
|
377
|
+
cb(ok(payload.data));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const maybeHandleConsent = (payload: EventPayload): void => {
|
|
381
|
+
if (config.onConsentRequired === undefined) return;
|
|
382
|
+
const data = payload.data as ConsentRequiredPayload | null;
|
|
383
|
+
if (!data || typeof data !== 'object') return;
|
|
384
|
+
void (async () => {
|
|
385
|
+
let decision: ConsentDecision = 'declined';
|
|
386
|
+
try {
|
|
387
|
+
decision = await config.onConsentRequired!(data);
|
|
388
|
+
} catch {
|
|
389
|
+
decision = 'declined';
|
|
390
|
+
}
|
|
391
|
+
if (decision !== 'accepted') return;
|
|
392
|
+
const result = await session.requestConsent(data);
|
|
393
|
+
// The bridge informs the runtime of the outcome via an
|
|
394
|
+
// `emit('consent.decision', …)`. The Runtime forwards the
|
|
395
|
+
// grant to the Persona Service (chunk B3) on its next read.
|
|
396
|
+
transport.send(
|
|
397
|
+
'emit',
|
|
398
|
+
buildEmitPayload(config.sessionId, {
|
|
399
|
+
eventType: 'consent.decision',
|
|
400
|
+
data: {
|
|
401
|
+
personaId: data.personaId,
|
|
402
|
+
scope: data.scope,
|
|
403
|
+
decision,
|
|
404
|
+
persistOk: result.ok,
|
|
405
|
+
persistError: result.ok ? null : result.error.kind,
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
})();
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const session: Session = {
|
|
413
|
+
sessionId: config.sessionId,
|
|
414
|
+
getState: () => {
|
|
415
|
+
const snap = store.getSnapshot();
|
|
416
|
+
return {
|
|
417
|
+
connectionState: snap.connectionState,
|
|
418
|
+
state: snap.state,
|
|
419
|
+
projectedState: store.getProjectedState(),
|
|
420
|
+
appliedSeq: snap.appliedSeq,
|
|
421
|
+
phaseId: snap.phaseId,
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
425
|
+
submit: (args) => {
|
|
426
|
+
const payload = buildSubmitPayload(config.sessionId, args);
|
|
427
|
+
const sent = transport.send('submit', payload);
|
|
428
|
+
const id = sent.id;
|
|
429
|
+
let projectionId: string | undefined;
|
|
430
|
+
if (args.predictive !== undefined) {
|
|
431
|
+
projectionId = id as unknown as string;
|
|
432
|
+
registerProjection(
|
|
433
|
+
store,
|
|
434
|
+
projectionId,
|
|
435
|
+
args.predictive as OptimisticProjection,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
let resolveFn!: (r: Result<void, SdkError>) => void;
|
|
439
|
+
const promise = new Promise<Result<void, SdkError>>((res) => {
|
|
440
|
+
resolveFn = res;
|
|
441
|
+
});
|
|
442
|
+
const timeoutMs = args.timeoutMs ?? config.submitTimeoutMs ?? 30_000;
|
|
443
|
+
const timer = scheduleTimeout(timeoutMs, () => {
|
|
444
|
+
if (!pendingSubmits.has(id)) return;
|
|
445
|
+
const record = pendingSubmits.get(id);
|
|
446
|
+
pendingSubmits.delete(id);
|
|
447
|
+
if (record?.projectionId !== undefined) {
|
|
448
|
+
store.removeProjection(record.projectionId);
|
|
449
|
+
}
|
|
450
|
+
resolveFn({ ok: false, error: { kind: 'timeout' } });
|
|
451
|
+
});
|
|
452
|
+
pendingSubmits.set(id, {
|
|
453
|
+
id,
|
|
454
|
+
resolve: resolveFn,
|
|
455
|
+
...(projectionId ? { projectionId } : {}),
|
|
456
|
+
timer,
|
|
457
|
+
});
|
|
458
|
+
return {
|
|
459
|
+
id: id as unknown as string,
|
|
460
|
+
promise,
|
|
461
|
+
cancel: () => {
|
|
462
|
+
const record = pendingSubmits.get(id);
|
|
463
|
+
if (record === undefined) return;
|
|
464
|
+
pendingSubmits.delete(id);
|
|
465
|
+
if (record.projectionId !== undefined) {
|
|
466
|
+
store.removeProjection(record.projectionId);
|
|
467
|
+
}
|
|
468
|
+
record.timer?.();
|
|
469
|
+
resolveFn({ ok: false, error: { kind: 'timeout' } });
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
},
|
|
473
|
+
emit: (args) => {
|
|
474
|
+
const payload = buildEmitPayload(config.sessionId, args);
|
|
475
|
+
const sent = transport.send('emit', payload);
|
|
476
|
+
return ok({ id: sent.id as unknown as string });
|
|
477
|
+
},
|
|
478
|
+
host: {
|
|
479
|
+
pause: () => {
|
|
480
|
+
const payload = buildHostEmitPayload(
|
|
481
|
+
config.sessionId,
|
|
482
|
+
HOST_EVENT_TYPES.pause,
|
|
483
|
+
);
|
|
484
|
+
const sent = transport.send('emit', payload);
|
|
485
|
+
return ok({ id: sent.id as unknown as string });
|
|
486
|
+
},
|
|
487
|
+
resume: () => {
|
|
488
|
+
const payload = buildHostEmitPayload(
|
|
489
|
+
config.sessionId,
|
|
490
|
+
HOST_EVENT_TYPES.resume,
|
|
491
|
+
);
|
|
492
|
+
const sent = transport.send('emit', payload);
|
|
493
|
+
return ok({ id: sent.id as unknown as string });
|
|
494
|
+
},
|
|
495
|
+
advancePhase: (detail?: unknown) => {
|
|
496
|
+
const payload = buildHostEmitPayload(
|
|
497
|
+
config.sessionId,
|
|
498
|
+
HOST_EVENT_TYPES.advancePhase,
|
|
499
|
+
detail ?? {},
|
|
500
|
+
);
|
|
501
|
+
const sent = transport.send('emit', payload);
|
|
502
|
+
return ok({ id: sent.id as unknown as string });
|
|
503
|
+
},
|
|
504
|
+
reclaim: () => {
|
|
505
|
+
const payload = buildHostEmitPayload(
|
|
506
|
+
config.sessionId,
|
|
507
|
+
HOST_EVENT_TYPES.reclaim,
|
|
508
|
+
);
|
|
509
|
+
const sent = transport.send('emit', payload);
|
|
510
|
+
return ok({ id: sent.id as unknown as string });
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
inference: buildInferenceVerbs(
|
|
514
|
+
config.sessionId,
|
|
515
|
+
transport,
|
|
516
|
+
pendingInference,
|
|
517
|
+
),
|
|
518
|
+
voice: buildVoiceVerbs(config.sessionId, transport, pendingVoice),
|
|
519
|
+
events: { onEvent: bus.onEvent, onAnyEvent: bus.onAnyEvent },
|
|
520
|
+
lifecycle,
|
|
521
|
+
time: { serverNow: time.serverNow, recordEvent: time.recordEvent },
|
|
522
|
+
store,
|
|
523
|
+
requestConsent: async (payload) => {
|
|
524
|
+
if (config.persistConsent === undefined) {
|
|
525
|
+
return { ok: false, error: { kind: 'consent_persistence_not_wired' } };
|
|
526
|
+
}
|
|
527
|
+
return config.persistConsent(payload);
|
|
528
|
+
},
|
|
529
|
+
close: () => {
|
|
530
|
+
transport.close(1000, 'sdk close');
|
|
531
|
+
for (const [, record] of pendingSubmits) {
|
|
532
|
+
record.timer?.();
|
|
533
|
+
record.resolve({ ok: false, error: { kind: 'not_connected' } });
|
|
534
|
+
}
|
|
535
|
+
pendingSubmits.clear();
|
|
536
|
+
for (const [, cb] of pendingInference) {
|
|
537
|
+
cb({ ok: false, error: { kind: 'not_connected' } });
|
|
538
|
+
}
|
|
539
|
+
pendingInference.clear();
|
|
540
|
+
for (const [, cb] of pendingVoice) {
|
|
541
|
+
cb({ ok: false, error: { kind: 'not_connected' } });
|
|
542
|
+
}
|
|
543
|
+
pendingVoice.clear();
|
|
544
|
+
bus.dispose();
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
transport.start();
|
|
549
|
+
return session;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Build a wire-form URL with the bearer token threaded onto the
|
|
554
|
+
* query string. The Runtime accepts the token via either
|
|
555
|
+
* `Sec-WebSocket-Protocol` or `?token=` for browser compatibility
|
|
556
|
+
* (browsers can't set arbitrary request headers on `new WebSocket`).
|
|
557
|
+
*
|
|
558
|
+
* The token is excluded from any URL the SDK logs — `formatSdkError`
|
|
559
|
+
* never includes it. Operators should still treat the URL as
|
|
560
|
+
* sensitive.
|
|
561
|
+
*/
|
|
562
|
+
const buildUrl = (config: SessionConfig): string => {
|
|
563
|
+
if (config.auth === undefined) return config.wsUrl;
|
|
564
|
+
const sep = config.wsUrl.includes('?') ? '&' : '?';
|
|
565
|
+
return `${config.wsUrl}${sep}token=${encodeURIComponent(config.auth)}`;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const scheduleTimeout = (
|
|
569
|
+
ms: number,
|
|
570
|
+
cb: () => void,
|
|
571
|
+
): (() => void) => {
|
|
572
|
+
const handle = setTimeout(cb, ms);
|
|
573
|
+
return () => clearTimeout(handle);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const buildInferenceVerbs = (
|
|
577
|
+
sessionId: SessionId,
|
|
578
|
+
transport: Transport,
|
|
579
|
+
pending: Map<MessageId, (r: Result<unknown, SdkError>) => void>,
|
|
580
|
+
): SessionInference => {
|
|
581
|
+
const call = async <TOutput extends ZodTypeAny>(
|
|
582
|
+
input: InferenceCallInput<TOutput>,
|
|
583
|
+
): Promise<Result<InferenceCallSuccess<TOutput>, SdkError>> => {
|
|
584
|
+
const serialised = buildInferenceRequest(input);
|
|
585
|
+
let resolveFn!: (r: Result<unknown, SdkError>) => void;
|
|
586
|
+
const promise = new Promise<Result<unknown, SdkError>>((res) => {
|
|
587
|
+
resolveFn = res;
|
|
588
|
+
});
|
|
589
|
+
const sent = transport.send('emit', {
|
|
590
|
+
sessionId,
|
|
591
|
+
eventType: `${INFERENCE_EVENT_PREFIX}${input.callKind}`,
|
|
592
|
+
data: serialised,
|
|
593
|
+
});
|
|
594
|
+
pending.set(sent.id, resolveFn);
|
|
595
|
+
// Until chunk B8a wires the runtime, the call never resolves
|
|
596
|
+
// organically. Emit a runtime_not_wired error after a tick so
|
|
597
|
+
// the surface is observable without flapping.
|
|
598
|
+
setTimeout(() => {
|
|
599
|
+
const cb = pending.get(sent.id);
|
|
600
|
+
if (cb === undefined) return;
|
|
601
|
+
pending.delete(sent.id);
|
|
602
|
+
transport.confirmSend(sent.id);
|
|
603
|
+
cb({ ok: false, error: { kind: 'runtime_not_wired' } });
|
|
604
|
+
}, 0);
|
|
605
|
+
const result = await promise;
|
|
606
|
+
return result as Result<InferenceCallSuccess<TOutput>, SdkError>;
|
|
607
|
+
};
|
|
608
|
+
return {
|
|
609
|
+
call,
|
|
610
|
+
host: (input) => call({ ...input, callKind: 'host_open_phase' }),
|
|
611
|
+
judge: (input) => call({ ...input, callKind: 'judge_submissions' }),
|
|
612
|
+
classify: (input) => call({ ...input, callKind: 'classify' }),
|
|
613
|
+
};
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const buildVoiceVerbs = (
|
|
617
|
+
sessionId: SessionId,
|
|
618
|
+
transport: Transport,
|
|
619
|
+
pending: Map<MessageId, (r: Result<unknown, SdkError>) => void>,
|
|
620
|
+
): SessionVoice => ({
|
|
621
|
+
speak: async (input) => {
|
|
622
|
+
let resolveFn!: (r: Result<unknown, SdkError>) => void;
|
|
623
|
+
const promise = new Promise<Result<unknown, SdkError>>((res) => {
|
|
624
|
+
resolveFn = res;
|
|
625
|
+
});
|
|
626
|
+
const sent = transport.send('emit', {
|
|
627
|
+
sessionId,
|
|
628
|
+
eventType: `${VOICE_EVENT_PREFIX}speak`,
|
|
629
|
+
data: {
|
|
630
|
+
personaId: input.personaId,
|
|
631
|
+
text: input.text,
|
|
632
|
+
options: input.options ?? {},
|
|
633
|
+
caption: input.caption === null ? null : (input.caption ?? input.text),
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
pending.set(sent.id, resolveFn);
|
|
637
|
+
setTimeout(() => {
|
|
638
|
+
const cb = pending.get(sent.id);
|
|
639
|
+
if (cb === undefined) return;
|
|
640
|
+
pending.delete(sent.id);
|
|
641
|
+
transport.confirmSend(sent.id);
|
|
642
|
+
cb({ ok: false, error: { kind: 'runtime_not_wired' } });
|
|
643
|
+
}, 0);
|
|
644
|
+
const result = await promise;
|
|
645
|
+
return result as Result<SpeakSuccess, SdkError>;
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// Re-export for consumers that want to format SdkError directly.
|
|
650
|
+
export { formatSdkError };
|