@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/time.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-time helpers (per Platform Spec §3.7.4).
|
|
3
|
+
*
|
|
4
|
+
* The Runtime stamps every server-emitted frame with its own clock
|
|
5
|
+
* (`ts` on the envelope). The SDK estimates the clock skew from the
|
|
6
|
+
* `pong` round-trip and exposes `serverNow()` so consumers can render
|
|
7
|
+
* timers without drifting against the source of truth.
|
|
8
|
+
*
|
|
9
|
+
* `recordEvent(eventId)` is the lightweight client-side marker hook —
|
|
10
|
+
* stores a `(eventId, clientTs)` row so a later test can assert
|
|
11
|
+
* ordering or latency. Not currently transmitted over the wire; B8a
|
|
12
|
+
* may extend it with a server-side echo.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ServerTimeSource = {
|
|
16
|
+
/** Server time in unix ms, adjusted for measured clock skew. */
|
|
17
|
+
readonly serverNow: () => number;
|
|
18
|
+
/**
|
|
19
|
+
* Record a client-side observation. Returns the wall-clock ms at
|
|
20
|
+
* the moment of capture. Stored in an in-memory ring buffer for
|
|
21
|
+
* test / debug access via `recordedEvents()`.
|
|
22
|
+
*/
|
|
23
|
+
readonly recordEvent: (eventId: string) => number;
|
|
24
|
+
readonly recordedEvents: () => readonly RecordedEvent[];
|
|
25
|
+
/** Internal: update the measured skew on a fresh pong. */
|
|
26
|
+
readonly updateSkew: (skewMs: number) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type RecordedEvent = {
|
|
30
|
+
readonly eventId: string;
|
|
31
|
+
readonly clientTs: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const EVENT_RING_CAP = 64;
|
|
35
|
+
|
|
36
|
+
export const createServerTimeSource = (
|
|
37
|
+
opts: {
|
|
38
|
+
readonly now?: () => number;
|
|
39
|
+
} = {},
|
|
40
|
+
): ServerTimeSource => {
|
|
41
|
+
const nowFn = opts.now ?? (() => Date.now());
|
|
42
|
+
let skew = 0;
|
|
43
|
+
const events: RecordedEvent[] = [];
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
serverNow: () => nowFn() + skew,
|
|
47
|
+
recordEvent: (eventId) => {
|
|
48
|
+
const clientTs = nowFn();
|
|
49
|
+
events.push({ eventId, clientTs });
|
|
50
|
+
if (events.length > EVENT_RING_CAP) events.shift();
|
|
51
|
+
return clientTs;
|
|
52
|
+
},
|
|
53
|
+
recordedEvents: () => events.slice(),
|
|
54
|
+
updateSkew: (newSkew) => {
|
|
55
|
+
skew = newSkew;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
};
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket transport for the Studio SDK.
|
|
3
|
+
*
|
|
4
|
+
* Per chunk B7 build: "transport.ts — WebSocket connection with
|
|
5
|
+
* automatic reconnection, exponential backoff, idempotent retries
|
|
6
|
+
* (uses MessageIdSchema for client → server frames), and pong-based
|
|
7
|
+
* clock-skew estimation."
|
|
8
|
+
*
|
|
9
|
+
* The transport owns nothing semantic — no game state, no game-level
|
|
10
|
+
* retries, no slot composition. It owns the socket lifecycle, frame
|
|
11
|
+
* encoding / decoding, and the per-connection seq counter for
|
|
12
|
+
* outbound frames. Inbound frames are surfaced through an
|
|
13
|
+
* observer interface; the higher-level pieces (`client.ts`,
|
|
14
|
+
* `store.ts`) layer their own behaviour on top.
|
|
15
|
+
*
|
|
16
|
+
* **Reconnection.** On any non-clean close, the transport schedules
|
|
17
|
+
* a reconnect after a randomised backoff (1s · 2^attempt up to 30s,
|
|
18
|
+
* full jitter). On reconnect, the SDK re-issues `subscribe` with the
|
|
19
|
+
* latest `resumeFromSeq` from the store and re-sends every pending
|
|
20
|
+
* `submit`/`emit` whose envelope `id` has not been confirmed. The
|
|
21
|
+
* Runtime dedupes by `id` per the chunk-B0 idempotency rule.
|
|
22
|
+
*
|
|
23
|
+
* **Test seam.** `WebSocketFactory` lets callers inject a mock socket
|
|
24
|
+
* (the SDK tests in `tests/transport.test.ts` use a fake constructor;
|
|
25
|
+
* the chunk-B7 acceptance "Disconnecting and reconnecting the
|
|
26
|
+
* WebSocket restores state correctly (Runtime mocked at the
|
|
27
|
+
* WebSocket boundary)").
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { newId } from '@wibly/internal-shared';
|
|
31
|
+
import {
|
|
32
|
+
PROTOCOL_VERSION,
|
|
33
|
+
decode,
|
|
34
|
+
encode,
|
|
35
|
+
isServerMessageKind,
|
|
36
|
+
type ClientMessage,
|
|
37
|
+
type ClientMessageKind,
|
|
38
|
+
type Message,
|
|
39
|
+
type MessageId,
|
|
40
|
+
type ProtocolError,
|
|
41
|
+
type ServerMessage,
|
|
42
|
+
type SessionId,
|
|
43
|
+
} from '@wibly/internal-protocol';
|
|
44
|
+
|
|
45
|
+
export type WebSocketLike = {
|
|
46
|
+
readonly readyState: number;
|
|
47
|
+
send(data: string): void;
|
|
48
|
+
close(code?: number, reason?: string): void;
|
|
49
|
+
onopen: ((ev?: unknown) => void) | null;
|
|
50
|
+
onclose: ((ev?: { code?: number; reason?: string } | undefined) => void) | null;
|
|
51
|
+
onerror: ((ev?: unknown) => void) | null;
|
|
52
|
+
onmessage: ((ev: { data: unknown }) => void) | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type WebSocketFactory = (url: string) => WebSocketLike;
|
|
56
|
+
|
|
57
|
+
const WS_OPEN = 1;
|
|
58
|
+
const WS_CLOSING = 2;
|
|
59
|
+
const WS_CLOSED = 3;
|
|
60
|
+
|
|
61
|
+
export type TransportObserver = {
|
|
62
|
+
/** Invoked for every successfully decoded server frame. */
|
|
63
|
+
onServerMessage: (msg: ServerMessage) => void;
|
|
64
|
+
/** Invoked when a frame fails to decode. */
|
|
65
|
+
onDecodeError: (e: ProtocolError, raw: string) => void;
|
|
66
|
+
/** Invoked when the socket transitions to a new connection phase. */
|
|
67
|
+
onConnectionState: (
|
|
68
|
+
next: 'connecting' | 'open' | 'reconnecting' | 'closed',
|
|
69
|
+
) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Invoked once the socket has just opened — fires AFTER the
|
|
72
|
+
* transport has dispatched its own per-connection `subscribe`
|
|
73
|
+
* frame and re-flushed pending submits. Use this hook to wire
|
|
74
|
+
* any per-connection bootstrap that's not part of the protocol
|
|
75
|
+
* (logging, telemetry).
|
|
76
|
+
*/
|
|
77
|
+
onReconnectReady: () => void;
|
|
78
|
+
/**
|
|
79
|
+
* Read the current `resumeFromSeq` to send on each new socket's
|
|
80
|
+
* automatic `subscribe`. Returning `undefined` triggers a
|
|
81
|
+
* full-state subscribe (the server replies with `snapshot`).
|
|
82
|
+
*/
|
|
83
|
+
getResumeFromSeq: () => number | undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type TransportConfig = {
|
|
87
|
+
readonly url: string;
|
|
88
|
+
readonly sessionId: SessionId;
|
|
89
|
+
readonly factory?: WebSocketFactory;
|
|
90
|
+
readonly now?: () => number;
|
|
91
|
+
/**
|
|
92
|
+
* Schedule a callback after a delay. Replaces `setTimeout` so tests
|
|
93
|
+
* can simulate reconnect backoff deterministically.
|
|
94
|
+
*/
|
|
95
|
+
readonly schedule?: (cb: () => void, ms: number) => () => void;
|
|
96
|
+
readonly random?: () => number;
|
|
97
|
+
/** Cap on backoff. Default 30_000ms (per Platform Spec §3.7.5). */
|
|
98
|
+
readonly maxBackoffMs?: number;
|
|
99
|
+
/** Base of the exponential backoff. Default 1_000ms. */
|
|
100
|
+
readonly baseBackoffMs?: number;
|
|
101
|
+
/** Maximum reconnect attempts before giving up. Default Infinity. */
|
|
102
|
+
readonly maxAttempts?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type Transport = {
|
|
106
|
+
readonly start: () => void;
|
|
107
|
+
readonly close: (code?: number, reason?: string) => void;
|
|
108
|
+
/**
|
|
109
|
+
* Send a client-side payload. The transport stamps `protocolVersion`,
|
|
110
|
+
* assigns a fresh outbound `seq`, and either dispatches immediately
|
|
111
|
+
* (when the socket is open) or queues until the next reconnect.
|
|
112
|
+
* Returns the envelope `id` so the caller can match a server
|
|
113
|
+
* acknowledgement / error frame against the pending request.
|
|
114
|
+
*
|
|
115
|
+
* The same `id` is re-sent on reconnect if the original send has
|
|
116
|
+
* not been confirmed (`confirmSend(id)`). This satisfies the
|
|
117
|
+
* idempotency contract from chunk B0.
|
|
118
|
+
*
|
|
119
|
+
* Never throws and never fails — buffered for retry while the
|
|
120
|
+
* socket is closed, dispatched immediately while open.
|
|
121
|
+
*/
|
|
122
|
+
readonly send: <K extends ClientMessageKind>(
|
|
123
|
+
kind: K,
|
|
124
|
+
payload: Extract<ClientMessage, { kind: K }>['payload'],
|
|
125
|
+
) => { readonly id: MessageId; readonly seq: number };
|
|
126
|
+
/**
|
|
127
|
+
* Mark a previously-sent client frame as confirmed. After
|
|
128
|
+
* confirmation the transport drops the frame from its retry buffer.
|
|
129
|
+
* The SDK calls this when the server emits a matching `state_diff`
|
|
130
|
+
* (for `submit`) or an `error` (for any client frame).
|
|
131
|
+
*/
|
|
132
|
+
readonly confirmSend: (id: MessageId) => void;
|
|
133
|
+
/**
|
|
134
|
+
* Re-send the per-connection subscribe frame on demand. Used by
|
|
135
|
+
* the session when it detects a state-diff gap and needs the
|
|
136
|
+
* Runtime to emit a fresh snapshot.
|
|
137
|
+
*/
|
|
138
|
+
readonly resubscribe: () => void;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const DEFAULT_FACTORY: WebSocketFactory = (url) => {
|
|
142
|
+
if (typeof WebSocket === 'undefined') {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'sdk: no WebSocket factory available; pass `factory` for non-browser environments',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return new WebSocket(url) as unknown as WebSocketLike;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const DEFAULT_SCHEDULE: NonNullable<TransportConfig['schedule']> = (
|
|
151
|
+
cb,
|
|
152
|
+
ms,
|
|
153
|
+
) => {
|
|
154
|
+
const handle = setTimeout(cb, ms);
|
|
155
|
+
return () => clearTimeout(handle);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compute the next reconnect delay. `attempt` is 1-indexed:
|
|
160
|
+
*
|
|
161
|
+
* attempt 1 → [0, base]
|
|
162
|
+
* attempt 2 → [0, base * 2]
|
|
163
|
+
* attempt 3 → [0, base * 4]
|
|
164
|
+
* …
|
|
165
|
+
*
|
|
166
|
+
* Capped at `maxMs`. Full jitter (Polly recommended) keeps thundering
|
|
167
|
+
* herds away from the Runtime after a region blip.
|
|
168
|
+
*/
|
|
169
|
+
export const computeBackoffMs = (
|
|
170
|
+
attempt: number,
|
|
171
|
+
opts: {
|
|
172
|
+
readonly base: number;
|
|
173
|
+
readonly cap: number;
|
|
174
|
+
readonly random: () => number;
|
|
175
|
+
},
|
|
176
|
+
): number => {
|
|
177
|
+
const ceiling = Math.min(opts.cap, opts.base * 2 ** (attempt - 1));
|
|
178
|
+
return Math.floor(opts.random() * ceiling);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const createTransport = (
|
|
182
|
+
config: TransportConfig,
|
|
183
|
+
observer: TransportObserver,
|
|
184
|
+
): Transport => {
|
|
185
|
+
const factory = config.factory ?? DEFAULT_FACTORY;
|
|
186
|
+
const schedule = config.schedule ?? DEFAULT_SCHEDULE;
|
|
187
|
+
const random = config.random ?? Math.random;
|
|
188
|
+
const now = config.now ?? Date.now;
|
|
189
|
+
const baseBackoffMs = config.baseBackoffMs ?? 1_000;
|
|
190
|
+
const maxBackoffMs = config.maxBackoffMs ?? 30_000;
|
|
191
|
+
const maxAttempts = config.maxAttempts ?? Infinity;
|
|
192
|
+
|
|
193
|
+
let socket: WebSocketLike | null = null;
|
|
194
|
+
let outboundSeq = 0;
|
|
195
|
+
let attempts = 0;
|
|
196
|
+
let cancelReconnect: (() => void) | null = null;
|
|
197
|
+
let closed = false;
|
|
198
|
+
let opened = false;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Pending outbound frames keyed by envelope id. We retain them
|
|
202
|
+
* until either (a) the server confirms (state_diff / error) or
|
|
203
|
+
* (b) the caller drops the session.
|
|
204
|
+
*
|
|
205
|
+
* This is a Map (insertion-ordered) so the re-send on reconnect
|
|
206
|
+
* preserves the original send order, which the Runtime needs to
|
|
207
|
+
* deduplicate against its own seq.
|
|
208
|
+
*/
|
|
209
|
+
const pending = new Map<MessageId, ClientMessage>();
|
|
210
|
+
|
|
211
|
+
const trySend = (msg: ClientMessage): boolean => {
|
|
212
|
+
if (socket === null || socket.readyState !== WS_OPEN) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
socket.send(encode(msg));
|
|
216
|
+
return true;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const enqueueAndSend = (msg: ClientMessage): void => {
|
|
220
|
+
pending.set(msg.id, msg);
|
|
221
|
+
trySend(msg);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const flushPending = (): void => {
|
|
225
|
+
for (const msg of pending.values()) {
|
|
226
|
+
trySend(msg);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const scheduleReconnect = (): void => {
|
|
231
|
+
if (closed) return;
|
|
232
|
+
attempts += 1;
|
|
233
|
+
if (attempts > maxAttempts) {
|
|
234
|
+
observer.onConnectionState('closed');
|
|
235
|
+
closed = true;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const delay = computeBackoffMs(attempts, {
|
|
239
|
+
base: baseBackoffMs,
|
|
240
|
+
cap: maxBackoffMs,
|
|
241
|
+
random,
|
|
242
|
+
});
|
|
243
|
+
observer.onConnectionState('reconnecting');
|
|
244
|
+
cancelReconnect = schedule(() => {
|
|
245
|
+
cancelReconnect = null;
|
|
246
|
+
open();
|
|
247
|
+
}, delay);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const sendSubscribe = (): void => {
|
|
251
|
+
const current = socket;
|
|
252
|
+
if (current === null || current.readyState !== WS_OPEN) return;
|
|
253
|
+
const resumeFromSeq = observer.getResumeFromSeq();
|
|
254
|
+
outboundSeq += 1;
|
|
255
|
+
const msg = {
|
|
256
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
257
|
+
kind: 'subscribe' as const,
|
|
258
|
+
seq: outboundSeq,
|
|
259
|
+
id: newId('msg'),
|
|
260
|
+
ts: now(),
|
|
261
|
+
payload: {
|
|
262
|
+
sessionId: config.sessionId,
|
|
263
|
+
...(resumeFromSeq !== undefined ? { resumeFromSeq } : {}),
|
|
264
|
+
},
|
|
265
|
+
} as unknown as ClientMessage;
|
|
266
|
+
current.send(encode(msg));
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const open = (): void => {
|
|
270
|
+
if (closed) return;
|
|
271
|
+
observer.onConnectionState(opened ? 'reconnecting' : 'connecting');
|
|
272
|
+
socket = factory(config.url);
|
|
273
|
+
|
|
274
|
+
socket.onopen = (): void => {
|
|
275
|
+
opened = true;
|
|
276
|
+
observer.onConnectionState('open');
|
|
277
|
+
// 1. Subscribe is the first frame on every new socket.
|
|
278
|
+
sendSubscribe();
|
|
279
|
+
// 2. Replay any pending user frames (submit / emit) — the
|
|
280
|
+
// Runtime dedupes by envelope id.
|
|
281
|
+
flushPending();
|
|
282
|
+
// 3. Tell the consumer the socket is hot.
|
|
283
|
+
observer.onReconnectReady();
|
|
284
|
+
};
|
|
285
|
+
socket.onmessage = (ev): void => {
|
|
286
|
+
const raw = typeof ev.data === 'string' ? ev.data : String(ev.data);
|
|
287
|
+
const decoded = decode(raw);
|
|
288
|
+
if (decoded.ok) {
|
|
289
|
+
if (isServerMessageKind(decoded.value.kind)) {
|
|
290
|
+
// A successfully-decoded server frame is the signal that
|
|
291
|
+
// the Runtime accepted our subscribe. Reset the backoff
|
|
292
|
+
// counter only here — a bare TCP open isn't enough; a
|
|
293
|
+
// misconfigured proxy / auth rejection can produce
|
|
294
|
+
// open-then-immediate-close loops which should still
|
|
295
|
+
// back off exponentially.
|
|
296
|
+
attempts = 0;
|
|
297
|
+
observer.onServerMessage(decoded.value as ServerMessage);
|
|
298
|
+
}
|
|
299
|
+
// A client-kind frame on the WS read path is a peer protocol
|
|
300
|
+
// bug; we drop it silently. The decoder validated the
|
|
301
|
+
// envelope, so this only happens if the Runtime echoes a
|
|
302
|
+
// client frame, which it never does.
|
|
303
|
+
} else {
|
|
304
|
+
observer.onDecodeError(decoded.error, raw);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
socket.onerror = (): void => {
|
|
308
|
+
// Errors are surfaced as a close in browsers; reconnection
|
|
309
|
+
// happens in `onclose`. We intentionally do nothing here.
|
|
310
|
+
};
|
|
311
|
+
socket.onclose = (ev): void => {
|
|
312
|
+
socket = null;
|
|
313
|
+
if (closed) {
|
|
314
|
+
// The caller invoked `transport.close()`; that path already
|
|
315
|
+
// emitted `onConnectionState('closed')`, so do nothing here.
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// 1000 normal, 1005 no status — treat both as a polite close
|
|
319
|
+
// initiated by the peer (Runtime restart). We still reconnect
|
|
320
|
+
// because the SDK's contract is "stay subscribed".
|
|
321
|
+
void ev;
|
|
322
|
+
scheduleReconnect();
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Resend the subscribe frame on demand. The session calls this
|
|
328
|
+
* when it detects a state-diff gap (`fromSeq` mismatch) and needs
|
|
329
|
+
* the Runtime to re-emit a fresh snapshot.
|
|
330
|
+
*/
|
|
331
|
+
const resubscribe = (): void => {
|
|
332
|
+
sendSubscribe();
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
start: () => {
|
|
337
|
+
if (closed) {
|
|
338
|
+
throw new Error('sdk transport: cannot start after close()');
|
|
339
|
+
}
|
|
340
|
+
if (socket !== null) return;
|
|
341
|
+
open();
|
|
342
|
+
},
|
|
343
|
+
close: (code, reason) => {
|
|
344
|
+
closed = true;
|
|
345
|
+
if (cancelReconnect !== null) {
|
|
346
|
+
cancelReconnect();
|
|
347
|
+
cancelReconnect = null;
|
|
348
|
+
}
|
|
349
|
+
const current = socket;
|
|
350
|
+
socket = null;
|
|
351
|
+
if (current !== null && current.readyState !== WS_CLOSED && current.readyState !== WS_CLOSING) {
|
|
352
|
+
current.close(code, reason);
|
|
353
|
+
}
|
|
354
|
+
observer.onConnectionState('closed');
|
|
355
|
+
},
|
|
356
|
+
send: (kind, payload) => {
|
|
357
|
+
const id = newId('msg');
|
|
358
|
+
outboundSeq += 1;
|
|
359
|
+
const msg = {
|
|
360
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
361
|
+
kind,
|
|
362
|
+
seq: outboundSeq,
|
|
363
|
+
id,
|
|
364
|
+
ts: now(),
|
|
365
|
+
payload,
|
|
366
|
+
} as unknown as ClientMessage;
|
|
367
|
+
enqueueAndSend(msg);
|
|
368
|
+
return { id, seq: outboundSeq };
|
|
369
|
+
},
|
|
370
|
+
confirmSend: (id) => {
|
|
371
|
+
pending.delete(id);
|
|
372
|
+
},
|
|
373
|
+
resubscribe,
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Internal helper exposed for the test suite. Surfaces the result of
|
|
379
|
+
* deciding what to do with an incoming `state_diff`'s seq vs the
|
|
380
|
+
* caller's `appliedSeq`. Kept here (rather than in `store.ts`) so the
|
|
381
|
+
* transport's reconnection flow can call into the same predicate.
|
|
382
|
+
*/
|
|
383
|
+
export const decideStateDiffAction = (
|
|
384
|
+
msg: ServerMessage,
|
|
385
|
+
appliedSeq: number,
|
|
386
|
+
): 'apply' | 'ignore_stale' | 'request_resync' => {
|
|
387
|
+
if (msg.kind !== 'state_diff') return 'apply';
|
|
388
|
+
if (msg.payload.toSeq <= appliedSeq) return 'ignore_stale';
|
|
389
|
+
if (msg.payload.fromSeq === appliedSeq) return 'apply';
|
|
390
|
+
return 'request_resync';
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Re-export internal constants for the SDK tests.
|
|
394
|
+
export const __test = {
|
|
395
|
+
WS_OPEN,
|
|
396
|
+
WS_CLOSING,
|
|
397
|
+
WS_CLOSED,
|
|
398
|
+
computeBackoffMs,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export type { Message };
|
package/src/voice.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice (TTS) helpers (per chunk B7 build: "voice.ts — TTS playback
|
|
3
|
+
* binding. Plays an audio response from the Gateway as a `<audio>`
|
|
4
|
+
* stream. Tone-down for accessibility (subtitle fallback) lives
|
|
5
|
+
* here.").
|
|
6
|
+
*
|
|
7
|
+
* Like inference, the SDK does NOT call the Gateway directly — voice
|
|
8
|
+
* lines are emitted via the Runtime so the same auth + metering +
|
|
9
|
+
* audit path applies. Chunk B8a wires the Runtime-side handler; the
|
|
10
|
+
* SDK's `voice.speak` returns `runtime_not_wired` until then.
|
|
11
|
+
*
|
|
12
|
+
* The chunk-B7 surface is the *playback* helper (a tiny wrapper that
|
|
13
|
+
* decodes the base64 audio and feeds it to an `<audio>` element)
|
|
14
|
+
* plus the wire shape — the SDK isn't holding the ElevenLabs key.
|
|
15
|
+
*
|
|
16
|
+
* Accessibility: `speak` accepts an optional `caption` field. If
|
|
17
|
+
* present, the SDK fires a `voice.caption` event on the bus (which
|
|
18
|
+
* the host shell renders as a transcript line) regardless of whether
|
|
19
|
+
* audio playback succeeds. This is the "subtitle fallback" the spec
|
|
20
|
+
* calls out.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Result } from '@wibly/internal-shared';
|
|
24
|
+
|
|
25
|
+
import type { SdkError } from './errors.js';
|
|
26
|
+
|
|
27
|
+
export type SpeakInput = {
|
|
28
|
+
readonly personaId: string;
|
|
29
|
+
readonly text: string;
|
|
30
|
+
readonly options?: {
|
|
31
|
+
readonly voiceId?: string;
|
|
32
|
+
readonly modelId?: string;
|
|
33
|
+
readonly qualityTier?: 'preview' | 'standard' | 'high';
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Caption text rendered through the SDK's `voice.caption` event.
|
|
37
|
+
* Defaults to `text` if omitted. Pass `null` to suppress the
|
|
38
|
+
* caption (debug only — production should always show a subtitle).
|
|
39
|
+
*/
|
|
40
|
+
readonly caption?: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SpeakSuccess = {
|
|
44
|
+
readonly audioBase64: string;
|
|
45
|
+
readonly contentType: string;
|
|
46
|
+
readonly durationMs: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type SessionVoice = {
|
|
50
|
+
readonly speak: (input: SpeakInput) => Promise<Result<SpeakSuccess, SdkError>>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const VOICE_EVENT_PREFIX = 'voice.' as const;
|
|
54
|
+
|
|
55
|
+
/** Approximate playback duration for a base64-encoded audio chunk.
|
|
56
|
+
*
|
|
57
|
+
* MP3 at 22.05 kHz mono averages ~16 bytes per second of audio in
|
|
58
|
+
* the encoded form for our typical bitrate; the estimate is good
|
|
59
|
+
* enough for fade-out timing and `aria-live` scheduling. Production
|
|
60
|
+
* playback should call `audio.duration` once the element loads, but
|
|
61
|
+
* this gives the SDK a useful number before then.
|
|
62
|
+
*/
|
|
63
|
+
export const estimateAudioDurationMs = (audioBase64: string): number => {
|
|
64
|
+
// base64 inflates by ~4/3; the underlying bytes are what we want.
|
|
65
|
+
const rawBytes = Math.floor((audioBase64.length * 3) / 4);
|
|
66
|
+
return Math.round((rawBytes / 16) * 1000);
|
|
67
|
+
};
|