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