@vxil/sdk 0.2.0 → 0.4.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/qs.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** A single query value: strings go verbatim, numbers/booleans stringify
2
+ * (`0` → `"0"`, `true` → `"true"`), an array joins with `,` (an empty array is
3
+ * omitted, like `undefined`). `undefined`/`null` entries are omitted. */
4
+ export type QsScalar = string | number | boolean;
5
+ export type QsValue = QsScalar | ReadonlyArray<QsScalar> | undefined | null;
6
+ /** Percent-encode one key or value the way the application/x-www-form-urlencoded
7
+ * serializer does: `encodeURIComponent`, then `!'()~` percent-encoded and
8
+ * space as `+`. Exported for reuse; every query the client sends goes through
9
+ * it. */
10
+ export declare function formEncode(s: string): string;
11
+ /** Build a query SUFFIX from a key → value bag: `''` when no entry survives,
12
+ * else `?k=v&k2=v2` in insertion order. Omits `undefined`/`null` (and empty
13
+ * arrays); stringifies numbers and booleans; joins arrays with `,`. Pure, no
14
+ * globals — works on every JS engine, React Native's Hermes included. */
15
+ export declare function qs(params: Record<string, QsValue>): string;
package/dist/qs.js ADDED
@@ -0,0 +1,48 @@
1
+ // Query-string builder — the SDK's replacement for the WHATWG `URLSearchParams`
2
+ // at every call site (F7-44, the React-Native-clean SDK).
3
+ //
4
+ // WHY (implementation note; `//` comments never reach the shipped .d.ts):
5
+ // React Native supplies its own URLSearchParams polyfill, and up to RN 0.79
6
+ // (Expo SDK ≤ 53) that polyfill is a stub — `set`/`get`/`has`/`delete` THROW
7
+ // "URLSearchParams.set is not implemented" and there is no `size` getter — so
8
+ // the previous `qs.set(...)` / `qs.size ? … : ''` pattern threw (or silently
9
+ // dropped every parameter) on every list call under Hermes. The citation and a
10
+ // faithful runtime replica of that polyfill live in hermes.smoke.test.ts. This
11
+ // module uses only `encodeURIComponent` + string ops, which every JS engine has.
12
+ //
13
+ // The bytes are IDENTICAL to what `new URLSearchParams(...).toString()` produced
14
+ // (application/x-www-form-urlencoded: space → `+`, `!'()~` percent-encoded),
15
+ // so nothing on the wire changes — qs.test.ts pins that against Node's real
16
+ // URLSearchParams.
17
+ /** Percent-encode one key or value the way the application/x-www-form-urlencoded
18
+ * serializer does: `encodeURIComponent`, then `!'()~` percent-encoded and
19
+ * space as `+`. Exported for reuse; every query the client sends goes through
20
+ * it. */
21
+ export function formEncode(s) {
22
+ return encodeURIComponent(s)
23
+ .replace(/[!'()~]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
24
+ .replace(/%20/g, '+');
25
+ }
26
+ /** Build a query SUFFIX from a key → value bag: `''` when no entry survives,
27
+ * else `?k=v&k2=v2` in insertion order. Omits `undefined`/`null` (and empty
28
+ * arrays); stringifies numbers and booleans; joins arrays with `,`. Pure, no
29
+ * globals — works on every JS engine, React Native's Hermes included. */
30
+ export function qs(params) {
31
+ let out = '';
32
+ for (const key of Object.keys(params)) {
33
+ const v = params[key];
34
+ if (v === undefined || v === null)
35
+ continue;
36
+ let s;
37
+ if (Array.isArray(v)) {
38
+ if (v.length === 0)
39
+ continue;
40
+ s = v.map(String).join(',');
41
+ }
42
+ else {
43
+ s = String(v);
44
+ }
45
+ out += `${out ? '&' : '?'}${formEncode(key)}=${formEncode(s)}`;
46
+ }
47
+ return out;
48
+ }
@@ -0,0 +1,47 @@
1
+ import type { VxilHooks, VxilRetryOptions } from './index.js';
2
+ export declare const DEFAULT_RETRY_ON: ReadonlyArray<number>;
3
+ export declare const DEFAULT_BACKOFF_MS = 250;
4
+ export declare const DEFAULT_MAX_BACKOFF_MS = 10000;
5
+ /** Parse a `Retry-After` header into SECONDS (a non-negative number), or
6
+ * `undefined` when absent/unparseable. Accepts delta-seconds (`30`, `1.5`)
7
+ * and an HTTP-date (`Wed, 21 Oct 2026 07:28:00 GMT`, measured from `nowMs`,
8
+ * floored at 0). */
9
+ export declare function parseRetryAfter(value: string | null | undefined, nowMs?: number): number | undefined;
10
+ /** The retry gate: GET/HEAD/PUT/DELETE/OPTIONS are idempotent by definition; a
11
+ * POST only when it carries a non-empty `Idempotency-Key` (any header case);
12
+ * PATCH and a bare POST never. */
13
+ export declare function isIdempotentRequest(method: string, headers: Record<string, string>): boolean;
14
+ /** Exponential backoff with equal jitter: `min(max, base·2^(attempt−1))`
15
+ * scaled into `[½, 1]` by `random()` (inject `() => 1` for a deterministic
16
+ * schedule). `attempt` is the attempt that just failed (1-based). */
17
+ export declare function backoffDelayMs(attempt: number, backoffMs: number, maxBackoffMs: number, random?: () => number): number;
18
+ export interface TransportInit {
19
+ method: string;
20
+ headers: Record<string, string>;
21
+ body?: string;
22
+ }
23
+ export interface TransportResult {
24
+ response: Response;
25
+ /** The body, fully read (so a retried attempt never leaks a stream and a
26
+ * `timeoutMs` covers the body too). */
27
+ text: string;
28
+ }
29
+ export interface TransportOptions {
30
+ fetch: typeof fetch;
31
+ retry?: VxilRetryOptions;
32
+ timeoutMs?: number;
33
+ hooks?: VxilHooks;
34
+ /** Builds the error thrown when an attempt exceeds `timeoutMs` (index.ts
35
+ * supplies a `VxilError` so callers catch one error class). */
36
+ timeoutError: (timeoutMs: number) => Error;
37
+ sleep?: (ms: number) => Promise<void>;
38
+ random?: () => number;
39
+ now?: () => number;
40
+ }
41
+ export interface Transport {
42
+ send(url: string, init: TransportInit): Promise<TransportResult>;
43
+ }
44
+ /** Build the transport the client routes every request through. With none of
45
+ * `retry` / `timeoutMs` / `hooks` set this is one `fetch` + `text()` — the
46
+ * pre-seam behaviour, byte for byte. */
47
+ export declare function createTransport(opts: TransportOptions): Transport;
package/dist/retry.js ADDED
@@ -0,0 +1,156 @@
1
+ // The request seam: retry + timeout + hooks around the ONE `fetch` every SDK
2
+ // request goes through (parity P1 #9), and the `Retry-After` parser
3
+ // `VxilError.retryAfter` shares (F7-44). DEFAULT OFF — with no `retry`,
4
+ // `timeoutMs` or `hooks` option the transport is a single `fetch` + `text()`,
5
+ // exactly what `call()` did before.
6
+ //
7
+ // Uses only `fetch`, `AbortController`, `setTimeout` and `Date` — all present
8
+ // in Node ≥ 18, browsers, edge runtimes and React Native.
9
+ // ─── the shared pieces ───────────────────────────────────────────────────────
10
+ export const DEFAULT_RETRY_ON = [429, 502, 503, 504];
11
+ export const DEFAULT_BACKOFF_MS = 250;
12
+ export const DEFAULT_MAX_BACKOFF_MS = 10_000;
13
+ /** Parse a `Retry-After` header into SECONDS (a non-negative number), or
14
+ * `undefined` when absent/unparseable. Accepts delta-seconds (`30`, `1.5`)
15
+ * and an HTTP-date (`Wed, 21 Oct 2026 07:28:00 GMT`, measured from `nowMs`,
16
+ * floored at 0). */
17
+ export function parseRetryAfter(value, nowMs = Date.now()) {
18
+ if (value === null || value === undefined)
19
+ return undefined;
20
+ const v = value.trim();
21
+ if (v === '')
22
+ return undefined;
23
+ if (/^\d+(\.\d+)?$/.test(v))
24
+ return Number(v);
25
+ const at = Date.parse(v);
26
+ if (Number.isNaN(at))
27
+ return undefined;
28
+ return Math.max(0, (at - nowMs) / 1000);
29
+ }
30
+ /** The retry gate: GET/HEAD/PUT/DELETE/OPTIONS are idempotent by definition; a
31
+ * POST only when it carries a non-empty `Idempotency-Key` (any header case);
32
+ * PATCH and a bare POST never. */
33
+ export function isIdempotentRequest(method, headers) {
34
+ const m = method.toUpperCase();
35
+ if (m === 'GET' || m === 'HEAD' || m === 'PUT' || m === 'DELETE' || m === 'OPTIONS')
36
+ return true;
37
+ if (m !== 'POST')
38
+ return false;
39
+ return Object.keys(headers).some((k) => k.toLowerCase() === 'idempotency-key' && headers[k] !== '');
40
+ }
41
+ /** Exponential backoff with equal jitter: `min(max, base·2^(attempt−1))`
42
+ * scaled into `[½, 1]` by `random()` (inject `() => 1` for a deterministic
43
+ * schedule). `attempt` is the attempt that just failed (1-based). */
44
+ export function backoffDelayMs(attempt, backoffMs, maxBackoffMs, random = Math.random) {
45
+ const capped = Math.min(maxBackoffMs, backoffMs * 2 ** Math.max(0, attempt - 1));
46
+ return Math.round(capped / 2 + (capped / 2) * random());
47
+ }
48
+ /** Headers a hook never sees and can never set. */
49
+ const CREDENTIAL_HEADERS = new Set(['authorization', 'x-vxil-end-user']);
50
+ function describeRequest(method, url, headers, attempt) {
51
+ const visible = {};
52
+ for (const k of Object.keys(headers)) {
53
+ if (!CREDENTIAL_HEADERS.has(k.toLowerCase()))
54
+ visible[k] = headers[k];
55
+ }
56
+ return Object.freeze({ method, url, headers: Object.freeze(visible), attempt });
57
+ }
58
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
+ /** One attempt: `fetch` + read the body, under an optional per-attempt
60
+ * timeout. On timeout the controller aborts the fetch (and its body stream),
61
+ * so nothing keeps running after the error is thrown; the timer is always
62
+ * cleared. */
63
+ async function attemptOnce(fetchImpl, url, init, timeoutMs, timeoutError) {
64
+ if (!timeoutMs) {
65
+ const response = await fetchImpl(url, init);
66
+ return { response, text: await response.text() };
67
+ }
68
+ const controller = new AbortController();
69
+ let timedOut = false;
70
+ const timer = setTimeout(() => {
71
+ timedOut = true;
72
+ controller.abort();
73
+ }, timeoutMs);
74
+ try {
75
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
76
+ const text = await response.text();
77
+ return { response, text };
78
+ }
79
+ catch (e) {
80
+ if (timedOut)
81
+ throw timeoutError(timeoutMs);
82
+ throw e;
83
+ }
84
+ finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+ /** Build the transport the client routes every request through. With none of
89
+ * `retry` / `timeoutMs` / `hooks` set this is one `fetch` + `text()` — the
90
+ * pre-seam behaviour, byte for byte. */
91
+ export function createTransport(opts) {
92
+ const attempts = Math.max(0, Math.floor(opts.retry?.attempts ?? 0));
93
+ const retryOn = new Set(opts.retry?.retryOn ?? DEFAULT_RETRY_ON);
94
+ const backoffMs = opts.retry?.backoffMs ?? DEFAULT_BACKOFF_MS;
95
+ const maxBackoffMs = opts.retry?.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
96
+ const respectRetryAfter = opts.retry?.respectRetryAfter ?? true;
97
+ const retryOnNetworkError = opts.retry?.retryOnNetworkError ?? true;
98
+ const timeoutMs = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : undefined;
99
+ const hooks = opts.hooks ?? {};
100
+ const sleep = opts.sleep ?? defaultSleep;
101
+ const random = opts.random ?? Math.random;
102
+ const now = opts.now ?? Date.now;
103
+ if (timeoutMs !== undefined && typeof AbortController !== 'function') {
104
+ throw new Error('@vxil/sdk: `timeoutMs` needs a runtime with AbortController');
105
+ }
106
+ return {
107
+ async send(url, init) {
108
+ const idempotent = isIdempotentRequest(init.method, init.headers);
109
+ for (let attempt = 1;; attempt++) {
110
+ const headers = { ...init.headers };
111
+ const request = describeRequest(init.method, url, headers, attempt);
112
+ const extra = await hooks.beforeRequest?.(request);
113
+ if (extra) {
114
+ for (const k of Object.keys(extra)) {
115
+ if (!CREDENTIAL_HEADERS.has(k.toLowerCase()))
116
+ headers[k] = extra[k];
117
+ }
118
+ }
119
+ const canRetry = attempt <= attempts && idempotent;
120
+ const started = now();
121
+ let result;
122
+ let failure;
123
+ try {
124
+ result = await attemptOnce(opts.fetch, url, { ...init, headers }, timeoutMs, opts.timeoutError);
125
+ }
126
+ catch (e) {
127
+ failure = e;
128
+ }
129
+ if (result) {
130
+ await hooks.afterResponse?.({ request, response: result.response, durationMs: now() - started });
131
+ const status = result.response.status;
132
+ if (!(canRetry && retryOn.has(status)))
133
+ return result;
134
+ const retryAfter = respectRetryAfter
135
+ ? parseRetryAfter(result.response.headers.get('retry-after'), now())
136
+ : undefined;
137
+ const delayMs = retryAfter !== undefined
138
+ ? Math.round(retryAfter * 1000)
139
+ : backoffDelayMs(attempt, backoffMs, maxBackoffMs, random);
140
+ // Never wait longer than the cap — and never retry EARLIER than the
141
+ // server asked: a Retry-After beyond the cap ends the loop instead.
142
+ if (delayMs > maxBackoffMs)
143
+ return result;
144
+ await hooks.onRetry?.({ request, delayMs, status });
145
+ await sleep(delayMs);
146
+ continue;
147
+ }
148
+ if (!(canRetry && retryOnNetworkError))
149
+ throw failure;
150
+ const delayMs = backoffDelayMs(attempt, backoffMs, maxBackoffMs, random);
151
+ await hooks.onRetry?.({ request, delayMs, error: failure });
152
+ await sleep(delayMs);
153
+ }
154
+ },
155
+ };
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Typed client for the Vxil REST API (notifications, auth, jobs, files, cms, comments, webhooks, realtime, orgs, rate-limits).",