@vxil/sdk 0.3.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/README.md +83 -6
- package/dist/index.d.ts +753 -58
- package/dist/index.js +402 -267
- package/dist/qs.d.ts +15 -0
- package/dist/qs.js +48 -0
- package/dist/retry.d.ts +47 -0
- package/dist/retry.js +156 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/retry.d.ts
ADDED
|
@@ -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