@lumavox/client 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +119 -2
- package/dist/audio.d.ts +38 -0
- package/dist/audio.js +1 -0
- package/dist/client.d.ts +108 -0
- package/dist/client.js +122 -0
- package/dist/conversation.d.ts +324 -0
- package/dist/conversation.js +763 -0
- package/dist/errors.d.ts +86 -0
- package/dist/errors.js +168 -0
- package/dist/events.d.ts +224 -0
- package/dist/events.js +1 -0
- package/dist/gen/index.d.ts +1 -0
- package/dist/gen/index.js +2 -0
- package/dist/gen/types.gen.d.ts +776 -0
- package/dist/gen/types.gen.js +2 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +15 -0
- package/dist/pcm.d.ts +38 -0
- package/dist/pcm.js +110 -0
- package/dist/protocol.gen.d.ts +44 -0
- package/dist/protocol.gen.js +46 -0
- package/dist/socket.d.ts +167 -0
- package/dist/socket.js +185 -0
- package/dist/token.d.ts +39 -0
- package/dist/token.js +63 -0
- package/dist/transport.d.ts +83 -0
- package/dist/transport.js +236 -0
- package/package.json +39 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lumavox/client`: agents from an end user's device — a browser, a React
|
|
3
|
+
* Native app, Node — with their ephemeral key. Conversations on the agent's
|
|
4
|
+
* socket (`client.connect`), and the agent's REST routes: sessions, facts,
|
|
5
|
+
* relations. Bodies, answers and frames are the wire's, snake_case,
|
|
6
|
+
* typed from the published OpenAPI document (`src/gen/`) and the edge's
|
|
7
|
+
* vocabulary (`src/protocol.gen.ts`); methods are camelCase, and the SDK's
|
|
8
|
+
* own options go last.
|
|
9
|
+
*/
|
|
10
|
+
export type { AgentAudio } from "./audio.js";
|
|
11
|
+
export { type Client, type ClientOptions, createClient, DEFAULT_BASE_URL, type Facts, type RelationListParams, type Relations, type SessionGetParams, type SessionListParams, type Sessions, } from "./client.js";
|
|
12
|
+
export { type CloseInfo, type ConnectOptions, Conversation, type ConversationHandlers, type ConversationState, type Disconnect, type Dropped, READY_DEADLINE_MS, type Snapshot, type StartAudioOptions, type TranscriptOutcome, type TurnOptions, type TurnOutcome, type TurnSnapshot, } from "./conversation.js";
|
|
13
|
+
export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, type PlatformErrorInit, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
|
|
14
|
+
export type * from "./events.js";
|
|
15
|
+
export type * from "./gen/index.js";
|
|
16
|
+
export { concatPcm, floatToPcm16, LITTLE_ENDIAN, pcm16ToFloat, pcmSeconds, resample, } from "./pcm.js";
|
|
17
|
+
export * from "./protocol.gen.js";
|
|
18
|
+
export { type AudioFrame, closeName, isFinalClose, MAX_RECONNECTS, type Reconnect, reconnectAfter, type Warning, type WebSocketConstructor, type WebSocketLike, } from "./socket.js";
|
|
19
|
+
export type { GetToken, TokenRequest } from "./token.js";
|
|
20
|
+
export type { Fetch, RequestOptions } from "./transport.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lumavox/client`: agents from an end user's device — a browser, a React
|
|
3
|
+
* Native app, Node — with their ephemeral key. Conversations on the agent's
|
|
4
|
+
* socket (`client.connect`), and the agent's REST routes: sessions, facts,
|
|
5
|
+
* relations. Bodies, answers and frames are the wire's, snake_case,
|
|
6
|
+
* typed from the published OpenAPI document (`src/gen/`) and the edge's
|
|
7
|
+
* vocabulary (`src/protocol.gen.ts`); methods are camelCase, and the SDK's
|
|
8
|
+
* own options go last.
|
|
9
|
+
*/
|
|
10
|
+
export { createClient, DEFAULT_BASE_URL, } from "./client.js";
|
|
11
|
+
export { Conversation, READY_DEADLINE_MS, } from "./conversation.js";
|
|
12
|
+
export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
|
|
13
|
+
export { concatPcm, floatToPcm16, LITTLE_ENDIAN, pcm16ToFloat, pcmSeconds, resample, } from "./pcm.js";
|
|
14
|
+
export * from "./protocol.gen.js";
|
|
15
|
+
export { closeName, isFinalClose, MAX_RECONNECTS, reconnectAfter, } from "./socket.js";
|
package/dist/pcm.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw PCM in and out: the wire's audio is 16-bit signed little-endian,
|
|
3
|
+
* mono, with no header (the docs' WebSocket page, *Audio out*). The SDK
|
|
4
|
+
* captures and plays nothing in v1 — a recorder hands it samples, a player
|
|
5
|
+
* takes them — so these are the conversions between the two and the wire,
|
|
6
|
+
* each tree-shaken away when unused.
|
|
7
|
+
*
|
|
8
|
+
* **Byte order is checked once, here.** Every device the SDK runs on is
|
|
9
|
+
* little-endian, so a typed array's bytes are the wire's bytes, and the
|
|
10
|
+
* fast path is a view; a big-endian one goes through a `DataView`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LITTLE_ENDIAN: boolean;
|
|
13
|
+
/** Float samples in [-1, 1] to 16-bit samples, clamped. */
|
|
14
|
+
export declare function floatToPcm16(samples: Float32Array): Int16Array;
|
|
15
|
+
/**
|
|
16
|
+
* The wire's PCM as float samples in [-1, 1]. An odd trailing byte — half a
|
|
17
|
+
* sample — is dropped: `Int16Array` throws on a buffer that holds one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function pcm16ToFloat(pcm: ArrayBuffer | Int16Array): Float32Array;
|
|
20
|
+
/**
|
|
21
|
+
* Samples at `from` Hz resampled to `to` Hz, linearly: a 48 kHz microphone
|
|
22
|
+
* is not a rate the socket accepts (`RECORDING_RATES`), and speech loses
|
|
23
|
+
* nothing a transcriber needs by it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resample(samples: Float32Array, from: number, to: number): Float32Array;
|
|
26
|
+
/** Pieces of PCM as one buffer: the frames of a reply, to save or replay. */
|
|
27
|
+
export declare function concatPcm(pieces: readonly (ArrayBuffer | ArrayBufferView)[]): ArrayBuffer;
|
|
28
|
+
/** How long PCM lasts: its bytes (or a buffer of them) at `rate` Hz. */
|
|
29
|
+
export declare function pcmSeconds(pcm: number | ArrayBuffer | ArrayBufferView, rate: number, channels?: number): number;
|
|
30
|
+
/**
|
|
31
|
+
* Samples as the wire's bytes, as an `ArrayBuffer` — what every runtime's
|
|
32
|
+
* `WebSocket.send` takes (`socket.ts`, `WebSocketLike`). A buffer as it is,
|
|
33
|
+
* since it already holds the wire's bytes; a typed array's own buffer on a
|
|
34
|
+
* little-endian device, never copied, when the array is the whole of an
|
|
35
|
+
* unshared one — what a recorder hands over. A view onto part of a buffer,
|
|
36
|
+
* or onto a shared one, is copied: one pass over its bytes.
|
|
37
|
+
*/
|
|
38
|
+
export declare function wireBytes(pcm: ArrayBuffer | Int16Array): ArrayBuffer;
|
package/dist/pcm.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw PCM in and out: the wire's audio is 16-bit signed little-endian,
|
|
3
|
+
* mono, with no header (the docs' WebSocket page, *Audio out*). The SDK
|
|
4
|
+
* captures and plays nothing in v1 — a recorder hands it samples, a player
|
|
5
|
+
* takes them — so these are the conversions between the two and the wire,
|
|
6
|
+
* each tree-shaken away when unused.
|
|
7
|
+
*
|
|
8
|
+
* **Byte order is checked once, here.** Every device the SDK runs on is
|
|
9
|
+
* little-endian, so a typed array's bytes are the wire's bytes, and the
|
|
10
|
+
* fast path is a view; a big-endian one goes through a `DataView`.
|
|
11
|
+
*/
|
|
12
|
+
export const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
|
|
13
|
+
/** Float samples in [-1, 1] to 16-bit samples, clamped. */
|
|
14
|
+
export function floatToPcm16(samples) {
|
|
15
|
+
const out = new Int16Array(samples.length);
|
|
16
|
+
for (let i = 0; i < samples.length; i++) {
|
|
17
|
+
const s = Math.max(-1, Math.min(1, samples[i]));
|
|
18
|
+
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The wire's PCM as float samples in [-1, 1]. An odd trailing byte — half a
|
|
24
|
+
* sample — is dropped: `Int16Array` throws on a buffer that holds one.
|
|
25
|
+
*/
|
|
26
|
+
export function pcm16ToFloat(pcm) {
|
|
27
|
+
if (pcm instanceof Int16Array) {
|
|
28
|
+
const out = new Float32Array(pcm.length);
|
|
29
|
+
for (let i = 0; i < pcm.length; i++)
|
|
30
|
+
out[i] = pcm[i] / 0x8000;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
const count = pcm.byteLength >> 1;
|
|
34
|
+
const out = new Float32Array(count);
|
|
35
|
+
if (LITTLE_ENDIAN) {
|
|
36
|
+
const samples = new Int16Array(pcm, 0, count);
|
|
37
|
+
for (let i = 0; i < count; i++)
|
|
38
|
+
out[i] = samples[i] / 0x8000;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const view = new DataView(pcm);
|
|
42
|
+
for (let i = 0; i < count; i++)
|
|
43
|
+
out[i] = view.getInt16(i * 2, true) / 0x8000;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Samples at `from` Hz resampled to `to` Hz, linearly: a 48 kHz microphone
|
|
49
|
+
* is not a rate the socket accepts (`RECORDING_RATES`), and speech loses
|
|
50
|
+
* nothing a transcriber needs by it.
|
|
51
|
+
*/
|
|
52
|
+
export function resample(samples, from, to) {
|
|
53
|
+
if (from === to || samples.length === 0)
|
|
54
|
+
return samples;
|
|
55
|
+
const length = Math.max(1, Math.round((samples.length * to) / from));
|
|
56
|
+
const out = new Float32Array(length);
|
|
57
|
+
const step = from / to;
|
|
58
|
+
const last = samples.length - 1;
|
|
59
|
+
for (let i = 0; i < length; i++) {
|
|
60
|
+
const at = i * step;
|
|
61
|
+
const left = Math.min(Math.floor(at), last);
|
|
62
|
+
const right = Math.min(left + 1, last);
|
|
63
|
+
const weight = at - left;
|
|
64
|
+
out[i] = samples[left] * (1 - weight) + samples[right] * weight;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Pieces of PCM as one buffer: the frames of a reply, to save or replay. */
|
|
69
|
+
export function concatPcm(pieces) {
|
|
70
|
+
const bytes = pieces.map((piece) => piece instanceof ArrayBuffer
|
|
71
|
+
? new Uint8Array(piece)
|
|
72
|
+
: new Uint8Array(piece.buffer, piece.byteOffset, piece.byteLength));
|
|
73
|
+
const out = new Uint8Array(bytes.reduce((n, piece) => n + piece.length, 0));
|
|
74
|
+
let offset = 0;
|
|
75
|
+
for (const piece of bytes) {
|
|
76
|
+
out.set(piece, offset);
|
|
77
|
+
offset += piece.length;
|
|
78
|
+
}
|
|
79
|
+
return out.buffer;
|
|
80
|
+
}
|
|
81
|
+
/** How long PCM lasts: its bytes (or a buffer of them) at `rate` Hz. */
|
|
82
|
+
export function pcmSeconds(pcm, rate, channels = 1) {
|
|
83
|
+
const bytes = typeof pcm === "number" ? pcm : pcm.byteLength;
|
|
84
|
+
return Math.floor(bytes / 2 / channels) / rate;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Samples as the wire's bytes, as an `ArrayBuffer` — what every runtime's
|
|
88
|
+
* `WebSocket.send` takes (`socket.ts`, `WebSocketLike`). A buffer as it is,
|
|
89
|
+
* since it already holds the wire's bytes; a typed array's own buffer on a
|
|
90
|
+
* little-endian device, never copied, when the array is the whole of an
|
|
91
|
+
* unshared one — what a recorder hands over. A view onto part of a buffer,
|
|
92
|
+
* or onto a shared one, is copied: one pass over its bytes.
|
|
93
|
+
*/
|
|
94
|
+
export function wireBytes(pcm) {
|
|
95
|
+
if (pcm instanceof ArrayBuffer)
|
|
96
|
+
return pcm;
|
|
97
|
+
if (LITTLE_ENDIAN) {
|
|
98
|
+
const { buffer, byteOffset, byteLength } = pcm;
|
|
99
|
+
return buffer instanceof ArrayBuffer &&
|
|
100
|
+
byteOffset === 0 &&
|
|
101
|
+
byteLength === buffer.byteLength
|
|
102
|
+
? buffer
|
|
103
|
+
: new Uint8Array(buffer, byteOffset, byteLength).slice().buffer;
|
|
104
|
+
}
|
|
105
|
+
const out = new ArrayBuffer(pcm.length * 2);
|
|
106
|
+
const view = new DataView(out);
|
|
107
|
+
for (let i = 0; i < pcm.length; i++)
|
|
108
|
+
view.setInt16(i * 2, pcm[i], true);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** The protocol, sent as the WebSocket subprotocol and echoed on `ready`. */
|
|
2
|
+
export declare const PROTOCOL = "ino.v1";
|
|
3
|
+
/** Every frame a client may send; the edge closes on any other (4009). */
|
|
4
|
+
export declare const CLIENT_VERBS: readonly ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping"];
|
|
5
|
+
export type ClientVerb = (typeof CLIENT_VERBS)[number];
|
|
6
|
+
/** Every event the edge sends. */
|
|
7
|
+
export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
|
|
8
|
+
export type ServerEventName = (typeof SERVER_EVENTS)[number];
|
|
9
|
+
/** The events that end a turn: exactly one of them ends every turn. */
|
|
10
|
+
export declare const TERMINAL_EVENTS: readonly ["turn.done", "interrupted"];
|
|
11
|
+
/** The edge's close codes by name: `CLOSE.auth_timeout === 4001`. */
|
|
12
|
+
export declare const CLOSE: {
|
|
13
|
+
readonly auth_timeout: 4001;
|
|
14
|
+
readonly auth_failed: 4002;
|
|
15
|
+
readonly expired: 4003;
|
|
16
|
+
readonly revoked: 4004;
|
|
17
|
+
readonly no_credits: 4005;
|
|
18
|
+
readonly rate_limited: 4006;
|
|
19
|
+
readonly idle: 4007;
|
|
20
|
+
readonly gone: 4008;
|
|
21
|
+
readonly protocol: 4009;
|
|
22
|
+
readonly unavailable: 4010;
|
|
23
|
+
readonly audio: 4011;
|
|
24
|
+
readonly internal: 4013;
|
|
25
|
+
readonly too_many_for_end_user: 4014;
|
|
26
|
+
};
|
|
27
|
+
export type CloseName = keyof typeof CLOSE;
|
|
28
|
+
/** The codes no reconnect with the same key can fix (the edge's `fatal`). */
|
|
29
|
+
export declare const FINAL_CLOSE_CODES: readonly number[];
|
|
30
|
+
export declare const AUTH_DEADLINE_S = 10;
|
|
31
|
+
export declare const IDLE_S = 300;
|
|
32
|
+
export declare const KEEPALIVE_S = 30;
|
|
33
|
+
export declare const MAX_AUDIO_BYTES = 10485760;
|
|
34
|
+
export declare const MAX_MESSAGE_CHARS = 32000;
|
|
35
|
+
export declare const HEARINGS_IN_FLIGHT = 3;
|
|
36
|
+
export declare const RECORDING_RATES: readonly [8000, 16000, 22050, 24000, 44100];
|
|
37
|
+
export declare const STT_MODELS: readonly ["scribe_v2"];
|
|
38
|
+
export declare const LANGUAGE_CHARS: readonly [2, 8];
|
|
39
|
+
export declare const MODEL_MAX_CHARS = 64;
|
|
40
|
+
export declare const RECORDING_ID_MAX_CHARS = 64;
|
|
41
|
+
export declare const AUDIO_CODEC = "pcm_s16le";
|
|
42
|
+
export declare const AUDIO_CHANNELS = 1;
|
|
43
|
+
export declare const DEFAULT_SAMPLE_RATE = 24000;
|
|
44
|
+
export declare const MAX_SOCKETS_PER_END_USER = 3;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Generated by `scripts/protocol.mjs` from the edge's vocabulary
|
|
2
|
+
// (`apps/edge/src/wire/vocabulary.json`). Never edit it: run
|
|
3
|
+
// `npm run protocol -w sdks/client`, which `scripts/sdk-protocol.mjs`
|
|
4
|
+
// fails until it is.
|
|
5
|
+
/** The protocol, sent as the WebSocket subprotocol and echoed on `ready`. */
|
|
6
|
+
export const PROTOCOL = "ino.v1";
|
|
7
|
+
/** Every frame a client may send; the edge closes on any other (4009). */
|
|
8
|
+
export const CLIENT_VERBS = ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping"];
|
|
9
|
+
/** Every event the edge sends. */
|
|
10
|
+
export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
|
|
11
|
+
/** The events that end a turn: exactly one of them ends every turn. */
|
|
12
|
+
export const TERMINAL_EVENTS = ["turn.done", "interrupted"];
|
|
13
|
+
/** The edge's close codes by name: `CLOSE.auth_timeout === 4001`. */
|
|
14
|
+
export const CLOSE = {
|
|
15
|
+
auth_timeout: 4001,
|
|
16
|
+
auth_failed: 4002,
|
|
17
|
+
expired: 4003,
|
|
18
|
+
revoked: 4004,
|
|
19
|
+
no_credits: 4005,
|
|
20
|
+
rate_limited: 4006,
|
|
21
|
+
idle: 4007,
|
|
22
|
+
gone: 4008,
|
|
23
|
+
protocol: 4009,
|
|
24
|
+
unavailable: 4010,
|
|
25
|
+
audio: 4011,
|
|
26
|
+
internal: 4013,
|
|
27
|
+
too_many_for_end_user: 4014,
|
|
28
|
+
};
|
|
29
|
+
/** The codes no reconnect with the same key can fix (the edge's `fatal`). */
|
|
30
|
+
export const FINAL_CLOSE_CODES = [4002, 4004, 4008, 4010];
|
|
31
|
+
// The limits, as the edge holds them.
|
|
32
|
+
export const AUTH_DEADLINE_S = 10;
|
|
33
|
+
export const IDLE_S = 300;
|
|
34
|
+
export const KEEPALIVE_S = 30;
|
|
35
|
+
export const MAX_AUDIO_BYTES = 10485760;
|
|
36
|
+
export const MAX_MESSAGE_CHARS = 32000;
|
|
37
|
+
export const HEARINGS_IN_FLIGHT = 3;
|
|
38
|
+
export const RECORDING_RATES = [8000, 16000, 22050, 24000, 44100];
|
|
39
|
+
export const STT_MODELS = ["scribe_v2"];
|
|
40
|
+
export const LANGUAGE_CHARS = [2, 8];
|
|
41
|
+
export const MODEL_MAX_CHARS = 64;
|
|
42
|
+
export const RECORDING_ID_MAX_CHARS = 64;
|
|
43
|
+
export const AUDIO_CODEC = "pcm_s16le";
|
|
44
|
+
export const AUDIO_CHANNELS = 1;
|
|
45
|
+
export const DEFAULT_SAMPLE_RATE = 24000;
|
|
46
|
+
export const MAX_SOCKETS_PER_END_USER = 3;
|
package/dist/socket.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One socket of `/v1/agent/stream`, and the policy for what follows its
|
|
3
|
+
* close — lifted from the console's `src/lib/agent-socket.ts`, which was
|
|
4
|
+
* written to be (left-overs #53). `conversation.ts` runs a conversation on
|
|
5
|
+
* as many of these as it takes.
|
|
6
|
+
*
|
|
7
|
+
* Three things about the protocol are worth knowing before reading:
|
|
8
|
+
*
|
|
9
|
+
* - **Text frames are JSON, binary frames are raw PCM.** A binary frame
|
|
10
|
+
* belongs to the `audio.start` before it, and the edge keeps one audio
|
|
11
|
+
* window open at a time — so there is no correlation to do beyond
|
|
12
|
+
* remembering which window is open.
|
|
13
|
+
* - **The credential travels in the first frame**, because a browser
|
|
14
|
+
* cannot put an `Authorization` header on a `WebSocket`, and a query
|
|
15
|
+
* parameter would put the key in every proxy log on the way.
|
|
16
|
+
* - **A close code says what to do next** (`reconnectAfter`).
|
|
17
|
+
*
|
|
18
|
+
* **What every runtime has, and nothing more**: the `on*` properties of a
|
|
19
|
+
* `WebSocket` (React Native's, a browser's, Node's and the `ws` package's
|
|
20
|
+
* all have them), `binaryType = "arraybuffer"` (React Native's default is
|
|
21
|
+
* a Blob), and text frames as strings — no `TextDecoder`.
|
|
22
|
+
*/
|
|
23
|
+
import type { ServerFrame } from "./events.js";
|
|
24
|
+
/**
|
|
25
|
+
* A handler the SDK sets on a socket, its event checked as a method's
|
|
26
|
+
* parameter is — both ways — so every runtime's own handler type fits: a
|
|
27
|
+
* browser's `onmessage` takes a whole `MessageEvent`, which a plain
|
|
28
|
+
* function property would refuse to stand in for `{ data }` (the docs'
|
|
29
|
+
* `createClient({ WebSocket })` failed to compile until this,
|
|
30
|
+
* `test/docs.test.ts`).
|
|
31
|
+
*/
|
|
32
|
+
type Handler<Event> = {
|
|
33
|
+
bivariance(event: Event): void;
|
|
34
|
+
}["bivariance"];
|
|
35
|
+
/** What `new WebSocket(url, protocol)` makes, as far as the SDK uses it. */
|
|
36
|
+
export interface WebSocketLike {
|
|
37
|
+
readonly readyState: number;
|
|
38
|
+
binaryType: string;
|
|
39
|
+
onopen: ((event: never) => void) | null;
|
|
40
|
+
onmessage: Handler<{
|
|
41
|
+
data: unknown;
|
|
42
|
+
}> | null;
|
|
43
|
+
onclose: Handler<{
|
|
44
|
+
code: number;
|
|
45
|
+
reason: string;
|
|
46
|
+
}> | null;
|
|
47
|
+
onerror: ((event: never) => void) | null;
|
|
48
|
+
send(data: string | ArrayBuffer): void;
|
|
49
|
+
close(code?: number, reason?: string): void;
|
|
50
|
+
}
|
|
51
|
+
/** A `WebSocket` class: the runtime's own by default, or `ws`, or a fake. */
|
|
52
|
+
export type WebSocketConstructor = new (url: string, protocols?: string | string[]) => WebSocketLike;
|
|
53
|
+
/** Raw PCM for the open audio window, with what its `audio.start` said. */
|
|
54
|
+
export interface AudioFrame {
|
|
55
|
+
/** 16-bit little-endian PCM, as it came off the wire: never copied. */
|
|
56
|
+
pcm: ArrayBuffer;
|
|
57
|
+
chunk: number;
|
|
58
|
+
turn: string;
|
|
59
|
+
sample_rate: number;
|
|
60
|
+
channels: number;
|
|
61
|
+
}
|
|
62
|
+
/** A frame the SDK could not use, and so dropped: a bug on one side. Never the frame itself, which may be someone's words. */
|
|
63
|
+
export type Warning =
|
|
64
|
+
/** A frame's `seq` was not the last one plus one. */
|
|
65
|
+
{
|
|
66
|
+
type: "gap";
|
|
67
|
+
expected: number;
|
|
68
|
+
got: number;
|
|
69
|
+
}
|
|
70
|
+
/** Text that is not a server event, or audio with no window open. */
|
|
71
|
+
| {
|
|
72
|
+
type: "unreadable";
|
|
73
|
+
what: "text" | "audio";
|
|
74
|
+
}
|
|
75
|
+
/** `sendAudio` past the 10 MB one recording may carry: not sent. */
|
|
76
|
+
| {
|
|
77
|
+
type: "audio_limit";
|
|
78
|
+
bytes: number;
|
|
79
|
+
};
|
|
80
|
+
export interface DialOptions {
|
|
81
|
+
url: string;
|
|
82
|
+
key: string;
|
|
83
|
+
sessionId: string;
|
|
84
|
+
WebSocket: WebSocketConstructor;
|
|
85
|
+
onFrame(frame: ServerFrame): void;
|
|
86
|
+
onAudio(frame: AudioFrame): void;
|
|
87
|
+
onClose(code: number, reason: string): void;
|
|
88
|
+
onWarning(warning: Warning): void;
|
|
89
|
+
}
|
|
90
|
+
/** One socket, from `dial`. */
|
|
91
|
+
export interface Dialled {
|
|
92
|
+
/** A client frame, as JSON; dropped unless the socket is open. */
|
|
93
|
+
send(frame: {
|
|
94
|
+
type: string;
|
|
95
|
+
} & Record<string, unknown>): boolean;
|
|
96
|
+
/** PCM, straight through; dropped unless the socket is open. */
|
|
97
|
+
sendBinary(data: ArrayBuffer): boolean;
|
|
98
|
+
/** 1000: the 4000 range is the edge's vocabulary for why *it* hung up. */
|
|
99
|
+
close(): void;
|
|
100
|
+
/** Says nothing more to the handlers: for a socket being replaced. */
|
|
101
|
+
detach(): void;
|
|
102
|
+
readonly ready: boolean;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Open a socket, authenticate in the first frame, and dispatch what
|
|
106
|
+
* arrives. Answers at once, not at `ready`: one way to learn a failure
|
|
107
|
+
* (`onClose`), not a promise and a close both.
|
|
108
|
+
*/
|
|
109
|
+
export declare function dial(options: DialOptions): Dialled;
|
|
110
|
+
/**
|
|
111
|
+
* One text frame, or `null` for anything that is not a server event: JSON
|
|
112
|
+
* that is `null`, a number or an array once threw inside the listener,
|
|
113
|
+
* which silenced the conversation with no close and no error.
|
|
114
|
+
*/
|
|
115
|
+
export declare function parseFrame(raw: string): ServerFrame | null;
|
|
116
|
+
/**
|
|
117
|
+
* The socket's URL for one session. **`?session_id=` is how the edge
|
|
118
|
+
* routes**: its front door picks the conversation before a frame is read,
|
|
119
|
+
* so the session is in the URL as well as in `auth`, and the two must be
|
|
120
|
+
* equal. An id is not a credential.
|
|
121
|
+
*/
|
|
122
|
+
export declare function socketUrl(base: string, sessionId: string): string;
|
|
123
|
+
/** What to do after a close. */
|
|
124
|
+
export type Reconnect =
|
|
125
|
+
/** Dial again after `delayMs`. */
|
|
126
|
+
{
|
|
127
|
+
kind: "retry";
|
|
128
|
+
delayMs: number;
|
|
129
|
+
}
|
|
130
|
+
/** Mint a new key, then dial — once, until a turn ends on the new key. */
|
|
131
|
+
| {
|
|
132
|
+
kind: "new-key";
|
|
133
|
+
}
|
|
134
|
+
/** The edge hung up on a quiet connection: dial on the next send. */
|
|
135
|
+
| {
|
|
136
|
+
kind: "dormant";
|
|
137
|
+
}
|
|
138
|
+
/** Nothing a reconnect can fix, or the tries ran out. */
|
|
139
|
+
| {
|
|
140
|
+
kind: "final";
|
|
141
|
+
};
|
|
142
|
+
/** Reconnects in a row before the SDK stops on its own; `ready` resets the count. */
|
|
143
|
+
export declare const MAX_RECONNECTS = 5;
|
|
144
|
+
/**
|
|
145
|
+
* The close policy, from the code and how many reconnects in a row came
|
|
146
|
+
* before it — pure, so its table is tested whole:
|
|
147
|
+
*
|
|
148
|
+
* - **1000 (ours), 4008, 4010: final.** 4002 and 4004 are final for the
|
|
149
|
+
* key that was refused, not for the conversation.
|
|
150
|
+
* - **4002–4005: a new key**, which the caller takes once and not again
|
|
151
|
+
* until a turn ends on it — the console's old 4005 loop (`ready` resets
|
|
152
|
+
* the count, so five tries were an unbounded loop of mints) cannot recur.
|
|
153
|
+
* - **4007: dormant.** Reconnecting at once would be hung up on again five
|
|
154
|
+
* minutes later.
|
|
155
|
+
* - **4006, 4014: 5 s doubling** — a window, or another tab's socket, is
|
|
156
|
+
* the cure, and both take longer than half a second.
|
|
157
|
+
* - **Anything else** — 4001, 4009, 4011, 4013, 1006, a dead network, no
|
|
158
|
+
* `ready`, the watchdog — **0.5 s doubling**. Both capped at 30 s, then
|
|
159
|
+
* ±20 % at random, so an edge deploy does not bring every socket back in
|
|
160
|
+
* the same millisecond; five in a row is final.
|
|
161
|
+
*/
|
|
162
|
+
export declare function reconnectAfter(code: number, attempt: number, random?: () => number): Reconnect;
|
|
163
|
+
/** Whether no reconnect with the same key can fix this close. */
|
|
164
|
+
export declare const isFinalClose: (code: number) => boolean;
|
|
165
|
+
/** A close code's name: the edge's (`auth_failed`), WebSocket's (`abnormal`), or `other`. */
|
|
166
|
+
export declare function closeName(code: number): string;
|
|
167
|
+
export {};
|
package/dist/socket.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { CLOSE, FINAL_CLOSE_CODES, PROTOCOL } from "./protocol.gen.js";
|
|
2
|
+
/** `WebSocket.OPEN`, which every implementation numbers the same. */
|
|
3
|
+
const OPEN = 1;
|
|
4
|
+
/**
|
|
5
|
+
* Open a socket, authenticate in the first frame, and dispatch what
|
|
6
|
+
* arrives. Answers at once, not at `ready`: one way to learn a failure
|
|
7
|
+
* (`onClose`), not a promise and a close both.
|
|
8
|
+
*/
|
|
9
|
+
export function dial(options) {
|
|
10
|
+
const socket = new options.WebSocket(options.url, PROTOCOL);
|
|
11
|
+
socket.binaryType = "arraybuffer";
|
|
12
|
+
// The window a binary frame belongs to: the edge opens one at a time.
|
|
13
|
+
let window = null;
|
|
14
|
+
let ready = false;
|
|
15
|
+
let detached = false;
|
|
16
|
+
let lastSeq = null;
|
|
17
|
+
const send = (data) => {
|
|
18
|
+
if (socket.readyState !== OPEN)
|
|
19
|
+
return false;
|
|
20
|
+
socket.send(data);
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
socket.onopen = () => {
|
|
24
|
+
// Two fields, and the edge refuses any third by name.
|
|
25
|
+
send(JSON.stringify({
|
|
26
|
+
type: "auth",
|
|
27
|
+
key: options.key,
|
|
28
|
+
session_id: options.sessionId,
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
31
|
+
socket.onmessage = ({ data }) => {
|
|
32
|
+
if (detached)
|
|
33
|
+
return;
|
|
34
|
+
if (typeof data !== "string") {
|
|
35
|
+
// Audio outside a window is the edge's bug; dropping it beats playing
|
|
36
|
+
// it against the wrong sentence.
|
|
37
|
+
if (window && isArrayBuffer(data)) {
|
|
38
|
+
options.onAudio({
|
|
39
|
+
pcm: data,
|
|
40
|
+
chunk: window.chunk,
|
|
41
|
+
turn: window.turn,
|
|
42
|
+
sample_rate: window.sample_rate,
|
|
43
|
+
channels: window.channels,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
options.onWarning({ type: "unreadable", what: "audio" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const frame = parseFrame(data);
|
|
51
|
+
if (!frame) {
|
|
52
|
+
options.onWarning({ type: "unreadable", what: "text" });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (typeof frame.seq === "number") {
|
|
56
|
+
if (lastSeq !== null && frame.seq !== lastSeq + 1) {
|
|
57
|
+
options.onWarning({
|
|
58
|
+
type: "gap",
|
|
59
|
+
expected: lastSeq + 1,
|
|
60
|
+
got: frame.seq,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
lastSeq = frame.seq;
|
|
64
|
+
}
|
|
65
|
+
if (frame.type === "audio.start")
|
|
66
|
+
window = frame;
|
|
67
|
+
else if (frame.type === "audio.end")
|
|
68
|
+
window = null;
|
|
69
|
+
else if (frame.type === "ready")
|
|
70
|
+
ready = true;
|
|
71
|
+
options.onFrame(frame);
|
|
72
|
+
};
|
|
73
|
+
socket.onclose = ({ code, reason }) => {
|
|
74
|
+
ready = false;
|
|
75
|
+
if (!detached)
|
|
76
|
+
options.onClose(code, reason);
|
|
77
|
+
};
|
|
78
|
+
// swallowed: a close always follows an error, and carries what is known.
|
|
79
|
+
socket.onerror = () => { };
|
|
80
|
+
return {
|
|
81
|
+
send: (frame) => send(JSON.stringify(frame)),
|
|
82
|
+
sendBinary: send,
|
|
83
|
+
close() {
|
|
84
|
+
if (socket.readyState <= OPEN)
|
|
85
|
+
socket.close(1000, "done");
|
|
86
|
+
},
|
|
87
|
+
detach() {
|
|
88
|
+
detached = true;
|
|
89
|
+
ready = false;
|
|
90
|
+
},
|
|
91
|
+
get ready() {
|
|
92
|
+
return ready;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** An `ArrayBuffer`, from this realm or another (a test's, a worker's). */
|
|
97
|
+
const isArrayBuffer = (data) => data instanceof ArrayBuffer ||
|
|
98
|
+
Object.prototype.toString.call(data) === "[object ArrayBuffer]";
|
|
99
|
+
/**
|
|
100
|
+
* One text frame, or `null` for anything that is not a server event: JSON
|
|
101
|
+
* that is `null`, a number or an array once threw inside the listener,
|
|
102
|
+
* which silenced the conversation with no close and no error.
|
|
103
|
+
*/
|
|
104
|
+
export function parseFrame(raw) {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// swallowed: not a server event — the socket warns `unreadable`.
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return typeof parsed === "object" &&
|
|
114
|
+
parsed !== null &&
|
|
115
|
+
typeof parsed.type === "string"
|
|
116
|
+
? parsed
|
|
117
|
+
: null;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The socket's URL for one session. **`?session_id=` is how the edge
|
|
121
|
+
* routes**: its front door picks the conversation before a frame is read,
|
|
122
|
+
* so the session is in the URL as well as in `auth`, and the two must be
|
|
123
|
+
* equal. An id is not a credential.
|
|
124
|
+
*/
|
|
125
|
+
export function socketUrl(base, sessionId) {
|
|
126
|
+
return `${base}${base.includes("?") ? "&" : "?"}session_id=${encodeURIComponent(sessionId)}`;
|
|
127
|
+
}
|
|
128
|
+
/** Reconnects in a row before the SDK stops on its own; `ready` resets the count. */
|
|
129
|
+
export const MAX_RECONNECTS = 5;
|
|
130
|
+
/** The codes a new key may cure: the key failed, lapsed, was revoked, or its org ran dry. */
|
|
131
|
+
const NEW_KEY = [
|
|
132
|
+
CLOSE.auth_failed,
|
|
133
|
+
CLOSE.expired,
|
|
134
|
+
CLOSE.revoked,
|
|
135
|
+
CLOSE.no_credits,
|
|
136
|
+
];
|
|
137
|
+
/**
|
|
138
|
+
* The close policy, from the code and how many reconnects in a row came
|
|
139
|
+
* before it — pure, so its table is tested whole:
|
|
140
|
+
*
|
|
141
|
+
* - **1000 (ours), 4008, 4010: final.** 4002 and 4004 are final for the
|
|
142
|
+
* key that was refused, not for the conversation.
|
|
143
|
+
* - **4002–4005: a new key**, which the caller takes once and not again
|
|
144
|
+
* until a turn ends on it — the console's old 4005 loop (`ready` resets
|
|
145
|
+
* the count, so five tries were an unbounded loop of mints) cannot recur.
|
|
146
|
+
* - **4007: dormant.** Reconnecting at once would be hung up on again five
|
|
147
|
+
* minutes later.
|
|
148
|
+
* - **4006, 4014: 5 s doubling** — a window, or another tab's socket, is
|
|
149
|
+
* the cure, and both take longer than half a second.
|
|
150
|
+
* - **Anything else** — 4001, 4009, 4011, 4013, 1006, a dead network, no
|
|
151
|
+
* `ready`, the watchdog — **0.5 s doubling**. Both capped at 30 s, then
|
|
152
|
+
* ±20 % at random, so an edge deploy does not bring every socket back in
|
|
153
|
+
* the same millisecond; five in a row is final.
|
|
154
|
+
*/
|
|
155
|
+
export function reconnectAfter(code, attempt, random = Math.random) {
|
|
156
|
+
if (code === 1000 || code === CLOSE.gone || code === CLOSE.unavailable) {
|
|
157
|
+
return { kind: "final" };
|
|
158
|
+
}
|
|
159
|
+
if (NEW_KEY.includes(code))
|
|
160
|
+
return { kind: "new-key" };
|
|
161
|
+
if (code === CLOSE.idle)
|
|
162
|
+
return { kind: "dormant" };
|
|
163
|
+
if (attempt >= MAX_RECONNECTS)
|
|
164
|
+
return { kind: "final" };
|
|
165
|
+
const slow = code === CLOSE.rate_limited || code === CLOSE.too_many_for_end_user;
|
|
166
|
+
const delay = Math.min((slow ? 5000 : 500) * 2 ** attempt, 30000);
|
|
167
|
+
return { kind: "retry", delayMs: Math.round(delay * (0.8 + 0.4 * random())) };
|
|
168
|
+
}
|
|
169
|
+
/** Whether no reconnect with the same key can fix this close. */
|
|
170
|
+
export const isFinalClose = (code) => code === 1000 || FINAL_CLOSE_CODES.includes(code);
|
|
171
|
+
const NAMES = {
|
|
172
|
+
1000: "normal",
|
|
173
|
+
1001: "going_away",
|
|
174
|
+
1005: "no_status",
|
|
175
|
+
1006: "abnormal",
|
|
176
|
+
1011: "server_error",
|
|
177
|
+
};
|
|
178
|
+
/** A close code's name: the edge's (`auth_failed`), WebSocket's (`abnormal`), or `other`. */
|
|
179
|
+
export function closeName(code) {
|
|
180
|
+
for (const [name, value] of Object.entries(CLOSE)) {
|
|
181
|
+
if (value === code)
|
|
182
|
+
return name;
|
|
183
|
+
}
|
|
184
|
+
return NAMES[code] ?? "other";
|
|
185
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The end user's ephemeral key: asked of the app's `getToken`, kept, and
|
|
3
|
+
* replaced when the platform refuses it.
|
|
4
|
+
*
|
|
5
|
+
* - **Single-flight.** Every mint deducts the key's credit up front, so N
|
|
6
|
+
* calls refused at once must not mint N keys: one mint is in flight at a
|
|
7
|
+
* time, and every caller waits on it.
|
|
8
|
+
* - **A refusal replaces only the key it refused**: a call refused with a
|
|
9
|
+
* key already replaced takes the new one, mints nothing.
|
|
10
|
+
* - **The app's policy decides**: `getToken` is told why it is asked —
|
|
11
|
+
* `initial`, or `refused` with the HTTP status or close code — and may
|
|
12
|
+
* answer from a budget of its own, or throw to stop.
|
|
13
|
+
*/
|
|
14
|
+
/** Why the SDK asks for a key. */
|
|
15
|
+
export interface TokenRequest {
|
|
16
|
+
/** `initial`: none yet, or `clearToken()` dropped it. `refused`: the platform refused the last one. */
|
|
17
|
+
reason: "initial" | "refused";
|
|
18
|
+
/** The HTTP status that refused it: 401. */
|
|
19
|
+
status?: number;
|
|
20
|
+
/** The socket's close code (4002–4005), or the API's error `type`. */
|
|
21
|
+
code?: number | string;
|
|
22
|
+
}
|
|
23
|
+
/** The app's way to an ephemeral key: usually a call to its own server, which mints one. */
|
|
24
|
+
export type GetToken = (request: TokenRequest) => string | Promise<string>;
|
|
25
|
+
export declare class Tokens {
|
|
26
|
+
private readonly get;
|
|
27
|
+
private token;
|
|
28
|
+
private pending;
|
|
29
|
+
/** Moves on `clear()`, so a mint already out is not kept after a sign-out. */
|
|
30
|
+
private epoch;
|
|
31
|
+
constructor(get: GetToken);
|
|
32
|
+
/** The key: the one kept, or one minted. */
|
|
33
|
+
current(): Promise<string>;
|
|
34
|
+
/** A key to use instead of `refused`, which the platform refused. */
|
|
35
|
+
replace(refused: string, why?: Omit<TokenRequest, "reason">): Promise<string>;
|
|
36
|
+
/** Forget the key: sign-out. The next call asks `getToken` again. */
|
|
37
|
+
clear(): void;
|
|
38
|
+
private mint;
|
|
39
|
+
}
|