@cplieger/ui 0.0.0-stage → 1.0.0-dev.1
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 +202 -0
- package/NOTICE +3 -0
- package/README.md +157 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +44 -0
- package/package.json +105 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -0
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- package/src/signal/store.ts +84 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import type { FetchConfig } from "./types.js";
|
|
2
|
+
import { API_TIMEOUT_MS, requestSignal, type AbortSource } from "./timeout.js";
|
|
3
|
+
import type { AnyRequestOptions, ApiErr, ApiResult, FailedRequest, HttpMethod } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** The single implementation behind every `RequestRawFn` overload. */
|
|
6
|
+
export type RequestRawCore = <T>(
|
|
7
|
+
method: HttpMethod,
|
|
8
|
+
path: string,
|
|
9
|
+
opts?: AnyRequestOptions<T>,
|
|
10
|
+
) => Promise<ApiResult<T>>;
|
|
11
|
+
|
|
12
|
+
/** The single implementation behind every `RequestFn` overload. */
|
|
13
|
+
export type RequestCore = <T>(
|
|
14
|
+
method: HttpMethod,
|
|
15
|
+
path: string,
|
|
16
|
+
opts?: AnyRequestOptions<T>,
|
|
17
|
+
) => Promise<T | null>;
|
|
18
|
+
|
|
19
|
+
const JSON_CT = "application/json";
|
|
20
|
+
|
|
21
|
+
/** Extract a human-readable message from an unknown thrown value without
|
|
22
|
+
* risking a `[object Object]` stringification. */
|
|
23
|
+
function errMsg(e: unknown): string {
|
|
24
|
+
if (e instanceof Error || e instanceof DOMException) {
|
|
25
|
+
return e.message;
|
|
26
|
+
}
|
|
27
|
+
if (typeof e === "string") {
|
|
28
|
+
return e;
|
|
29
|
+
}
|
|
30
|
+
return "unknown error";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Narrow an unknown parsed body to an indexable object. */
|
|
34
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
35
|
+
return typeof v === "object" && v !== null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build an ApiErr, omitting optional fields when absent
|
|
39
|
+
* (exactOptionalPropertyTypes). `headers`/`body` are passed only where a real
|
|
40
|
+
* HTTP response exists, per the ApiErr contract. */
|
|
41
|
+
function makeErr(
|
|
42
|
+
status: number,
|
|
43
|
+
error: string,
|
|
44
|
+
code?: string,
|
|
45
|
+
requestId?: string,
|
|
46
|
+
headers?: Headers,
|
|
47
|
+
body?: unknown,
|
|
48
|
+
): ApiErr {
|
|
49
|
+
const err: {
|
|
50
|
+
ok: false;
|
|
51
|
+
status: number;
|
|
52
|
+
error: string;
|
|
53
|
+
code?: string;
|
|
54
|
+
requestId?: string;
|
|
55
|
+
headers?: Headers;
|
|
56
|
+
body?: unknown;
|
|
57
|
+
} = {
|
|
58
|
+
ok: false,
|
|
59
|
+
status,
|
|
60
|
+
error,
|
|
61
|
+
};
|
|
62
|
+
if (code !== undefined) {
|
|
63
|
+
err.code = code;
|
|
64
|
+
}
|
|
65
|
+
if (requestId !== undefined) {
|
|
66
|
+
err.requestId = requestId;
|
|
67
|
+
}
|
|
68
|
+
if (headers !== undefined) {
|
|
69
|
+
err.headers = headers;
|
|
70
|
+
}
|
|
71
|
+
if (body !== undefined) {
|
|
72
|
+
err.body = body;
|
|
73
|
+
}
|
|
74
|
+
return err;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Coerce a possibly-non-numeric status (a custom fetchFn may return a
|
|
78
|
+
* malformed object) to a number, defaulting to 0. */
|
|
79
|
+
function statusOf(res: Response): number {
|
|
80
|
+
return typeof res.status === "number" ? res.status : 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Neutralize a relative path's navigation syntax before it joins a base URL,
|
|
84
|
+
* so URL normalization cannot pop the base path prefix. Backslashes, and in
|
|
85
|
+
* the path part ASCII TAB/LF/CR (which the WHATWG parser strips, fusing two
|
|
86
|
+
* dots into a live `..`), are percent-encoded. Every dot-segment, encoded or
|
|
87
|
+
* not, is double-encoded to `%252E`: the parser still pops `%2E`. */
|
|
88
|
+
function safeSuffix(path: string): string {
|
|
89
|
+
// Query (`?`) / fragment (`#`) are isolated before segment processing: the
|
|
90
|
+
// URL parser does not path-normalize them and they must reach the server
|
|
91
|
+
// verbatim.
|
|
92
|
+
const marks = [path.indexOf("?"), path.indexOf("#")].filter((i) => i !== -1);
|
|
93
|
+
const sep = marks.length > 0 ? Math.min(...marks) : -1;
|
|
94
|
+
const pathPart = sep === -1 ? path : path.slice(0, sep);
|
|
95
|
+
const rest = sep === -1 ? "" : path.slice(sep);
|
|
96
|
+
const raw = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
|
|
97
|
+
const encoded = raw
|
|
98
|
+
.replace(/\\/g, "%5C")
|
|
99
|
+
.replace(/\t/g, "%09")
|
|
100
|
+
.replace(/\n/g, "%0A")
|
|
101
|
+
.replace(/\r/g, "%0D")
|
|
102
|
+
.split("/")
|
|
103
|
+
.map((segment) => {
|
|
104
|
+
const dotLike = segment.replace(/%2e/gi, ".");
|
|
105
|
+
if (dotLike === "." || dotLike === "..") {
|
|
106
|
+
return segment.replace(/\./g, "%2E").replace(/%/g, "%25");
|
|
107
|
+
}
|
|
108
|
+
return segment;
|
|
109
|
+
})
|
|
110
|
+
.join("/");
|
|
111
|
+
return encoded + rest;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Join a base URL with a relative path: strip a trailing slash from the base,
|
|
116
|
+
* then append the path via {@link safeSuffix}. With no base, the path is
|
|
117
|
+
* returned verbatim.
|
|
118
|
+
*/
|
|
119
|
+
function joinUrl(baseUrl: string | undefined, path: string): string {
|
|
120
|
+
if (baseUrl === undefined) {
|
|
121
|
+
return path;
|
|
122
|
+
}
|
|
123
|
+
const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
124
|
+
return `${base}${safeSuffix(path)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Merge caller-supplied headers (object or Headers) into the target. */
|
|
128
|
+
function mergeHeaders(target: Headers, source: Record<string, string> | Headers | undefined): void {
|
|
129
|
+
if (source === undefined) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (source instanceof Headers) {
|
|
133
|
+
source.forEach((value, key) => {
|
|
134
|
+
target.set(key, value);
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const [key, value] of Object.entries(source)) {
|
|
139
|
+
target.set(key, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The caller-aborted-wins envelope shared by both classifiers. */
|
|
144
|
+
function cancelledErr(): ApiErr {
|
|
145
|
+
return makeErr(0, "request cancelled", "cancelled");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Classify a throw from the BUILD phase (header construction, JSON body
|
|
150
|
+
* encoding, `prepareHeaders`, timeout composition, url join). A caller that
|
|
151
|
+
* already aborted classifies as "cancelled"; every other build failure is a
|
|
152
|
+
* client-side "invalid" request — it never reached the network.
|
|
153
|
+
*/
|
|
154
|
+
function classifyBuildError(e: unknown, callerSignal: AbortSignal | undefined): ApiErr {
|
|
155
|
+
if (callerSignal?.aborted === true) {
|
|
156
|
+
return cancelledErr();
|
|
157
|
+
}
|
|
158
|
+
return makeErr(0, errMsg(e), "invalid");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Classify a thrown error from the FETCH / response-read phase. Priority:
|
|
163
|
+
* the caller's abort won the request signal → "cancelled"; DOMException
|
|
164
|
+
* TimeoutError / AbortError → "timeout"; everything else → "network".
|
|
165
|
+
*/
|
|
166
|
+
function classifyThrown(e: unknown, winner: AbortSource | undefined): ApiErr {
|
|
167
|
+
if (winner === "caller") {
|
|
168
|
+
return cancelledErr();
|
|
169
|
+
}
|
|
170
|
+
if (e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
171
|
+
return makeErr(0, e.message, "timeout");
|
|
172
|
+
}
|
|
173
|
+
return makeErr(0, errMsg(e), "network");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Start releasing a body the decided result no longer reads, without
|
|
177
|
+
* waiting: a cancel that throws, rejects or never settles cannot replace or
|
|
178
|
+
* hold that result. */
|
|
179
|
+
function releaseQuietly(cancel: () => Promise<void> | undefined): void {
|
|
180
|
+
try {
|
|
181
|
+
void cancel()?.catch(() => undefined);
|
|
182
|
+
} catch {
|
|
183
|
+
// A custom fetchFn's stream may throw where a platform one rejects.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read a response body's bytes, refusing more than `max`: a `content-length`
|
|
188
|
+
* over the cap is rejected up front and the streamed body is aborted the
|
|
189
|
+
* moment it exceeds the cap, so an untrusted upstream cannot force unbounded
|
|
190
|
+
* buffering. */
|
|
191
|
+
async function readChunks(res: Response, max: number): Promise<Uint8Array<ArrayBuffer>[]> {
|
|
192
|
+
const contentLength = res.headers.get("content-length");
|
|
193
|
+
if (contentLength !== null && Number(contentLength) > max) {
|
|
194
|
+
throw new Error(`response exceeds ${max} bytes`);
|
|
195
|
+
}
|
|
196
|
+
const body = res.body;
|
|
197
|
+
if (body === null) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
const reader = body.getReader();
|
|
201
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
202
|
+
let total = 0;
|
|
203
|
+
for (;;) {
|
|
204
|
+
const result = await reader.read();
|
|
205
|
+
if (result.done) {
|
|
206
|
+
return chunks;
|
|
207
|
+
}
|
|
208
|
+
total += result.value.byteLength;
|
|
209
|
+
if (total > max) {
|
|
210
|
+
releaseQuietly(() => reader.cancel());
|
|
211
|
+
throw new Error(`response exceeds ${max} bytes`);
|
|
212
|
+
}
|
|
213
|
+
chunks.push(result.value);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Read a response body as text, bounded to `max` bytes when set. */
|
|
218
|
+
async function readBoundedText(res: Response, max: number | undefined): Promise<string> {
|
|
219
|
+
if (max === undefined) {
|
|
220
|
+
return res.text();
|
|
221
|
+
}
|
|
222
|
+
return new Blob(await readChunks(res, max)).text();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Read a response body as a `Blob` typed by its `Content-Type`, bounded to
|
|
226
|
+
* `max` bytes when set. */
|
|
227
|
+
async function readBoundedBlob(res: Response, max: number | undefined): Promise<Blob> {
|
|
228
|
+
const type = res.headers.get("content-type") ?? "";
|
|
229
|
+
return new Blob(await readChunks(res, max ?? Number.POSITIVE_INFINITY), { type });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Parse a non-2xx response body, lifting error / code / request_id fields. */
|
|
233
|
+
async function parseErrorResponse(res: Response, max: number | undefined): Promise<ApiErr> {
|
|
234
|
+
const status = statusOf(res);
|
|
235
|
+
let error = `HTTP ${status}`;
|
|
236
|
+
let code: string | undefined;
|
|
237
|
+
let requestId: string | undefined;
|
|
238
|
+
let parsed: unknown;
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(await readBoundedText(res, max));
|
|
241
|
+
if (isRecord(parsed)) {
|
|
242
|
+
const errField = parsed["error"];
|
|
243
|
+
if (typeof errField === "string") {
|
|
244
|
+
error = errField;
|
|
245
|
+
}
|
|
246
|
+
const codeField = parsed["code"];
|
|
247
|
+
if (typeof codeField === "string") {
|
|
248
|
+
code = codeField;
|
|
249
|
+
}
|
|
250
|
+
const ridField = parsed["request_id"] ?? parsed["requestId"];
|
|
251
|
+
if (typeof ridField === "string") {
|
|
252
|
+
requestId = ridField;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Non-JSON / empty error body: keep the `HTTP <status>` fallback.
|
|
257
|
+
}
|
|
258
|
+
return makeErr(status, error, code, requestId, res.headers, parsed);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function logObserverError(e: unknown): void {
|
|
262
|
+
try {
|
|
263
|
+
console.error("@cplieger/fetch: onError threw", e);
|
|
264
|
+
} catch {
|
|
265
|
+
// A throwing console cannot reach the caller either.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Hand a failure to the instance's observer, which must never change or
|
|
270
|
+
* delay the result its caller receives. */
|
|
271
|
+
function notify(
|
|
272
|
+
onError: NonNullable<FetchConfig["onError"]>,
|
|
273
|
+
err: ApiErr,
|
|
274
|
+
req: FailedRequest,
|
|
275
|
+
): void {
|
|
276
|
+
try {
|
|
277
|
+
Promise.resolve(onError(err, req)).catch(logObserverError);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
logObserverError(e);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Build the non-throwing request core bound to an immutable config, captured
|
|
285
|
+
* once at instance construction ({@link createFetch}).
|
|
286
|
+
*/
|
|
287
|
+
export function makeRequestRaw(cfg: FetchConfig): RequestRawCore {
|
|
288
|
+
const perform = async function perform<T>(
|
|
289
|
+
method: HttpMethod,
|
|
290
|
+
path: string,
|
|
291
|
+
opts?: AnyRequestOptions<T>,
|
|
292
|
+
): Promise<ApiResult<T>> {
|
|
293
|
+
let callerSignal: AbortSignal | undefined;
|
|
294
|
+
let winner: () => AbortSource | undefined;
|
|
295
|
+
|
|
296
|
+
// --- Build phase ------------------------------------------------------
|
|
297
|
+
// A throw here is a client-side "invalid" request (or "cancelled" if the
|
|
298
|
+
// caller already aborted) — never hit the network.
|
|
299
|
+
const init: RequestInit = { method };
|
|
300
|
+
let url: string;
|
|
301
|
+
try {
|
|
302
|
+
callerSignal = opts?.signal;
|
|
303
|
+
const headers = new Headers();
|
|
304
|
+
if (opts?.rawBody !== undefined && opts.body != null) {
|
|
305
|
+
throw new TypeError("body and rawBody are mutually exclusive");
|
|
306
|
+
}
|
|
307
|
+
// The option types refuse this pairing; a JavaScript caller can still pass it.
|
|
308
|
+
const loose: { as?: unknown; decoder?: unknown; ignoreBody?: unknown } | undefined = opts;
|
|
309
|
+
if (loose?.as === "blob" && (loose.decoder !== undefined || loose.ignoreBody === true)) {
|
|
310
|
+
throw new TypeError('as: "blob" excludes decoder and ignoreBody');
|
|
311
|
+
}
|
|
312
|
+
if (method !== "GET" && opts?.rawBody !== undefined) {
|
|
313
|
+
init.body = opts.rawBody;
|
|
314
|
+
} else if (method !== "GET" && opts?.body != null) {
|
|
315
|
+
const encoded = JSON.stringify(opts.body) as string | undefined;
|
|
316
|
+
if (encoded === undefined) {
|
|
317
|
+
throw new TypeError("request body is not JSON-encodable");
|
|
318
|
+
}
|
|
319
|
+
headers.set("Content-Type", JSON_CT);
|
|
320
|
+
init.body = encoded;
|
|
321
|
+
}
|
|
322
|
+
mergeHeaders(headers, opts?.headers);
|
|
323
|
+
|
|
324
|
+
let effectiveHeaders = headers;
|
|
325
|
+
if (cfg.prepareHeaders !== undefined) {
|
|
326
|
+
const prepared = await cfg.prepareHeaders(headers);
|
|
327
|
+
if (prepared !== undefined) {
|
|
328
|
+
effectiveHeaders = prepared;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
init.headers = effectiveHeaders;
|
|
332
|
+
|
|
333
|
+
if (cfg.credentials !== undefined) {
|
|
334
|
+
init.credentials = cfg.credentials;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const composed = requestSignal(callerSignal, opts?.timeoutMs ?? API_TIMEOUT_MS);
|
|
338
|
+
init.signal = composed.signal;
|
|
339
|
+
winner = composed.winner;
|
|
340
|
+
url = joinUrl(cfg.baseUrl, path);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return classifyBuildError(e, callerSignal);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Fetch phase --------------------------------------------------------
|
|
346
|
+
// A throw here is a genuine network / timeout / cancellation failure.
|
|
347
|
+
let res: Response;
|
|
348
|
+
try {
|
|
349
|
+
const fetchImpl = cfg.fetchFn ?? fetch;
|
|
350
|
+
res = await fetchImpl(url, init);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
return classifyThrown(e, winner());
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Response phase -----------------------------------------------------
|
|
356
|
+
// The outer try preserves the never-throw guarantee for a malformed
|
|
357
|
+
// result from a custom fetchFn and for a mid-body read failure. A
|
|
358
|
+
// JSON.parse / decoder throw is a "decode" error, handled by its own
|
|
359
|
+
// inner try before it can reach here.
|
|
360
|
+
try {
|
|
361
|
+
if (!res.ok) {
|
|
362
|
+
return await parseErrorResponse(res, cfg.maxResponseBytes);
|
|
363
|
+
}
|
|
364
|
+
const status = statusOf(res);
|
|
365
|
+
if (opts?.as === "blob") {
|
|
366
|
+
const blob = await readBoundedBlob(res, cfg.maxResponseBytes);
|
|
367
|
+
return { ok: true, status, data: blob as T, headers: res.headers };
|
|
368
|
+
}
|
|
369
|
+
if (status === 204) {
|
|
370
|
+
return { ok: true, status, data: undefined as T, headers: res.headers };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (opts?.ignoreBody === true) {
|
|
374
|
+
releaseQuietly(() => res.body?.cancel());
|
|
375
|
+
return { ok: true, status, data: undefined as T, headers: res.headers };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const text = await readBoundedText(res, cfg.maxResponseBytes);
|
|
379
|
+
if (text === "") {
|
|
380
|
+
return { ok: true, status, data: undefined as T, headers: res.headers };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let parsed: unknown;
|
|
384
|
+
try {
|
|
385
|
+
parsed = JSON.parse(text);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
return makeErr(status, `response not JSON: ${errMsg(e)}`, "decode", undefined, res.headers);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (opts?.decoder !== undefined) {
|
|
391
|
+
try {
|
|
392
|
+
return { ok: true, status, data: opts.decoder(parsed), headers: res.headers };
|
|
393
|
+
} catch (e) {
|
|
394
|
+
return makeErr(
|
|
395
|
+
status,
|
|
396
|
+
`response shape mismatch: ${errMsg(e)}`,
|
|
397
|
+
"decode",
|
|
398
|
+
undefined,
|
|
399
|
+
res.headers,
|
|
400
|
+
parsed,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { ok: true, status, data: parsed as T, headers: res.headers };
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return classifyThrown(e, winner());
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
return async function requestRaw<T>(
|
|
412
|
+
method: HttpMethod,
|
|
413
|
+
path: string,
|
|
414
|
+
opts?: AnyRequestOptions<T>,
|
|
415
|
+
): Promise<ApiResult<T>> {
|
|
416
|
+
const result = await perform(method, path, opts);
|
|
417
|
+
if (!result.ok && cfg.onError !== undefined) {
|
|
418
|
+
notify(cfg.onError, result, { method, path });
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Build a null-collapsing `request` over a `requestRaw`: the decoded data on a
|
|
426
|
+
* successful result, or `null` on any error.
|
|
427
|
+
*/
|
|
428
|
+
export function makeRequest(raw: RequestRawCore): RequestCore {
|
|
429
|
+
return async function request<T>(
|
|
430
|
+
method: HttpMethod,
|
|
431
|
+
path: string,
|
|
432
|
+
opts?: AnyRequestOptions<T>,
|
|
433
|
+
): Promise<T | null> {
|
|
434
|
+
const result = await raw<T>(method, path, opts);
|
|
435
|
+
return result.ok && result.data !== undefined ? result.data : null;
|
|
436
|
+
};
|
|
437
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Default request timeout in milliseconds. */
|
|
2
|
+
export const API_TIMEOUT_MS = 30_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compose an optional caller signal with a fresh timeout signal: the result
|
|
6
|
+
* aborts on whichever fires first, with that signal's `reason`. Throws the
|
|
7
|
+
* `TypeError` of `AbortSignal.timeout` for an out-of-range `ms`.
|
|
8
|
+
*/
|
|
9
|
+
export function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
10
|
+
const timeout = AbortSignal.timeout(ms);
|
|
11
|
+
if (signal === undefined) {
|
|
12
|
+
return timeout;
|
|
13
|
+
}
|
|
14
|
+
// Preferred where present: the native composite lets a finished request be
|
|
15
|
+
// collected at once instead of when its timeout fires.
|
|
16
|
+
if (typeof AbortSignal.any === "function") {
|
|
17
|
+
return AbortSignal.any([signal, timeout]);
|
|
18
|
+
}
|
|
19
|
+
return firstAbort(signal, timeout);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @internal Which source aborted a request's signal first. */
|
|
23
|
+
export type AbortSource = "caller" | "timeout";
|
|
24
|
+
|
|
25
|
+
/** @internal {@link withTimeout}'s signal, plus the source that won it: set
|
|
26
|
+
* once when the signal aborts, `undefined` while it has not. */
|
|
27
|
+
export function requestSignal(
|
|
28
|
+
caller: AbortSignal | undefined,
|
|
29
|
+
ms: number,
|
|
30
|
+
): { readonly signal: AbortSignal; readonly winner: () => AbortSource | undefined } {
|
|
31
|
+
const signal = withTimeout(caller, ms);
|
|
32
|
+
let winner: AbortSource | undefined;
|
|
33
|
+
// Hooked before `signal` is handed out, so it runs before any code that sees
|
|
34
|
+
// the abort could abort the caller: here `caller.aborted` means the caller won.
|
|
35
|
+
// Write-once on the real abort: an "abort" event anyone dispatches on the
|
|
36
|
+
// handed-out signal, before it aborted or after the winner is set, is ignored.
|
|
37
|
+
const record = (): void => {
|
|
38
|
+
if (winner === undefined && signal.aborted) {
|
|
39
|
+
winner = caller?.aborted === true ? "caller" : "timeout";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
record();
|
|
43
|
+
signal.addEventListener("abort", record);
|
|
44
|
+
return { signal, winner: () => winner };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `AbortSignal.any` for the runtimes without it (Chrome before 116, Safari
|
|
48
|
+
* before 17.4, Firefox before 124, Node before 20.3). */
|
|
49
|
+
function firstAbort(a: AbortSignal, b: AbortSignal): AbortSignal {
|
|
50
|
+
const out = new AbortController();
|
|
51
|
+
for (const source of [a, b]) {
|
|
52
|
+
if (source.aborted) {
|
|
53
|
+
out.abort(source.reason);
|
|
54
|
+
return out.signal;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// The first abort unhooks both listeners, so a long-lived caller signal holds
|
|
58
|
+
// a finished request's controller only until that request's timeout fires.
|
|
59
|
+
const unhook = new AbortController();
|
|
60
|
+
const forward = (source: AbortSignal) => () => {
|
|
61
|
+
out.abort(source.reason);
|
|
62
|
+
unhook.abort();
|
|
63
|
+
};
|
|
64
|
+
a.addEventListener("abort", forward(a), { signal: unhook.signal });
|
|
65
|
+
b.addEventListener("abort", forward(b), { signal: unhook.signal });
|
|
66
|
+
return out.signal;
|
|
67
|
+
}
|