whisp-js 0.0.0-stage → 0.3.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +739 -2
  3. package/dist/cjs/client.d.ts +213 -0
  4. package/dist/cjs/client.js +570 -0
  5. package/dist/cjs/constants.d.ts +43 -0
  6. package/dist/cjs/constants.js +60 -0
  7. package/dist/cjs/errors.d.ts +96 -0
  8. package/dist/cjs/errors.js +115 -0
  9. package/dist/cjs/index.d.ts +19 -0
  10. package/dist/cjs/index.js +39 -0
  11. package/dist/cjs/listener.d.ts +93 -0
  12. package/dist/cjs/listener.js +457 -0
  13. package/dist/cjs/media.d.ts +40 -0
  14. package/dist/cjs/media.js +181 -0
  15. package/dist/cjs/package.json +3 -0
  16. package/dist/cjs/refs.d.ts +4 -0
  17. package/dist/cjs/refs.js +38 -0
  18. package/dist/cjs/runtime.d.ts +24 -0
  19. package/dist/cjs/runtime.js +93 -0
  20. package/dist/cjs/sse.d.ts +32 -0
  21. package/dist/cjs/sse.js +105 -0
  22. package/dist/cjs/transport.d.ts +61 -0
  23. package/dist/cjs/transport.js +181 -0
  24. package/dist/cjs/types.d.ts +583 -0
  25. package/dist/cjs/types.js +14 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/cjs/webhook.d.ts +27 -0
  29. package/dist/cjs/webhook.js +128 -0
  30. package/dist/esm/client.d.ts +213 -0
  31. package/dist/esm/client.js +566 -0
  32. package/dist/esm/constants.d.ts +43 -0
  33. package/dist/esm/constants.js +57 -0
  34. package/dist/esm/errors.d.ts +96 -0
  35. package/dist/esm/errors.js +107 -0
  36. package/dist/esm/index.d.ts +19 -0
  37. package/dist/esm/index.js +16 -0
  38. package/dist/esm/listener.d.ts +93 -0
  39. package/dist/esm/listener.js +452 -0
  40. package/dist/esm/media.d.ts +40 -0
  41. package/dist/esm/media.js +175 -0
  42. package/dist/esm/refs.d.ts +4 -0
  43. package/dist/esm/refs.js +33 -0
  44. package/dist/esm/runtime.d.ts +24 -0
  45. package/dist/esm/runtime.js +84 -0
  46. package/dist/esm/sse.d.ts +32 -0
  47. package/dist/esm/sse.js +100 -0
  48. package/dist/esm/transport.d.ts +61 -0
  49. package/dist/esm/transport.js +172 -0
  50. package/dist/esm/types.d.ts +583 -0
  51. package/dist/esm/types.js +13 -0
  52. package/dist/esm/version.d.ts +2 -0
  53. package/dist/esm/version.js +2 -0
  54. package/dist/esm/webhook.d.ts +27 -0
  55. package/dist/esm/webhook.js +123 -0
  56. package/package.json +37 -5
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Number references the SDK checks before any request (phase2-design 12.2):
3
+ * `phone` and `number(...)` take a phone in international form, as people
4
+ * write it (`+972 50-123-4567`), or a number id (`num_7Hq2kD3x`). The API
5
+ * takes the canonical form only (core's `parseNumberRef`: digits, or the id),
6
+ * so the SDK normalizes and a typo throws at construction instead of
7
+ * answering 404 NUMBER_NOT_IN_PROJECT on the first send.
8
+ */
9
+ import { NUMBER_ID_PATTERN } from './constants.js';
10
+ import { localError } from './errors.js';
11
+ /** What people put between a phone's digits. Anything else is refused, not cleaned. */
12
+ const SEPARATORS = /[\s\-.()]/g;
13
+ /** E.164 digits: a country code first (never 0), 8 to 15 digits in all (parseNumberRef's range). */
14
+ const PHONE_DIGITS = /^[1-9]\d{7,14}$/;
15
+ /** The canonical reference (`972501234567` or `num_7Hq2kD3x`), or null for anything else. */
16
+ export const normalizeNumberRef = (ref) => {
17
+ if (typeof ref !== 'string')
18
+ return null;
19
+ const trimmed = ref.trim();
20
+ if (NUMBER_ID_PATTERN.test(trimmed))
21
+ return trimmed;
22
+ const stripped = trimmed.replace(SEPARATORS, '');
23
+ const digits = stripped.startsWith('+') ? stripped.slice(1) : stripped;
24
+ return PHONE_DIGITS.test(digits) ? digits : null;
25
+ };
26
+ /** Like normalizeNumberRef, but throws PHONE_INVALID naming the value. */
27
+ export const requireNumberRef = (ref, where) => {
28
+ const normalized = normalizeNumberRef(ref);
29
+ if (normalized !== null)
30
+ return normalized;
31
+ const shown = typeof ref === 'string' ? `'${ref.slice(0, 40)}'` : String(typeof ref);
32
+ throw localError('PHONE_INVALID', `${where}: ${shown} is not a phone in international form (972501234567) or a number id (num_7Hq2kD3x)`);
33
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * An environment variable, trimmed, or undefined where there is none (Deno
3
+ * without `--allow-env` throws on access). Read when a call needs it, never at
4
+ * import (12.1), so a value set after import still counts.
5
+ */
6
+ export declare const readEnv: (name: string) => string | undefined;
7
+ /** A page with a DOM (5.3). Node, Bun, Deno and workers have neither global. */
8
+ export declare const isBrowser: () => boolean;
9
+ /** `bun/1.4.2`, `node/22.14.0`, `deno/2.1.0`. Bun also sets `process.versions.node`, so it is asked first. */
10
+ export declare const runtimeTag: () => string;
11
+ /** `whisp-sdk/<version> (<runtime>/<version>)`, the only identifying header the SDK sends (12.3). */
12
+ export declare const userAgent: () => string;
13
+ /** A v4 UUID: the default `Idempotency-Key` of a send (5.9). */
14
+ export declare const randomId: () => string;
15
+ /** Timers and randomness, injectable so tests can drive reconnects, backoff and watchdogs without waiting. */
16
+ export interface Timers {
17
+ now(): number;
18
+ setTimeout(callback: () => void, ms: number): unknown;
19
+ clearTimeout(handle: unknown): void;
20
+ random(): number;
21
+ }
22
+ export declare const realTimers: Timers;
23
+ /** Resolves after `ms`, or at once when `signal` aborts (it never rejects). */
24
+ export declare const sleep: (timers: Timers, ms: number, signal?: AbortSignal) => Promise<void>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The few runtime facts the SDK needs, read from globals that Node 22.12+,
3
+ * Bun and Deno all have (phase2-design 12.1): no `node:` built-ins, so the
4
+ * same build runs everywhere `fetch`, Web Streams and Web Crypto exist.
5
+ */
6
+ import { USER_AGENT_PREFIX } from './constants.js';
7
+ import { SDK_VERSION } from './version.js';
8
+ const runtimeGlobals = () => globalThis;
9
+ /**
10
+ * An environment variable, trimmed, or undefined where there is none (Deno
11
+ * without `--allow-env` throws on access). Read when a call needs it, never at
12
+ * import (12.1), so a value set after import still counts.
13
+ */
14
+ export const readEnv = (name) => {
15
+ try {
16
+ const value = runtimeGlobals().process?.env?.[name];
17
+ const trimmed = typeof value === 'string' ? value.trim() : '';
18
+ return trimmed || undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ };
24
+ /** A page with a DOM (5.3). Node, Bun, Deno and workers have neither global. */
25
+ export const isBrowser = () => {
26
+ const g = runtimeGlobals();
27
+ return typeof g.window !== 'undefined' && typeof g.document !== 'undefined';
28
+ };
29
+ /** `bun/1.4.2`, `node/22.14.0`, `deno/2.1.0`. Bun also sets `process.versions.node`, so it is asked first. */
30
+ export const runtimeTag = () => {
31
+ const g = runtimeGlobals();
32
+ try {
33
+ if (typeof g.Bun?.version === 'string')
34
+ return `bun/${g.Bun.version}`;
35
+ if (typeof g.Deno?.version?.deno === 'string')
36
+ return `deno/${g.Deno.version.deno}`;
37
+ const node = g.process?.versions?.node;
38
+ if (typeof node === 'string')
39
+ return `node/${node}`;
40
+ }
41
+ catch {
42
+ // A locked-down global: fall through.
43
+ }
44
+ return 'unknown';
45
+ };
46
+ /** `whisp-sdk/<version> (<runtime>/<version>)`, the only identifying header the SDK sends (12.3). */
47
+ export const userAgent = () => `${USER_AGENT_PREFIX}/${SDK_VERSION} (${runtimeTag()})`;
48
+ const hex = (bytes) => {
49
+ let out = '';
50
+ for (const byte of bytes)
51
+ out += byte.toString(16).padStart(2, '0');
52
+ return out;
53
+ };
54
+ /** A v4 UUID: the default `Idempotency-Key` of a send (5.9). */
55
+ export const randomId = () => {
56
+ const webCrypto = globalThis.crypto;
57
+ if (typeof webCrypto?.randomUUID === 'function')
58
+ return webCrypto.randomUUID();
59
+ // randomUUID is missing on insecure (http) pages, where getRandomValues still works.
60
+ const bytes = new Uint8Array(16);
61
+ webCrypto.getRandomValues(bytes);
62
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
63
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
64
+ const h = hex(bytes);
65
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
66
+ };
67
+ export const realTimers = {
68
+ now: () => Date.now(),
69
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
70
+ clearTimeout: handle => clearTimeout(handle),
71
+ random: () => Math.random(),
72
+ };
73
+ /** Resolves after `ms`, or at once when `signal` aborts (it never rejects). */
74
+ export const sleep = (timers, ms, signal) => new Promise(resolve => {
75
+ if (signal?.aborted)
76
+ return resolve();
77
+ const done = () => {
78
+ timers.clearTimeout(handle);
79
+ signal?.removeEventListener('abort', done);
80
+ resolve();
81
+ };
82
+ const handle = timers.setTimeout(done, Math.max(0, ms));
83
+ signal?.addEventListener('abort', done, { once: true });
84
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A small parser for `text/event-stream` (WHATWG HTML, "Server-sent events"),
3
+ * fed with the bytes of a fetch body (phase2-design 12.4). Not `EventSource`:
4
+ * Node doesn't ship one unflagged, and it can't send an `Authorization`
5
+ * header.
6
+ *
7
+ * Lines end with CRLF, LF or CR, and a chunk can end anywhere, between the CR
8
+ * and the LF of one break included. `id` here is the field of the event's own
9
+ * block, not the spec's sticky "last event id": the stream's `error` carries
10
+ * no `id`, `ready`'s is where the stream starts, a `ping`'s how far it has
11
+ * covered, and only a frame that carries one moves the SDK's cursor (6.5).
12
+ */
13
+ export interface SseEvent {
14
+ /** `event:`, or `message` when the block had none. */
15
+ event: string;
16
+ /** The `data:` lines joined with LF. */
17
+ data: string;
18
+ /** The block's own `id:`, or undefined. */
19
+ id: string | undefined;
20
+ }
21
+ /** A line longer than this is not from our API (an event is at most about 130 KB); the stream is dropped. */
22
+ export declare const MAX_LINE_CHARS: number;
23
+ export declare class SseLineTooLongError extends Error {
24
+ readonly name = "SseLineTooLongError";
25
+ }
26
+ export interface SseParser {
27
+ /** The events completed by this chunk, in order. */
28
+ push(chunk: Uint8Array | string): SseEvent[];
29
+ /** The end of the stream: a block without its blank line is dropped, as the spec says. */
30
+ end(): SseEvent[];
31
+ }
32
+ export declare const createSseParser: () => SseParser;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A small parser for `text/event-stream` (WHATWG HTML, "Server-sent events"),
3
+ * fed with the bytes of a fetch body (phase2-design 12.4). Not `EventSource`:
4
+ * Node doesn't ship one unflagged, and it can't send an `Authorization`
5
+ * header.
6
+ *
7
+ * Lines end with CRLF, LF or CR, and a chunk can end anywhere, between the CR
8
+ * and the LF of one break included. `id` here is the field of the event's own
9
+ * block, not the spec's sticky "last event id": the stream's `error` carries
10
+ * no `id`, `ready`'s is where the stream starts, a `ping`'s how far it has
11
+ * covered, and only a frame that carries one moves the SDK's cursor (6.5).
12
+ */
13
+ /** A line longer than this is not from our API (an event is at most about 130 KB); the stream is dropped. */
14
+ export const MAX_LINE_CHARS = 4 * 1024 * 1024;
15
+ export class SseLineTooLongError extends Error {
16
+ name = 'SseLineTooLongError';
17
+ }
18
+ export const createSseParser = () => {
19
+ // The default decoder drops a leading BOM, which the spec tells parsers to ignore.
20
+ const decoder = new TextDecoder();
21
+ let buffer = '';
22
+ let eventType = '';
23
+ let data = [];
24
+ let id;
25
+ const dispatch = (out) => {
26
+ if (data.length > 0)
27
+ out.push({ event: eventType || 'message', data: data.join('\n'), id });
28
+ eventType = '';
29
+ data = [];
30
+ id = undefined;
31
+ };
32
+ const processLine = (line, out) => {
33
+ if (line === '')
34
+ return dispatch(out);
35
+ if (line.startsWith(':'))
36
+ return; // a comment
37
+ const colon = line.indexOf(':');
38
+ const field = colon === -1 ? line : line.slice(0, colon);
39
+ let value = colon === -1 ? '' : line.slice(colon + 1);
40
+ if (value.startsWith(' '))
41
+ value = value.slice(1);
42
+ switch (field) {
43
+ case 'event':
44
+ eventType = value;
45
+ break;
46
+ case 'data':
47
+ data.push(value);
48
+ break;
49
+ case 'id':
50
+ // The spec ignores an id with a NULL in it.
51
+ if (!value.includes('\0'))
52
+ id = value;
53
+ break;
54
+ default:
55
+ // `retry` and unknown fields: the SDK keeps its own reconnect schedule (12.4).
56
+ }
57
+ };
58
+ const drain = (final) => {
59
+ const out = [];
60
+ let start = 0;
61
+ for (;;) {
62
+ const lf = buffer.indexOf('\n', start);
63
+ const cr = buffer.indexOf('\r', start);
64
+ const at = lf === -1 ? cr : cr === -1 ? lf : Math.min(lf, cr);
65
+ if (at === -1)
66
+ break;
67
+ if (buffer[at] === '\r') {
68
+ // A CR at the end of the chunk may be half of a CRLF: wait for the next chunk to say.
69
+ if (at === buffer.length - 1 && !final)
70
+ break;
71
+ processLine(buffer.slice(start, at), out);
72
+ start = buffer[at + 1] === '\n' ? at + 2 : at + 1;
73
+ }
74
+ else {
75
+ processLine(buffer.slice(start, at), out);
76
+ start = at + 1;
77
+ }
78
+ }
79
+ buffer = buffer.slice(start);
80
+ if (buffer.length > MAX_LINE_CHARS)
81
+ throw new SseLineTooLongError('An event stream line is too long');
82
+ return out;
83
+ };
84
+ return {
85
+ push(chunk) {
86
+ buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
87
+ return drain(false);
88
+ },
89
+ end() {
90
+ buffer += decoder.decode();
91
+ const out = drain(true);
92
+ // What is left is a line without its end, and the block it belongs to never got its blank line.
93
+ buffer = '';
94
+ eventType = '';
95
+ data = [];
96
+ id = undefined;
97
+ return out;
98
+ },
99
+ };
100
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One API call with a timeout and retries (phase2-design 12.3).
3
+ *
4
+ * Retried: network errors, timeouts, 408, 429 and 5xx (but 501, which never
5
+ * changes), with full-jitter backoff (0.5 s base, 8 s cap), or exactly
6
+ * `Retry-After` when the API sends one, up to 60 s; a longer `Retry-After`
7
+ * fails at once with `retryAfter` set, rather than holding the caller for
8
+ * minutes. Never on other 4xx. Every attempt sends the same bytes and the
9
+ * same `Idempotency-Key`, so a retried send can't send twice (5.9).
10
+ *
11
+ * The token goes only to the API's origin: redirects are never followed
12
+ * (`redirect: 'manual'`), so a 3xx is an error, not a hop that might carry
13
+ * the `Authorization` header somewhere else.
14
+ */
15
+ import { WhispError } from './errors.js';
16
+ import { type Timers } from './runtime.js';
17
+ /** Any WHATWG fetch: the global one, undici's, or a test's. */
18
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
19
+ export declare const BACKOFF_BASE_MS = 500;
20
+ export declare const BACKOFF_CAP_MS = 8000;
21
+ export declare const RETRY_AFTER_MAX_SECONDS = 60;
22
+ export interface TransportConfig {
23
+ baseUrl: string;
24
+ token: string;
25
+ fetch: FetchLike;
26
+ userAgent: string;
27
+ /** Per attempt, the answer's body included. */
28
+ timeoutMs: number;
29
+ /** Attempts after the first. */
30
+ maxRetries: number;
31
+ timers: Timers;
32
+ /** Aborted by `close()`: a retry waiting for its turn gives up and throws the last error. */
33
+ retrySignal?: AbortSignal;
34
+ }
35
+ export type QueryValue = string | number | boolean | undefined | null;
36
+ export interface ApiRequest {
37
+ method: 'GET' | 'POST';
38
+ /** Starts with `/`; path segments already encoded. */
39
+ path: string;
40
+ query?: Record<string, QueryValue>;
41
+ body?: unknown;
42
+ idempotencyKey?: string;
43
+ /** Overrides the config's per-attempt timeout (a send with `wait` runs longer). */
44
+ timeoutMs?: number;
45
+ }
46
+ export interface ApiResponse {
47
+ status: number;
48
+ headers: Headers;
49
+ body: unknown;
50
+ }
51
+ /** Full jitter (AWS's "Exponential Backoff and Jitter"): uniform in [0, min(cap, base * 2^attempt)). */
52
+ export declare const backoffDelay: (attempt: number, random: () => number) => number;
53
+ /** `Retry-After` in seconds: a number of seconds or an HTTP date. Undefined when absent or unreadable. */
54
+ export declare const parseRetryAfter: (value: string | null, nowMs: number) => number | undefined;
55
+ export declare const isRetryableStatus: (status: number) => boolean;
56
+ /** `https://host/base` + `/path` + `?query`. `new URL(path, base)` would drop a path prefix on the base. */
57
+ export declare const buildUrl: (baseUrl: string, path: string, query?: Record<string, QueryValue>) => string;
58
+ /** The WhispError for a non-2xx answer, with the API's code and the request id. */
59
+ export declare const errorFromResponse: (response: Response, text: string, nowMs: number) => WhispError;
60
+ /** The 2xx answer, or throws the last WhispError (or WhispConnectionError) once retries are spent. */
61
+ export declare const request: (config: TransportConfig, call: ApiRequest) => Promise<ApiResponse>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * One API call with a timeout and retries (phase2-design 12.3).
3
+ *
4
+ * Retried: network errors, timeouts, 408, 429 and 5xx (but 501, which never
5
+ * changes), with full-jitter backoff (0.5 s base, 8 s cap), or exactly
6
+ * `Retry-After` when the API sends one, up to 60 s; a longer `Retry-After`
7
+ * fails at once with `retryAfter` set, rather than holding the caller for
8
+ * minutes. Never on other 4xx. Every attempt sends the same bytes and the
9
+ * same `Idempotency-Key`, so a retried send can't send twice (5.9).
10
+ *
11
+ * The token goes only to the API's origin: redirects are never followed
12
+ * (`redirect: 'manual'`), so a 3xx is an error, not a hop that might carry
13
+ * the `Authorization` header somewhere else.
14
+ */
15
+ import { readApiError, WhispConnectionError, WhispError } from './errors.js';
16
+ import { sleep } from './runtime.js';
17
+ export const BACKOFF_BASE_MS = 500;
18
+ export const BACKOFF_CAP_MS = 8_000;
19
+ export const RETRY_AFTER_MAX_SECONDS = 60;
20
+ /** Full jitter (AWS's "Exponential Backoff and Jitter"): uniform in [0, min(cap, base * 2^attempt)). */
21
+ export const backoffDelay = (attempt, random) => Math.floor(random() * Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt));
22
+ /** `Retry-After` in seconds: a number of seconds or an HTTP date. Undefined when absent or unreadable. */
23
+ export const parseRetryAfter = (value, nowMs) => {
24
+ if (!value)
25
+ return undefined;
26
+ const text = value.trim();
27
+ if (/^\d+(?:\.\d+)?$/.test(text))
28
+ return Math.ceil(Number(text));
29
+ // An HTTP date names a weekday and a month; Date.parse alone reads "-5" as a year.
30
+ if (!/[a-z]/i.test(text))
31
+ return undefined;
32
+ const at = Date.parse(text);
33
+ if (Number.isNaN(at))
34
+ return undefined;
35
+ return Math.max(0, Math.ceil((at - nowMs) / 1_000));
36
+ };
37
+ export const isRetryableStatus = (status) => status === 408 || status === 429 || (status >= 500 && status !== 501);
38
+ /** `https://host/base` + `/path` + `?query`. `new URL(path, base)` would drop a path prefix on the base. */
39
+ export const buildUrl = (baseUrl, path, query) => {
40
+ let url = `${baseUrl.replace(/\/+$/, '')}${path}`;
41
+ if (query) {
42
+ const params = new URLSearchParams();
43
+ for (const [key, value] of Object.entries(query)) {
44
+ if (value !== undefined && value !== null)
45
+ params.set(key, String(value));
46
+ }
47
+ const search = params.toString();
48
+ if (search)
49
+ url += `?${search}`;
50
+ }
51
+ return url;
52
+ };
53
+ const parseJson = (text) => {
54
+ if (!text)
55
+ return { ok: true, value: null };
56
+ try {
57
+ return { ok: true, value: JSON.parse(text) };
58
+ }
59
+ catch {
60
+ return { ok: false };
61
+ }
62
+ };
63
+ const describe = (cause) => cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
64
+ /** The WhispError for a non-2xx answer, with the API's code and the request id. */
65
+ export const errorFromResponse = (response, text, nowMs) => {
66
+ const parsed = parseJson(text);
67
+ const api = parsed.ok ? readApiError(parsed.value) : null;
68
+ const requestId = response.headers.get('x-request-id');
69
+ const retryAfter = parseRetryAfter(response.headers.get('retry-after'), nowMs) ?? null;
70
+ if (response.status >= 300 && response.status < 400) {
71
+ const location = response.headers.get('location');
72
+ return new WhispError({
73
+ code: 'INVALID_RESPONSE',
74
+ status: response.status,
75
+ message: `The API answered ${response.status}${location ? ` (a redirect to ${location.slice(0, 200)})` : ''}; the SDK never follows redirects with your token. Check baseUrl.`,
76
+ requestId,
77
+ });
78
+ }
79
+ return new WhispError({
80
+ code: api?.code ?? `HTTP_${response.status}`,
81
+ message: api?.message ?? `The API answered ${response.status}`,
82
+ status: response.status,
83
+ params: api?.params ?? null,
84
+ requestId,
85
+ retryAfter,
86
+ });
87
+ };
88
+ const attemptOnce = async (config, call, url, payload) => {
89
+ const controller = new AbortController();
90
+ const timeoutMs = call.timeoutMs ?? config.timeoutMs;
91
+ let timedOut = false;
92
+ const timer = config.timers.setTimeout(() => {
93
+ timedOut = true;
94
+ controller.abort();
95
+ }, timeoutMs);
96
+ const headers = {
97
+ authorization: `Bearer ${config.token}`,
98
+ accept: 'application/json',
99
+ 'user-agent': config.userAgent,
100
+ };
101
+ if (payload !== undefined)
102
+ headers['content-type'] = 'application/json';
103
+ if (call.idempotencyKey !== undefined)
104
+ headers['idempotency-key'] = call.idempotencyKey;
105
+ try {
106
+ const response = await config.fetch(url, {
107
+ method: call.method,
108
+ headers,
109
+ // Always a string: the runtime sends its Content-Length, which public routes require (5.2 step 2).
110
+ ...(payload !== undefined ? { body: payload } : {}),
111
+ redirect: 'manual',
112
+ signal: controller.signal,
113
+ });
114
+ // Read inside the timeout: an answer whose body never ends is a hung request too.
115
+ const text = await response.text();
116
+ if (response.status >= 200 && response.status < 300) {
117
+ const parsed = parseJson(text);
118
+ if (!parsed.ok) {
119
+ return {
120
+ done: false,
121
+ retryable: false,
122
+ error: new WhispError({
123
+ code: 'INVALID_RESPONSE',
124
+ status: response.status,
125
+ message: `The API answered ${response.status} with a body that is not JSON`,
126
+ requestId: response.headers.get('x-request-id'),
127
+ }),
128
+ };
129
+ }
130
+ return { done: true, response: { status: response.status, headers: response.headers, body: parsed.value } };
131
+ }
132
+ const error = errorFromResponse(response, text, config.timers.now());
133
+ return { done: false, error, retryable: isRetryableStatus(response.status) };
134
+ }
135
+ catch (cause) {
136
+ if (timedOut) {
137
+ return {
138
+ done: false,
139
+ retryable: true,
140
+ error: new WhispConnectionError('TIMEOUT', `No answer from the API within ${timeoutMs} ms`, cause),
141
+ };
142
+ }
143
+ return {
144
+ done: false,
145
+ retryable: true,
146
+ error: new WhispConnectionError('NETWORK_ERROR', `The request failed (${describe(cause)})`, cause),
147
+ };
148
+ }
149
+ finally {
150
+ config.timers.clearTimeout(timer);
151
+ }
152
+ };
153
+ /** The 2xx answer, or throws the last WhispError (or WhispConnectionError) once retries are spent. */
154
+ export const request = async (config, call) => {
155
+ const url = buildUrl(config.baseUrl, call.path, call.query);
156
+ // Serialized once: every retry sends exactly these bytes.
157
+ const payload = call.body === undefined ? undefined : JSON.stringify(call.body);
158
+ for (let attempt = 0;; attempt++) {
159
+ const outcome = await attemptOnce(config, call, url, payload);
160
+ if (outcome.done)
161
+ return outcome.response;
162
+ const { error } = outcome;
163
+ if (!outcome.retryable || attempt >= config.maxRetries || config.retrySignal?.aborted)
164
+ throw error;
165
+ if (error.retryAfter !== null && error.retryAfter > RETRY_AFTER_MAX_SECONDS)
166
+ throw error;
167
+ const delay = error.retryAfter !== null ? error.retryAfter * 1_000 : backoffDelay(attempt, () => config.timers.random());
168
+ await sleep(config.timers, delay, config.retrySignal);
169
+ if (config.retrySignal?.aborted)
170
+ throw error;
171
+ }
172
+ };