@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/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
+ };
@@ -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
+ };