@recapi/client 0.0.0-stage → 0.1.2
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 +21 -0
- package/README.md +560 -2
- package/index.d.ts +188 -0
- package/package.json +26 -4
- package/src/client.js +678 -0
- package/src/history.js +500 -0
- package/src/index.js +20 -0
- package/src/storage.js +156 -0
- package/src/transport.js +195 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP layer: fetch with timeout, JSON, error mapping and retry.
|
|
3
|
+
*
|
|
4
|
+
* Page-hide flushing uses `fetch(..., { keepalive: true })` rather than
|
|
5
|
+
* `navigator.sendBeacon`: a beacon cannot carry the `Authorization` header and
|
|
6
|
+
* the API only accepts `?key=` on `GET /v1/popular`. A keepalive fetch
|
|
7
|
+
* survives page unload like a beacon does, as long as in-flight keepalive
|
|
8
|
+
* bodies stay under the browser's 64 KiB budget.
|
|
9
|
+
*
|
|
10
|
+
* @module transport
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Keepalive requests above the browser's 64 KiB budget are rejected outright. */
|
|
14
|
+
const KEEPALIVE_MAX_BYTES = 60000;
|
|
15
|
+
|
|
16
|
+
const CODE_BY_STATUS = {
|
|
17
|
+
400: 'invalid_request',
|
|
18
|
+
401: 'unauthorized',
|
|
19
|
+
403: 'forbidden',
|
|
20
|
+
404: 'not_found',
|
|
21
|
+
413: 'too_large',
|
|
22
|
+
429: 'rate_limited'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every failure surfaced by the SDK. `status` is 0 when no HTTP response was
|
|
27
|
+
* received (`network`, `timeout`) or the error is client-side.
|
|
28
|
+
*/
|
|
29
|
+
export class RecapiError extends Error {
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} code API error code (`invalid_request`, `rate_limited`…)
|
|
32
|
+
* or a client code (`network`, `timeout`, `invalid_response`,
|
|
33
|
+
* `event_rejected`, `queue_overflow`).
|
|
34
|
+
* @param {string} message
|
|
35
|
+
* @param {{ status?: number, retryAfterMs?: number, details?: unknown, cause?: unknown }} [extra]
|
|
36
|
+
*/
|
|
37
|
+
constructor(code, message, { status = 0, retryAfterMs, details, cause } = {}) {
|
|
38
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
39
|
+
this.name = 'RecapiError';
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.retryAfterMs = retryAfterMs;
|
|
43
|
+
this.details = details;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether trying again later can succeed. `quota_exceeded` is a 429 too,
|
|
48
|
+
* but retrying it only burns requests until the quota resets.
|
|
49
|
+
*
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
get retryable() {
|
|
53
|
+
if (this.code === 'network' || this.code === 'timeout') return true;
|
|
54
|
+
if (this.status === 429) return this.code !== 'quota_exceeded';
|
|
55
|
+
return this.status >= 500;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} RequestOptions
|
|
61
|
+
* @property {'GET' | 'POST'} method
|
|
62
|
+
* @property {string} path
|
|
63
|
+
* @property {Record<string, string | number | undefined>} [query]
|
|
64
|
+
* @property {unknown} [body]
|
|
65
|
+
* @property {'header' | 'query'} [auth] `query` sends `?key=` so GET
|
|
66
|
+
* responses are CDN-cacheable and need no CORS preflight.
|
|
67
|
+
* @property {boolean} [keepalive] Let the request outlive the page.
|
|
68
|
+
* @property {number} [timeoutMs]
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {{ baseUrl: string, apiKey: string, fetch: typeof fetch, timeoutMs: number }} options
|
|
73
|
+
* @returns {{ request: (options: RequestOptions) => Promise<any> }}
|
|
74
|
+
*/
|
|
75
|
+
export function createTransport({ baseUrl, apiKey, fetch, timeoutMs: defaultTimeoutMs }) {
|
|
76
|
+
const root = baseUrl.replace(/\/+$/, '');
|
|
77
|
+
|
|
78
|
+
/** @param {RequestOptions} options */
|
|
79
|
+
async function request({ method, path, query, body, auth = 'header', keepalive = false, timeoutMs }) {
|
|
80
|
+
const params = new URLSearchParams();
|
|
81
|
+
for (const [name, value] of Object.entries(query ?? {})) {
|
|
82
|
+
if (value !== undefined) params.set(name, String(value));
|
|
83
|
+
}
|
|
84
|
+
if (auth === 'query') params.set('key', apiKey);
|
|
85
|
+
const qs = params.toString();
|
|
86
|
+
const url = `${root}${path}${qs ? `?${qs}` : ''}`;
|
|
87
|
+
|
|
88
|
+
const headers = { Accept: 'application/json' };
|
|
89
|
+
if (auth === 'header') headers.Authorization = `Bearer ${apiKey}`;
|
|
90
|
+
const init = { method, headers };
|
|
91
|
+
if (body !== undefined) {
|
|
92
|
+
headers['Content-Type'] = 'application/json';
|
|
93
|
+
init.body = JSON.stringify(body);
|
|
94
|
+
if (keepalive && byteLength(init.body) <= KEEPALIVE_MAX_BYTES) init.keepalive = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
98
|
+
if (controller) init.signal = controller.signal;
|
|
99
|
+
const ms = timeoutMs ?? defaultTimeoutMs;
|
|
100
|
+
const timer = controller ? setTimeout(() => controller.abort(), ms) : null;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await fetch(url, init);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw transportError(error, controller, ms);
|
|
108
|
+
}
|
|
109
|
+
let text;
|
|
110
|
+
try {
|
|
111
|
+
text = await response.text();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw transportError(error, controller, ms);
|
|
114
|
+
}
|
|
115
|
+
const json = parseJson(text);
|
|
116
|
+
if (!response.ok) throw httpError(response, json);
|
|
117
|
+
return json;
|
|
118
|
+
} finally {
|
|
119
|
+
if (timer) clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { request };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function byteLength(text) {
|
|
127
|
+
return typeof TextEncoder === 'function' ? new TextEncoder().encode(text).length : text.length * 3;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function transportError(error, controller, ms) {
|
|
131
|
+
if (controller?.signal.aborted) {
|
|
132
|
+
return new RecapiError('timeout', `request timed out after ${ms} ms`, { cause: error });
|
|
133
|
+
}
|
|
134
|
+
return new RecapiError('network', error?.message || 'network error', { cause: error });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseJson(text) {
|
|
138
|
+
if (!text) return null;
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(text);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function httpError(response, json) {
|
|
147
|
+
const status = response.status;
|
|
148
|
+
const code = json?.error?.code || CODE_BY_STATUS[status] || (status >= 500 ? 'internal' : 'http_error');
|
|
149
|
+
const message = json?.error?.message || `HTTP ${status}`;
|
|
150
|
+
return new RecapiError(code, message, { status, retryAfterMs: retryAfter(response) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function retryAfter(response) {
|
|
154
|
+
const header = response.headers?.get?.('retry-after');
|
|
155
|
+
const seconds = header ? Number(header) : NaN;
|
|
156
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Retries `fn` on retryable {@link RecapiError}s with exponential backoff
|
|
161
|
+
* and jitter (honouring `Retry-After`, capped at `maxDelayMs`). Only used for
|
|
162
|
+
* events: a recommendation that arrives seconds late is useless, so
|
|
163
|
+
* `recommend()` fails fast and falls back to popular items instead.
|
|
164
|
+
*
|
|
165
|
+
* @template T
|
|
166
|
+
* @param {() => Promise<T>} fn
|
|
167
|
+
* @param {{ retries?: number, baseDelayMs?: number, maxDelayMs?: number,
|
|
168
|
+
* sleep?: (ms: number) => Promise<void>, random?: () => number }} [options]
|
|
169
|
+
* @returns {Promise<T>}
|
|
170
|
+
*/
|
|
171
|
+
export async function withRetry(fn, {
|
|
172
|
+
retries = 3,
|
|
173
|
+
baseDelayMs = 500,
|
|
174
|
+
maxDelayMs = 8000,
|
|
175
|
+
sleep = defaultSleep,
|
|
176
|
+
random = Math.random
|
|
177
|
+
} = {}) {
|
|
178
|
+
for (let attempt = 0; ; attempt++) {
|
|
179
|
+
try {
|
|
180
|
+
return await fn();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (attempt >= retries || !(error instanceof RecapiError) || !error.retryable) throw error;
|
|
183
|
+
const backoff = baseDelayMs * 2 ** attempt * (0.5 + random() / 2);
|
|
184
|
+
await sleep(Math.min(maxDelayMs, error.retryAfterMs ?? backoff));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {number} ms
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
export function defaultSleep(ms) {
|
|
194
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
195
|
+
}
|