@upvoiced/sdk 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joao Pereira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,134 @@
1
- # Temporary Holding Version
1
+ # @upvoiced/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed TypeScript client for the [UpVoiced](https://upvoiced.com) API. Every path, parameter,
4
+ request body and response type is generated from the API's OpenAPI 3.1 document
5
+ (`/openapi.json`), and a thin `fetch` wrapper adds the API key, `X-Workspace`,
6
+ `Idempotency-Key`, retries, cursor pagination and typed errors. No runtime dependencies: it needs
7
+ only a standard `fetch` (Node 22+, Deno, Bun, Cloudflare Workers). API keys are secrets: use them
8
+ from your server, never from a browser.
9
+
10
+ ```sh
11
+ npm install @upvoiced/sdk
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ The initial `0.1.0` preview uses the approved staging API contract. Set `baseUrl` explicitly;
17
+ publishing the SDK does not certify production API availability. Use a key from your staging
18
+ workspace. The client retains its production-origin default for later production use.
19
+
20
+ ```ts
21
+ import { UpVoiced, UpVoicedError } from '@upvoiced/sdk';
22
+
23
+ const upvoiced = new UpVoiced({
24
+ baseUrl: 'https://api-staging.upvoiced.com',
25
+ apiKey: process.env.UPVOICED_API_KEY, // from the staging workspace's Settings → API keys
26
+ workspace: process.env.UPVOICED_WORKSPACE_ID, // sent as X-Workspace
27
+ });
28
+
29
+ const { items: boards } = await upvoiced.get('/v1/boards');
30
+
31
+ const post = await upvoiced.post('/v1/posts', {
32
+ body: { board_id: boards[0].id, title: 'Dark mode', body_md: 'From our CRM.' },
33
+ });
34
+
35
+ await upvoiced.patch('/v1/posts/{id}', { path: { id: post.id }, body: { title: 'Dark mode' } });
36
+
37
+ for await (const item of upvoiced.paginate('/v1/posts', { query: { sort: 'top', limit: 100 } })) {
38
+ console.log(item.title, item.vote_count);
39
+ }
40
+ ```
41
+
42
+ Paths are the API's own (`/v1/posts/{id}`), so the [API reference](https://docs.upvoiced.com)
43
+ and `curl` examples map one to one. Your editor completes paths, query parameters and bodies,
44
+ and TypeScript rejects a missing path parameter, an unknown field or a wrong enum value.
45
+
46
+ ## Client options
47
+
48
+ | Option | Default | Meaning |
49
+ | ----------------- | -------------------------- | ------------------------------------------------------------------------ |
50
+ | `apiKey` | — | API key, sent as `Authorization: Bearer`. Omit for `/v1/public/*` reads. |
51
+ | `workspace` | — | Workspace id (`X-Workspace`); a workspace slug for `/v1/public/*`. |
52
+ | `baseUrl` | `https://api.upvoiced.com` | API origin. |
53
+ | `maxRetries` | `2` | Retries after 429, 502/503/504 and network errors (see below). |
54
+ | `maxRetryDelayMs` | `60000` | A `Retry-After` longer than this is not waited for; the error is thrown. |
55
+ | `fetch` | global `fetch` | Custom fetch (proxies, tests). |
56
+ | `headers` | — | Extra headers on every request. |
57
+
58
+ Every call also accepts `workspace`, `idempotencyKey`, `headers` and an `AbortSignal` (`signal`).
59
+
60
+ ### Keys, scopes and workspaces
61
+
62
+ Keys carry the `read`, `write` or `admin` scope. A workspace-bound key works on its workspace
63
+ only; an operator-wide key ("all workspaces I admin") picks one per call:
64
+
65
+ ```ts
66
+ const other = upvoiced.withWorkspace(otherWorkspaceId);
67
+ await upvoiced.get('/v1/workspace', { workspace: otherWorkspaceId });
68
+ ```
69
+
70
+ Key management, workspace creation and other session-only routes refuse API keys
71
+ (`403 session_required`). Each key may send 600 GET and 120 other requests per minute.
72
+
73
+ ### Idempotency and retries
74
+
75
+ The API deduplicates POSTs by `Idempotency-Key` for 24 hours. With retries on (the default),
76
+ the client sends a generated key on every POST and reuses it on each retry, so a POST whose
77
+ response was lost is answered from the first attempt instead of being applied twice. Pass your
78
+ own `idempotencyKey` to make a POST safe across process restarts too.
79
+
80
+ The client retries a 429 for any method (the request was refused before it ran), and a 502, 503,
81
+ 504 or network error for GET, PUT, DELETE and POST. It never retries a PATCH after a 5xx. It
82
+ waits for `Retry-After` when present, else an exponential backoff with jitter.
83
+
84
+ ### Errors
85
+
86
+ Every non-2xx response throws an `UpVoicedError` with the API's error shape:
87
+
88
+ ```ts
89
+ try {
90
+ await upvoiced.post('/v1/posts', { body: { board_id, title: '' } });
91
+ } catch (error) {
92
+ if (error instanceof UpVoicedError) {
93
+ error.status; // 400
94
+ error.code; // 'invalid_request' | 'insufficient_scope' | 'not_found' | 'rate_limited' | …
95
+ error.details; // [{ path: 'title', code: 'too_small' }]
96
+ error.retryAfter; // seconds, for 429/503
97
+ error.idempotencyKey; // reuse it to retry the same POST later
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### Status and headers
103
+
104
+ `request()` resolves with the status and headers as well as the data:
105
+
106
+ ```ts
107
+ const { data, status, headers } = await upvoiced.request('post', '/v1/posts', {
108
+ body,
109
+ idempotencyKey: 'import-2026-10-22-row-17',
110
+ });
111
+ headers.get('idempotent-replayed'); // 'true' when answered from an earlier attempt
112
+ ```
113
+
114
+ ### Types
115
+
116
+ The generated OpenAPI types are exported for your own code:
117
+
118
+ ```ts
119
+ import type { Operation, RequestBody, ResponseData, paths } from '@upvoiced/sdk';
120
+
121
+ type Post = ResponseData<Operation<'/v1/posts/{id}', 'get'>>;
122
+ type NewPost = RequestBody<Operation<'/v1/posts', 'post'>>;
123
+ ```
124
+
125
+ ## Versioning
126
+
127
+ `/v1` changes are additive only (breaking changes go to `/v2` with a 12-month overlap). The
128
+ package version follows UpVoiced releases; each release is regenerated from that release's API.
129
+
130
+ ## Development (UpVoiced monorepo)
131
+
132
+ `src/generated/schema.ts` is generated, never edited: after changing API routes run
133
+ `pnpm --filter @upvoiced/sdk generate` (needs a built `@upvoiced/api`). The SDK's tests fail
134
+ while the committed file differs from the API. See ADR-0067.
@@ -0,0 +1,54 @@
1
+ import type { ApiResponse, HttpMethod, Operation, PageItem, PaginatedPath, PathsWith, RequestArgs, ResponseData } from './types.js';
2
+ /** Production API origin (ADR-0015). Staging: https://api-staging.upvoiced.com */
3
+ export declare const defaultBaseUrl = "https://api.upvoiced.com";
4
+ export declare const defaultMaxRetries = 2;
5
+ export declare const defaultMaxRetryDelayMs = 60000;
6
+ export interface ClientOptions {
7
+ /** API key (`uv_live_…` / `uv_test_…`), sent as `Authorization: Bearer`. Omit for public reads. */
8
+ apiKey?: string;
9
+ /** Workspace id (X-Workspace) for operator routes; a slug for `/v1/public/*`. */
10
+ workspace?: string;
11
+ /** API origin; defaults to production. */
12
+ baseUrl?: string;
13
+ /** fetch implementation (Node 22+, browsers, Workers and Deno have one). */
14
+ fetch?: (input: string, init: RequestInit) => Promise<Response>;
15
+ /**
16
+ * Retries after 429 (any method; the API refuses before running the handler), and after
17
+ * 502/503/504 or a network error for GET, PUT, DELETE and POST (POSTs always carry an
18
+ * Idempotency-Key when retries are on). PATCH is never retried after a 5xx. Default 2.
19
+ */
20
+ maxRetries?: number;
21
+ /** A Retry-After longer than this is not waited for: the error is thrown. Default 60 s. */
22
+ maxRetryDelayMs?: number;
23
+ /** Extra headers on every request. */
24
+ headers?: Record<string, string>;
25
+ }
26
+ /**
27
+ * P2-015 (ADR-0067, INT-004): thin typed client over `fetch`. Paths, parameters, bodies and
28
+ * responses are typed from the generated OpenAPI `paths`; at runtime it only adds credentials,
29
+ * X-Workspace, Idempotency-Key, retries, cursor pagination and the error shape.
30
+ *
31
+ * ```ts
32
+ * const upvoiced = new UpVoiced({ apiKey: process.env.UPVOICED_API_KEY, workspace });
33
+ * const post = await upvoiced.post('/v1/posts', { body: { board_id, title: 'Dark mode' } });
34
+ * for await (const item of upvoiced.paginate('/v1/posts', { query: { board: board_id } })) {}
35
+ * ```
36
+ */
37
+ export declare class UpVoiced {
38
+ #private;
39
+ constructor(options?: ClientOptions);
40
+ /** A client with the same settings acting on another workspace (operator-wide keys). */
41
+ withWorkspace(workspace: string): UpVoiced;
42
+ get<P extends PathsWith<'get'>>(path: P, ...args: RequestArgs<Operation<P, 'get'>>): Promise<ResponseData<Operation<P, "get">>>;
43
+ post<P extends PathsWith<'post'>>(path: P, ...args: RequestArgs<Operation<P, 'post'>>): Promise<ResponseData<Operation<P, "post">>>;
44
+ put<P extends PathsWith<'put'>>(path: P, ...args: RequestArgs<Operation<P, 'put'>>): Promise<ResponseData<Operation<P, "put">>>;
45
+ patch<P extends PathsWith<'patch'>>(path: P, ...args: RequestArgs<Operation<P, 'patch'>>): Promise<ResponseData<Operation<P, "patch">>>;
46
+ delete<P extends PathsWith<'delete'>>(path: P, ...args: RequestArgs<Operation<P, 'delete'>>): Promise<ResponseData<Operation<P, "delete">>>;
47
+ /** Like the method helpers, but resolves with `{ data, status, headers }`. */
48
+ request<M extends HttpMethod, P extends PathsWith<M>>(method: M, path: P, ...args: RequestArgs<Operation<P, M>>): Promise<ApiResponse<ResponseData<Operation<P, M>>>>;
49
+ /**
50
+ * Every item of a cursor-paginated list (`{ items, next_cursor }`), fetching pages lazily.
51
+ * `query.limit` is the page size; stop iterating to stop fetching.
52
+ */
53
+ paginate<P extends PaginatedPath>(path: P, ...args: RequestArgs<Operation<P, 'get'>>): AsyncGenerator<PageItem<P>, void, undefined>;
54
+ }
package/dist/client.js ADDED
@@ -0,0 +1,209 @@
1
+ import { UpVoicedError, errorShape, parseRetryAfter } from './errors.js';
2
+ /** Production API origin (ADR-0015). Staging: https://api-staging.upvoiced.com */
3
+ export const defaultBaseUrl = 'https://api.upvoiced.com';
4
+ export const defaultMaxRetries = 2;
5
+ export const defaultMaxRetryDelayMs = 60_000;
6
+ const methods = ['get', 'put', 'post', 'patch', 'delete'];
7
+ const retryableStatus = new Set([502, 503, 504]);
8
+ function expandPath(template, values) {
9
+ return template.replace(/\{([^}]+)\}/g, (_, name) => {
10
+ const value = values?.[name];
11
+ if (value === undefined || value === '')
12
+ throw new TypeError(`Missing path parameter "${name}" for ${template}.`);
13
+ return encodeURIComponent(String(value));
14
+ });
15
+ }
16
+ const queryValue = (item) => item instanceof Date
17
+ ? item.toISOString()
18
+ : typeof item === 'string'
19
+ ? item
20
+ : typeof item === 'number' || typeof item === 'boolean' || typeof item === 'bigint'
21
+ ? String(item)
22
+ : JSON.stringify(item);
23
+ function searchOf(query) {
24
+ const search = new URLSearchParams();
25
+ for (const [name, value] of Object.entries(query ?? {})) {
26
+ if (value === undefined || value === null)
27
+ continue;
28
+ for (const item of Array.isArray(value) ? value : [value])
29
+ search.append(name, queryValue(item));
30
+ }
31
+ const text = search.toString();
32
+ return text ? `?${text}` : '';
33
+ }
34
+ async function bodyOf(response) {
35
+ const text = await response.text();
36
+ if (text === '')
37
+ return undefined;
38
+ const type = response.headers.get('content-type') ?? '';
39
+ if (/\bjson\b/i.test(type)) {
40
+ try {
41
+ return JSON.parse(text);
42
+ }
43
+ catch {
44
+ return text;
45
+ }
46
+ }
47
+ return text;
48
+ }
49
+ function sleep(ms, signal) {
50
+ return new Promise((resolve, reject) => {
51
+ if (signal?.aborted) {
52
+ reject(signal.reason);
53
+ return;
54
+ }
55
+ const abort = () => {
56
+ clearTimeout(timer);
57
+ reject(signal?.reason);
58
+ };
59
+ const timer = setTimeout(() => {
60
+ signal?.removeEventListener('abort', abort);
61
+ resolve();
62
+ }, ms);
63
+ signal?.addEventListener('abort', abort, { once: true });
64
+ });
65
+ }
66
+ /** Exponential backoff with jitter: ~0.5 s, 1 s, 2 s … capped at 8 s. */
67
+ const backoff = (attempt) => Math.min(8000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2);
68
+ /**
69
+ * P2-015 (ADR-0067, INT-004): thin typed client over `fetch`. Paths, parameters, bodies and
70
+ * responses are typed from the generated OpenAPI `paths`; at runtime it only adds credentials,
71
+ * X-Workspace, Idempotency-Key, retries, cursor pagination and the error shape.
72
+ *
73
+ * ```ts
74
+ * const upvoiced = new UpVoiced({ apiKey: process.env.UPVOICED_API_KEY, workspace });
75
+ * const post = await upvoiced.post('/v1/posts', { body: { board_id, title: 'Dark mode' } });
76
+ * for await (const item of upvoiced.paginate('/v1/posts', { query: { board: board_id } })) {}
77
+ * ```
78
+ */
79
+ export class UpVoiced {
80
+ #options;
81
+ #baseUrl;
82
+ #maxRetries;
83
+ #maxRetryDelayMs;
84
+ constructor(options = {}) {
85
+ const base = new URL(options.baseUrl ?? defaultBaseUrl);
86
+ if (base.protocol !== 'https:' && base.protocol !== 'http:')
87
+ throw new TypeError(`baseUrl must be http(s), got ${base.protocol}`);
88
+ const maxRetries = options.maxRetries ?? defaultMaxRetries;
89
+ if (!Number.isInteger(maxRetries) || maxRetries < 0)
90
+ throw new TypeError('maxRetries must be a non-negative integer.');
91
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? defaultMaxRetryDelayMs;
92
+ if (!(maxRetryDelayMs >= 0))
93
+ throw new TypeError('maxRetryDelayMs must be ≥ 0.');
94
+ this.#options = options;
95
+ this.#baseUrl = base.href.replace(/\/+$/, '');
96
+ this.#maxRetries = maxRetries;
97
+ this.#maxRetryDelayMs = maxRetryDelayMs;
98
+ }
99
+ /** A client with the same settings acting on another workspace (operator-wide keys). */
100
+ withWorkspace(workspace) {
101
+ return new UpVoiced({ ...this.#options, workspace });
102
+ }
103
+ get(path, ...args) {
104
+ return this.#data('get', path, args[0]);
105
+ }
106
+ post(path, ...args) {
107
+ return this.#data('post', path, args[0]);
108
+ }
109
+ put(path, ...args) {
110
+ return this.#data('put', path, args[0]);
111
+ }
112
+ patch(path, ...args) {
113
+ return this.#data('patch', path, args[0]);
114
+ }
115
+ delete(path, ...args) {
116
+ return this.#data('delete', path, args[0]);
117
+ }
118
+ /** Like the method helpers, but resolves with `{ data, status, headers }`. */
119
+ request(method, path, ...args) {
120
+ return this.#send(method, path, args[0]);
121
+ }
122
+ /**
123
+ * Every item of a cursor-paginated list (`{ items, next_cursor }`), fetching pages lazily.
124
+ * `query.limit` is the page size; stop iterating to stop fetching.
125
+ */
126
+ async *paginate(path, ...args) {
127
+ const options = (args[0] ?? {});
128
+ let cursor = options.query?.['cursor'];
129
+ for (;;) {
130
+ const query = { ...options.query, ...(cursor ? { cursor } : {}) };
131
+ const { data } = await this.#send('get', path, { ...options, query });
132
+ const page = data;
133
+ yield* page.items;
134
+ if (!page.next_cursor)
135
+ return;
136
+ if (page.next_cursor === cursor)
137
+ throw new Error(`Pagination of ${path} did not advance (cursor repeated).`);
138
+ cursor = page.next_cursor;
139
+ }
140
+ }
141
+ async #data(method, path, options) {
142
+ return (await this.#send(method, path, options)).data;
143
+ }
144
+ async #send(method, template, raw) {
145
+ if (!methods.includes(method))
146
+ throw new TypeError(`Unsupported method ${method}.`);
147
+ const options = (raw ?? {});
148
+ const path = expandPath(template, options.path);
149
+ const url = `${this.#baseUrl}${path}${searchOf(options.query)}`;
150
+ const headers = new Headers({ ...this.#options.headers, ...options.headers });
151
+ headers.set('accept', 'application/json');
152
+ if (this.#options.apiKey)
153
+ headers.set('authorization', `Bearer ${this.#options.apiKey}`);
154
+ const workspace = options.workspace ?? this.#options.workspace;
155
+ if (workspace)
156
+ headers.set('x-workspace', workspace);
157
+ let body;
158
+ if (options.body !== undefined) {
159
+ body = JSON.stringify(options.body);
160
+ headers.set('content-type', 'application/json');
161
+ }
162
+ const idempotencyKey = method === 'post'
163
+ ? (options.idempotencyKey ?? (this.#maxRetries > 0 ? crypto.randomUUID() : null))
164
+ : null;
165
+ if (idempotencyKey)
166
+ headers.set('idempotency-key', idempotencyKey);
167
+ // A 5xx or a lost connection may follow a write that happened: retry only when repeating
168
+ // it is harmless (GET/PUT/DELETE, or a POST the API deduplicates by its Idempotency-Key).
169
+ const repeatable = method !== 'patch' && (method !== 'post' || idempotencyKey !== null);
170
+ const fetcher = this.#options.fetch ?? ((input, init) => fetch(input, init));
171
+ const init = {
172
+ method: method.toUpperCase(),
173
+ headers,
174
+ ...(body !== undefined ? { body } : {}),
175
+ ...(options.signal ? { signal: options.signal } : {}),
176
+ };
177
+ for (let attempt = 0;; attempt++) {
178
+ let response;
179
+ try {
180
+ response = await fetcher(url, init);
181
+ }
182
+ catch (error) {
183
+ if (options.signal?.aborted || !repeatable || attempt >= this.#maxRetries)
184
+ throw error;
185
+ await sleep(backoff(attempt), options.signal);
186
+ continue;
187
+ }
188
+ const data = await bodyOf(response);
189
+ if (response.ok)
190
+ return { data, status: response.status, headers: response.headers };
191
+ const retryAfter = parseRetryAfter(response.headers.get('retry-after'));
192
+ const retryable = response.status === 429 || (retryableStatus.has(response.status) && repeatable);
193
+ const delay = retryAfter === null ? backoff(attempt) : retryAfter * 1000;
194
+ if (retryable && attempt < this.#maxRetries && delay <= this.#maxRetryDelayMs) {
195
+ await sleep(delay, options.signal);
196
+ continue;
197
+ }
198
+ throw new UpVoicedError({
199
+ status: response.status,
200
+ ...errorShape(response.status, response.statusText, data),
201
+ method: method.toUpperCase(),
202
+ path,
203
+ retryAfter,
204
+ idempotencyKey,
205
+ body: data,
206
+ });
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,43 @@
1
+ /** One failed field or rule, as the API reports it in `error.details`. */
2
+ export interface ErrorDetail {
3
+ path: string;
4
+ code: string;
5
+ }
6
+ export interface UpVoicedErrorInit {
7
+ status: number;
8
+ code: string;
9
+ message: string;
10
+ details: readonly ErrorDetail[];
11
+ method: string;
12
+ path: string;
13
+ retryAfter: number | null;
14
+ idempotencyKey: string | null;
15
+ body: unknown;
16
+ }
17
+ /**
18
+ * P2-015 (ADR-0067): every non-2xx answer becomes an UpVoicedError carrying the API's error
19
+ * shape `{ error: { code, message, details? } }` (spec 05). Bodies that are not that shape
20
+ * (a proxy's HTML 502) get `code: "http_<status>"`.
21
+ */
22
+ export declare class UpVoicedError extends Error {
23
+ readonly name = "UpVoicedError";
24
+ /** HTTP status. */
25
+ readonly status: number;
26
+ /** Stable machine-readable code (`insufficient_scope`, `not_found`, `rate_limited`, …). */
27
+ readonly code: string;
28
+ readonly details: readonly ErrorDetail[];
29
+ readonly method: string;
30
+ /** Request path without the query string. */
31
+ readonly path: string;
32
+ /** Seconds from `Retry-After` (429/503), else null. */
33
+ readonly retryAfter: number | null;
34
+ /** The Idempotency-Key the request carried; reuse it to retry the same POST safely. */
35
+ readonly idempotencyKey: string | null;
36
+ /** The parsed (or raw text) response body. */
37
+ readonly body: unknown;
38
+ constructor(init: UpVoicedErrorInit);
39
+ }
40
+ /** Reads `{ error: { code, message, details? } }`, tolerating anything else. */
41
+ export declare function errorShape(status: number, statusText: string, body: unknown): Pick<UpVoicedErrorInit, 'code' | 'message' | 'details'>;
42
+ /** `Retry-After` as seconds: delta-seconds or an HTTP date; null when absent or invalid. */
43
+ export declare function parseRetryAfter(value: string | null, now?: number): number | null;
package/dist/errors.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * P2-015 (ADR-0067): every non-2xx answer becomes an UpVoicedError carrying the API's error
3
+ * shape `{ error: { code, message, details? } }` (spec 05). Bodies that are not that shape
4
+ * (a proxy's HTML 502) get `code: "http_<status>"`.
5
+ */
6
+ export class UpVoicedError extends Error {
7
+ name = 'UpVoicedError';
8
+ /** HTTP status. */
9
+ status;
10
+ /** Stable machine-readable code (`insufficient_scope`, `not_found`, `rate_limited`, …). */
11
+ code;
12
+ details;
13
+ method;
14
+ /** Request path without the query string. */
15
+ path;
16
+ /** Seconds from `Retry-After` (429/503), else null. */
17
+ retryAfter;
18
+ /** The Idempotency-Key the request carried; reuse it to retry the same POST safely. */
19
+ idempotencyKey;
20
+ /** The parsed (or raw text) response body. */
21
+ body;
22
+ constructor(init) {
23
+ super(init.message);
24
+ this.status = init.status;
25
+ this.code = init.code;
26
+ this.details = init.details;
27
+ this.method = init.method;
28
+ this.path = init.path;
29
+ this.retryAfter = init.retryAfter;
30
+ this.idempotencyKey = init.idempotencyKey;
31
+ this.body = init.body;
32
+ }
33
+ }
34
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ const detailsOf = (value) => Array.isArray(value)
36
+ ? value.flatMap((item) => isRecord(item) && typeof item['path'] === 'string' && typeof item['code'] === 'string'
37
+ ? [{ path: item['path'], code: item['code'] }]
38
+ : [])
39
+ : [];
40
+ /** Reads `{ error: { code, message, details? } }`, tolerating anything else. */
41
+ export function errorShape(status, statusText, body) {
42
+ const error = isRecord(body) ? body['error'] : undefined;
43
+ if (isRecord(error) && typeof error['code'] === 'string')
44
+ return {
45
+ code: error['code'],
46
+ message: typeof error['message'] === 'string' ? error['message'] : error['code'],
47
+ details: detailsOf(error['details']),
48
+ };
49
+ return {
50
+ code: `http_${String(status)}`,
51
+ message: statusText || `HTTP ${String(status)}`,
52
+ details: [],
53
+ };
54
+ }
55
+ /** `Retry-After` as seconds: delta-seconds or an HTTP date; null when absent or invalid. */
56
+ export function parseRetryAfter(value, now = Date.now()) {
57
+ if (value === null || value.trim() === '')
58
+ return null;
59
+ const trimmed = value.trim();
60
+ if (/^\d+$/.test(trimmed))
61
+ return Number(trimmed);
62
+ const at = Date.parse(trimmed);
63
+ return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000));
64
+ }