@lumavox/client 0.0.0-stage → 0.2.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/dist/token.js ADDED
@@ -0,0 +1,63 @@
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
+ export class Tokens {
15
+ constructor(get) {
16
+ this.token = null;
17
+ this.pending = null;
18
+ /** Moves on `clear()`, so a mint already out is not kept after a sign-out. */
19
+ this.epoch = 0;
20
+ this.get = get;
21
+ }
22
+ /** The key: the one kept, or one minted. */
23
+ current() {
24
+ return this.token !== null
25
+ ? Promise.resolve(this.token)
26
+ : this.mint({ reason: "initial" });
27
+ }
28
+ /** A key to use instead of `refused`, which the platform refused. */
29
+ replace(refused, why = {}) {
30
+ if (this.token !== null && this.token !== refused) {
31
+ return Promise.resolve(this.token);
32
+ }
33
+ this.token = null;
34
+ return this.mint({ reason: "refused", ...why });
35
+ }
36
+ /** Forget the key: sign-out. The next call asks `getToken` again. */
37
+ clear() {
38
+ this.token = null;
39
+ this.pending = null;
40
+ this.epoch += 1;
41
+ }
42
+ mint(request) {
43
+ if (this.pending)
44
+ return this.pending;
45
+ const epoch = this.epoch;
46
+ const pending = Promise.resolve()
47
+ .then(() => this.get(request))
48
+ .then((token) => {
49
+ if (typeof token !== "string" || !token) {
50
+ throw new TypeError("getToken must answer an ephemeral key.");
51
+ }
52
+ if (epoch === this.epoch)
53
+ this.token = token;
54
+ return token;
55
+ })
56
+ .finally(() => {
57
+ if (this.pending === pending)
58
+ this.pending = null;
59
+ });
60
+ this.pending = pending;
61
+ return pending;
62
+ }
63
+ }
@@ -0,0 +1,83 @@
1
+ /** What `fetch` the client calls: the global one, or the caller's. */
2
+ export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
3
+ /** Options any call takes, in its last argument. */
4
+ export interface RequestOptions {
5
+ /** Per attempt; the client's `timeoutMs` (30 s) by default. */
6
+ timeoutMs?: number;
7
+ /** Aborts the call, its retries and their waits. */
8
+ signal?: AbortSignal;
9
+ /** The client's `maxRetries` (2) by default; `0` sends once. */
10
+ maxRetries?: number;
11
+ /** Sent as `X-Request-Id` on every attempt; one is made when absent. */
12
+ requestId?: string;
13
+ }
14
+ export interface TransportConfig {
15
+ apiKey: string;
16
+ baseUrl: string;
17
+ timeoutMs: number;
18
+ maxRetries: number;
19
+ fetch: Fetch;
20
+ }
21
+ export type Query = Record<string, string | number | boolean | null | undefined>;
22
+ export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
23
+ /** One call, as a method describes it. */
24
+ export interface Call {
25
+ method: Method;
26
+ /** With its parameters already encoded (`segment`); or a whole URL. */
27
+ path: string;
28
+ query?: Query;
29
+ /** A JSON body. */
30
+ body?: unknown;
31
+ /** A body sent as it is, with `type` as its `Content-Type`. */
32
+ raw?: BodyInit;
33
+ type?: string;
34
+ headers?: Record<string, string>;
35
+ /** The answer's bytes, or its body unread; JSON by default. */
36
+ read?: "bytes" | "stream";
37
+ /** No credential: the open routes, and object storage's signed URLs. */
38
+ open?: boolean;
39
+ /** A POST that may be sent twice with the same effect. */
40
+ idempotent?: boolean;
41
+ /** Another bearer for this call: an end user's ephemeral key. */
42
+ token?: string;
43
+ }
44
+ export interface Answer<T> {
45
+ data: T;
46
+ status: number;
47
+ headers: Headers;
48
+ requestId: string;
49
+ }
50
+ /** The longest `Retry-After` slept through; past it the 429 is thrown. */
51
+ export declare const MAX_RETRY_AFTER_S = 60;
52
+ /** A path parameter or a query's value, encoded: an `external_id` may hold `/` or `?`. */
53
+ export declare const segment: (value: string | number | boolean) => string;
54
+ /**
55
+ * A request id of the API's own shape: 16 hex. From `Math.random`, not
56
+ * `crypto`, which React Native lacks (this file is the client SDK's too):
57
+ * an id names a call in the logs and is no secret.
58
+ */
59
+ export declare const newRequestId: () => string;
60
+ /**
61
+ * How long to wait before retry `attempt` (1, 2, …): the doubling backoff
62
+ * less up to a quarter, or `Retry-After`'s seconds when the API sent one.
63
+ */
64
+ export declare function retryDelayMs(attempt: number, retryAfter: number | null, random?: () => number): number;
65
+ /** Waits `ms`, or rejects with the signal's reason when it aborts first. */
66
+ export declare function sleep(ms: number, signal: AbortSignal | undefined): Promise<void>;
67
+ /** A 2xx body: JSON, or nothing. Anything else is not the API answering. */
68
+ export declare function parsed<T>(text: string, status: number, requestId: string): T;
69
+ /** `promise`'s value, or `null` when it is a 404: what is not there is done. */
70
+ export declare const orNull: <T>(promise: Promise<T>) => Promise<T | null>;
71
+ export declare class Transport {
72
+ private readonly config;
73
+ constructor(config: TransportConfig);
74
+ /** A JSON call's answer: `data` is a GET's query and any other's body. */
75
+ send<T>(method: Method, path: string, options?: RequestOptions, data?: unknown): Promise<T>;
76
+ request<T>(call: Call, options?: RequestOptions): Promise<Answer<T>>;
77
+ private url;
78
+ /**
79
+ * One send and its body read, under this attempt's timer — which stops
80
+ * at the headers of a stream the caller reads (and of nothing else).
81
+ */
82
+ private attempt;
83
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * One HTTP call to the API, with everything every method shares: the
3
+ * credential, the request id, the timeout, the retries and the errors.
4
+ *
5
+ * - **One request id per call** (16 hex, as the API mints its own), sent
6
+ * as `X-Request-Id` and **reused on every retry**: the API keeps a valid
7
+ * one (`apps/api/src/http/request-id.ts`), so every attempt of a call
8
+ * shares one id in Workers Logs and in the error the caller sees.
9
+ * - **The timeout is per attempt**, a timer joined with the caller's
10
+ * `signal`, and it covers reading the body — **except a stream's**,
11
+ * whose timer stops at the headers: a transcript streamed for minutes
12
+ * is not a call that timed out. The caller's abort is theirs: it is
13
+ * thrown as their reason and never retried, and it still ends a stream.
14
+ * - **Not every body is JSON**: speech is bytes or a stream, `/v1/stt`
15
+ * takes the audio raw, and an upload's `PUT` goes to object storage's
16
+ * own URL with no credential (`open`) — the same attempts, retries and
17
+ * errors. A body that is a stream is sent once: it cannot be read twice.
18
+ * - **What is retried is what cannot have done anything twice.** GET, PUT,
19
+ * PATCH and DELETE: a connection that failed, a timeout, 429, 502, 503,
20
+ * 504. A POST: only a 429, which the API answers at admission before any
21
+ * work — unless the method says the POST is idempotent
22
+ * (`POST /v1/end-users` with an `external_id`). Never a 500: whatever
23
+ * failed may have half-happened.
24
+ * - **The wait** doubles from 0.5 s to 8 s, less up to a quarter at random
25
+ * so callers refused together do not return together; `Retry-After`
26
+ * wins, up to a minute. A longer one is thrown at once as a
27
+ * `RateLimitError` with its `retryAfter`: a call that sleeps for an hour
28
+ * is not one a caller can reason about.
29
+ * - **The client SDK runs this file too** (`sdks/client/src/transport.ts`,
30
+ * a copy its `test/copies.test.ts` holds identical), on React Native's
31
+ * Hermes as well: so no `crypto`, `URLSearchParams`, `AbortSignal.any`
32
+ * or global `ReadableStream` taken for granted, each of which it lacks.
33
+ */
34
+ import { ConnectionError, errorFromResponse, NotFoundError, PlatformError, TimeoutError, } from "./errors.js";
35
+ const RETRIED_STATUS = new Set([429, 502, 503, 504]);
36
+ const BASE_DELAY_MS = 500;
37
+ const MAX_DELAY_MS = 8000;
38
+ /** The longest `Retry-After` slept through; past it the 429 is thrown. */
39
+ export const MAX_RETRY_AFTER_S = 60;
40
+ /** A path parameter or a query's value, encoded: an `external_id` may hold `/` or `?`. */
41
+ export const segment = (value) => encodeURIComponent(value);
42
+ /**
43
+ * A request id of the API's own shape: 16 hex. From `Math.random`, not
44
+ * `crypto`, which React Native lacks (this file is the client SDK's too):
45
+ * an id names a call in the logs and is no secret.
46
+ */
47
+ export const newRequestId = () => "x".repeat(16).replace(/x/g, () => ((Math.random() * 16) | 0).toString(16));
48
+ /**
49
+ * How long to wait before retry `attempt` (1, 2, …): the doubling backoff
50
+ * less up to a quarter, or `Retry-After`'s seconds when the API sent one.
51
+ */
52
+ export function retryDelayMs(attempt, retryAfter, random = Math.random) {
53
+ if (retryAfter !== null)
54
+ return Math.ceil(retryAfter * 1000);
55
+ const full = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
56
+ return Math.round(full * (1 - 0.25 * random()));
57
+ }
58
+ /** Waits `ms`, or rejects with the signal's reason when it aborts first. */
59
+ export function sleep(ms, signal) {
60
+ if (ms <= 0)
61
+ return Promise.resolve();
62
+ return new Promise((resolve, reject) => {
63
+ if (signal?.aborted)
64
+ return reject(signal.reason);
65
+ const done = () => {
66
+ signal?.removeEventListener("abort", stop);
67
+ resolve();
68
+ };
69
+ const timer = setTimeout(done, ms);
70
+ const stop = () => {
71
+ clearTimeout(timer);
72
+ reject(signal.reason);
73
+ };
74
+ signal?.addEventListener("abort", stop, { once: true });
75
+ });
76
+ }
77
+ /** A 2xx body: JSON, or nothing. Anything else is not the API answering. */
78
+ export function parsed(text, status, requestId) {
79
+ if (!text)
80
+ return undefined;
81
+ try {
82
+ return JSON.parse(text);
83
+ }
84
+ catch (cause) {
85
+ throw new PlatformError({
86
+ status,
87
+ code: "invalid_response",
88
+ message: `HTTP ${status} with a body that is not JSON.`,
89
+ requestId,
90
+ cause,
91
+ });
92
+ }
93
+ }
94
+ /** `promise`'s value, or `null` when it is a 404: what is not there is done. */
95
+ export const orNull = (promise) => promise.catch((error) => {
96
+ if (error instanceof NotFoundError)
97
+ return null;
98
+ throw error;
99
+ });
100
+ export class Transport {
101
+ constructor(config) {
102
+ this.config = config;
103
+ }
104
+ /** A JSON call's answer: `data` is a GET's query and any other's body. */
105
+ send(method, path, options, data) {
106
+ return this.request(method === "GET"
107
+ ? { method, path, query: data }
108
+ : { method, path, body: data }, options).then((answer) => answer.data);
109
+ }
110
+ async request(call, options = {}) {
111
+ const requestId = options.requestId ?? newRequestId();
112
+ // A stream by its shape: not every runtime has a global `ReadableStream`.
113
+ const once = "getReader" in Object(call.raw);
114
+ const maxRetries = once
115
+ ? 0
116
+ : (options.maxRetries ?? this.config.maxRetries);
117
+ const retriesAll = call.method !== "POST" || call.idempotent === true;
118
+ const url = this.url(call);
119
+ const headers = {
120
+ Accept: call.read ? "*/*" : "application/json",
121
+ "X-Request-Id": requestId,
122
+ ...call.headers,
123
+ };
124
+ if (!call.open) {
125
+ headers.Authorization = `Bearer ${call.token ?? this.config.apiKey}`;
126
+ }
127
+ let body = call.raw;
128
+ if (call.type)
129
+ headers["Content-Type"] = call.type;
130
+ if (call.body !== undefined) {
131
+ headers["Content-Type"] = "application/json";
132
+ body = JSON.stringify(call.body);
133
+ }
134
+ const init = { method: call.method, headers, body };
135
+ // Node's fetch sends a stream only when told it is one-way.
136
+ if (once)
137
+ Object.assign(init, { duplex: "half" });
138
+ for (let attempt = 0;; attempt++) {
139
+ const outcome = await this.attempt(url, init, call.read, options);
140
+ const last = attempt >= maxRetries;
141
+ if (outcome.kind === "response") {
142
+ const { response, body: read } = outcome;
143
+ if (response.ok) {
144
+ return {
145
+ data: call.read
146
+ ? read
147
+ : parsed(read, response.status, requestId),
148
+ status: response.status,
149
+ headers: response.headers,
150
+ requestId,
151
+ };
152
+ }
153
+ const error = errorFromResponse(response.status, response.headers, read, requestId);
154
+ const retried = response.status === 429 ||
155
+ (retriesAll && RETRIED_STATUS.has(response.status));
156
+ if (last || !retried || (error.retryAfter ?? 0) > MAX_RETRY_AFTER_S) {
157
+ throw error;
158
+ }
159
+ await sleep(retryDelayMs(attempt + 1, error.retryAfter), options.signal);
160
+ continue;
161
+ }
162
+ if (last || !retriesAll) {
163
+ throw outcome.kind === "timeout"
164
+ ? new TimeoutError({
165
+ status: 0,
166
+ code: "timeout",
167
+ message: "The API did not answer in time.",
168
+ requestId,
169
+ })
170
+ : new ConnectionError({
171
+ status: 0,
172
+ code: "connection_error",
173
+ message: "Could not reach the API.",
174
+ requestId,
175
+ cause: outcome.error,
176
+ });
177
+ }
178
+ await sleep(retryDelayMs(attempt + 1, null), options.signal);
179
+ }
180
+ }
181
+ url(call) {
182
+ // By hand: React Native's `URLSearchParams` is partial.
183
+ let query = "";
184
+ for (const [key, value] of Object.entries(call.query ?? {})) {
185
+ if (value != null) {
186
+ query += `${query ? "&" : "?"}${key}=${segment(value)}`;
187
+ }
188
+ }
189
+ const base = /^https?:/.test(call.path) ? "" : this.config.baseUrl;
190
+ return base + call.path + query;
191
+ }
192
+ /**
193
+ * One send and its body read, under this attempt's timer — which stops
194
+ * at the headers of a stream the caller reads (and of nothing else).
195
+ */
196
+ async attempt(url, init, read, options) {
197
+ // The caller's signal joined to the timer by hand, its reason carried
198
+ // over: `AbortSignal.any` is not React Native's. Left joined for a
199
+ // stream, whose body the caller's abort still ends.
200
+ const { signal } = options;
201
+ const timer = new AbortController();
202
+ const stop = () => timer.abort(signal?.reason);
203
+ const clock = setTimeout(stop, options.timeoutMs ?? this.config.timeoutMs);
204
+ signal?.addEventListener("abort", stop);
205
+ // Already aborted: no event will come, and `fetch` sends nothing.
206
+ if (signal?.aborted)
207
+ stop();
208
+ try {
209
+ const response = await this.config.fetch(url, {
210
+ ...init,
211
+ signal: timer.signal,
212
+ });
213
+ const body = !response.ok
214
+ ? await response.text()
215
+ : read === "stream"
216
+ ? response.body
217
+ : read === "bytes"
218
+ ? await response.arrayBuffer()
219
+ : await response.text();
220
+ return { kind: "response", response, body };
221
+ }
222
+ catch (error) {
223
+ // The caller's abort is the caller's, whatever else happened.
224
+ if (signal?.aborted)
225
+ throw signal.reason;
226
+ if (timer.signal.aborted)
227
+ return { kind: "timeout" };
228
+ return { kind: "network", error };
229
+ }
230
+ finally {
231
+ clearTimeout(clock);
232
+ if (read !== "stream")
233
+ signal?.removeEventListener("abort", stop);
234
+ }
235
+ }
236
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Talk to Lumavox agents from a browser, a React Native app or Node, with an end user's ephemeral key: conversations on the agent's socket, sessions, memory and relations. Zero dependencies.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "sideEffects": false,
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "@ino/source": "./src/index.ts",
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "scripts": {
22
+ "generate": "openapi-ts",
23
+ "protocol": "node scripts/protocol.mjs",
24
+ "build": "node scripts/build.mjs",
25
+ "prepack": "node scripts/build.mjs",
26
+ "check": "biome check --write",
27
+ "format": "biome format --write",
28
+ "lint": "biome lint",
29
+ "test": "vitest run",
30
+ "test:unit": "vitest run",
31
+ "typecheck": "tsc --noEmit && tsc --noEmit -p test"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "2.6.2",
35
+ "@hey-api/openapi-ts": "^0.99.0",
36
+ "@types/node": "^24",
37
+ "esbuild": "^0.28.1",
38
+ "typescript": "^6",
39
+ "vitest": "^4.1.12"
40
+ }
41
+ }