@uniflowed/fetch 0.0.0-alpha.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/index.js ADDED
@@ -0,0 +1,19 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/fetch`: typed HTTP over the platform's own `fetch`.
4
+ //
5
+ // The platform's `fetch` is the right primitive and this does not replace it:
6
+ // `raw` hands the `Response` straight back. What it adds is the three things
7
+ // every application writes around it and gets subtly wrong — a failed response
8
+ // being a resolved promise, no timeout at all, and retrying things that must
9
+ // not be retried.
10
+
11
+ export type {
12
+ FetchClient,
13
+ FetchConfig,
14
+ FetchFailure,
15
+ Parse,
16
+ RequestOptions,
17
+ } from "./internal/client.js";
18
+
19
+ export { FetchError, createFetch } from "./internal/client.js";
@@ -0,0 +1,336 @@
1
+ // @flow
2
+ //
3
+ // Typed HTTP over the platform's `fetch`.
4
+ //
5
+ // Not a wrapper for its own sake. Three things every application writes by
6
+ // hand around `fetch`, each of which is easy to get subtly wrong:
7
+ //
8
+ // * **A failed response is not a failed promise.** `fetch` resolves for a
9
+ // 500. Code that forgets to check `response.ok` treats an error page as
10
+ // data, and the failure surfaces later as an unrelated type error.
11
+ // * **A timeout.** `fetch` has none. A request to a host that accepts the
12
+ // connection and never answers hangs until the process ends.
13
+ // * **Retrying the right things.** A 500 is worth retrying and a 400 is not,
14
+ // and retrying a POST that may have succeeded is how you charge a card
15
+ // twice.
16
+ //
17
+ // A schema is optional and, when given, is applied to the parsed body — so a
18
+ // response that does not match is a failure at the boundary rather than a
19
+ // `TypeError` three call frames later.
20
+
21
+ /** What went wrong, as a value rather than a string. */
22
+ export type FetchFailure =
23
+ | {|
24
+ readonly kind: "http",
25
+ readonly status: number,
26
+ readonly statusText: string,
27
+ readonly response: Response,
28
+ |}
29
+ | {| readonly kind: "network", readonly cause: mixed |}
30
+ | {| readonly kind: "timeout", readonly millis: number |}
31
+ | {| readonly kind: "parse", readonly cause: mixed |}
32
+ | {| readonly kind: "invalid", readonly issues: $ReadOnlyArray<mixed> |};
33
+
34
+ /**
35
+ * A request that failed, carrying why.
36
+ *
37
+ * One error class with a typed `failure`, rather than a class per case: a
38
+ * caller that wants to branch reads `failure.kind`, and one that does not gets
39
+ * a message that already says what happened.
40
+ */
41
+ export class FetchError extends Error {
42
+ readonly failure: FetchFailure;
43
+ readonly url: string;
44
+
45
+ constructor(url: string, failure: FetchFailure) {
46
+ super(describe(url, failure));
47
+ this.name = "FetchError";
48
+ this.failure = failure;
49
+ this.url = url;
50
+ }
51
+
52
+ /** Whether another attempt could plausibly settle this. */
53
+ get retriable(): boolean {
54
+ return match (this.failure.kind) {
55
+ "network" => true,
56
+ "timeout" => true,
57
+ // 408 is a timeout the server noticed, 429 is "slow down", and 5xx is
58
+ // the server's problem rather than the request's. Nothing else is worth
59
+ // sending again: a 400 will be a 400 next time too.
60
+ "http" =>
61
+ this.failure.status === 408 || this.failure.status === 429 || this.failure.status >= 500,
62
+ _ => false,
63
+ };
64
+ }
65
+ }
66
+
67
+ function describe(url: string, failure: FetchFailure): string {
68
+ return match (failure.kind) {
69
+ "http" => `${url} answered ${failure.status} ${failure.statusText}`,
70
+ "network" => `${url} could not be reached: ${String(failure.cause)}`,
71
+ "timeout" => `${url} did not answer within ${failure.millis}ms`,
72
+ "parse" => `${url} did not return the body it said it would: ${String(failure.cause)}`,
73
+ "invalid" => `${url} returned ${failure.issues.length} value(s) the schema rejected`,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * A function that checks what arrived and narrows it.
79
+ *
80
+ * A function rather than a schema object, so this module depends on no
81
+ * validator: `@uniflowed/validator`'s `parser(User)` is exactly this shape,
82
+ * and so is a hand-written check.
83
+ */
84
+ export type Parse<T> = (
85
+ value: mixed,
86
+ ) =>
87
+ | {| readonly ok: true, readonly value: T |}
88
+ | {| readonly ok: false, readonly issues: $ReadOnlyArray<mixed> |};
89
+
90
+ /** How a client behaves for every request it makes. */
91
+ export type FetchConfig = {|
92
+ readonly baseURL?: string,
93
+ readonly headers?: { readonly [string]: string },
94
+ /** Abort a request that has not answered. Defaults to 30 seconds. */
95
+ readonly timeout?: number,
96
+ /** How many further attempts a retriable failure gets. Defaults to none. */
97
+ readonly retries?: number,
98
+ /** Milliseconds before the first retry; doubles each time. Defaults to 200. */
99
+ readonly retryDelay?: number,
100
+ /** Swap in a different `fetch`, which is how a test avoids the network. */
101
+ readonly fetch?: typeof fetch,
102
+ |};
103
+
104
+ /** One request. */
105
+ export type RequestOptions<T> = {|
106
+ readonly method?: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE",
107
+ /** Sent as JSON unless it is already a `BodyInit`. */
108
+ readonly body?: mixed,
109
+ readonly headers?: { readonly [string]: string },
110
+ readonly searchParams?: { readonly [string]: string | number | boolean },
111
+ readonly signal?: AbortSignal,
112
+ readonly timeout?: number,
113
+ readonly retries?: number,
114
+ /** Checked against the parsed body; its failure is the request's failure. */
115
+ readonly parse?: Parse<T>,
116
+ |};
117
+
118
+ /** A configured client. */
119
+ export type FetchClient = {|
120
+ readonly request: <T>(path: string, options?: RequestOptions<T>) => Promise<T>,
121
+ readonly raw: (path: string, options?: RequestOptions<mixed>) => Promise<Response>,
122
+ /** A client with more defaults applied on top of this one's. */
123
+ readonly extend: (config: FetchConfig) => FetchClient,
124
+ |};
125
+
126
+ const DEFAULTS = { timeout: 30_000, retries: 0, retryDelay: 200 };
127
+
128
+ /** Methods that may be retried without asking whether they were applied. */
129
+ const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
130
+
131
+ /**
132
+ * A client with these defaults.
133
+ *
134
+ * Creating a client rather than exporting a function per verb, because the
135
+ * base URL, the headers and the retry policy belong to a *service* — and an
136
+ * application talks to more than one.
137
+ */
138
+ export function createFetch(config?: FetchConfig): FetchClient {
139
+ const settings = { ...DEFAULTS, ...(config ?? {}) };
140
+
141
+ const raw = async (path: string, options?: RequestOptions<mixed>): Promise<Response> =>
142
+ send(settings, path, options ?? {});
143
+
144
+ return {
145
+ raw,
146
+ request: async <T>(path: string, options?: RequestOptions<T>): Promise<T> => {
147
+ const given = options ?? {};
148
+ const response = await send(settings, path, given);
149
+ return parse(response, given, resolveUrl(settings, path, given)) as $FlowFixMe;
150
+ },
151
+ extend: (extra: FetchConfig) =>
152
+ createFetch({
153
+ ...settings,
154
+ ...extra,
155
+ headers: { ...(settings.headers ?? {}), ...(extra.headers ?? {}) },
156
+ }),
157
+ };
158
+ }
159
+
160
+ /** Send, with the timeout and the retry policy applied. */
161
+ async function send(
162
+ settings: $FlowFixMe,
163
+ path: string,
164
+ options: RequestOptions<mixed>,
165
+ ): Promise<Response> {
166
+ const url = resolveUrl(settings, path, options);
167
+ const method = (options.method ?? "GET").toUpperCase();
168
+ const attempts = 1 + Math.max(0, options.retries ?? settings.retries);
169
+ const timeout = options.timeout ?? settings.timeout;
170
+ const doFetch = settings.fetch ?? globalThis.fetch;
171
+
172
+ let failure: FetchError | null = null;
173
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
174
+ if (attempt > 0) {
175
+ await pause(settings.retryDelay * 2 ** (attempt - 1));
176
+ }
177
+ try {
178
+ const response = await withTimeout(
179
+ (signal) => doFetch(url, requestInit(settings, options, method, signal)),
180
+ timeout,
181
+ options.signal,
182
+ url,
183
+ );
184
+ if (response.ok) {
185
+ return response;
186
+ }
187
+ failure = new FetchError(url, {
188
+ kind: "http",
189
+ status: response.status,
190
+ statusText: response.statusText,
191
+ response,
192
+ });
193
+ } catch (error) {
194
+ failure =
195
+ error instanceof FetchError
196
+ ? error
197
+ : new FetchError(url, { kind: "network", cause: error });
198
+ }
199
+
200
+ // A method that may have been applied is not retried, however retriable
201
+ // the failure looks: a POST that timed out may have succeeded, and sending
202
+ // it again is how an order is placed twice.
203
+ if (!failure.retriable || !IDEMPOTENT.has(method)) {
204
+ throw failure;
205
+ }
206
+ }
207
+ throw failure ?? new FetchError(url, { kind: "network", cause: "no attempt was made" });
208
+ }
209
+
210
+ function requestInit(
211
+ settings: $FlowFixMe,
212
+ options: RequestOptions<mixed>,
213
+ method: string,
214
+ signal: AbortSignal,
215
+ ): RequestOptions<mixed> {
216
+ const headers: { [string]: string } = {
217
+ ...(settings.headers ?? {}),
218
+ ...(options.headers ?? {}),
219
+ };
220
+
221
+ let body = options.body;
222
+ if (body != null && !isBodyInit(body)) {
223
+ // JSON unless the caller already made it something the platform accepts,
224
+ // and the header set only if they did not choose one.
225
+ body = JSON.stringify(body);
226
+ if (headers["content-type"] == null && headers["Content-Type"] == null) {
227
+ headers["content-type"] = "application/json";
228
+ }
229
+ }
230
+
231
+ return { method, headers, body, signal } as $FlowFixMe;
232
+ }
233
+
234
+ /** Whether the platform can send this as-is. */
235
+ function isBodyInit(body: mixed): boolean {
236
+ return (
237
+ typeof body === "string" ||
238
+ body instanceof URLSearchParams ||
239
+ body instanceof FormData ||
240
+ body instanceof Blob ||
241
+ body instanceof ArrayBuffer ||
242
+ (globalThis.ReadableStream != null && body instanceof globalThis.ReadableStream)
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Race the request against the clock, and against the caller's own signal.
248
+ *
249
+ * A timeout has to abort the request rather than merely stop waiting for it:
250
+ * leaving it in flight holds a connection and, worse, lets its result arrive
251
+ * after the caller has moved on.
252
+ */
253
+ async function withTimeout(
254
+ run: (signal: AbortSignal) => Promise<Response>,
255
+ millis: number,
256
+ external: AbortSignal | void,
257
+ url: string,
258
+ ): Promise<Response> {
259
+ const controller = new AbortController();
260
+ const timer = setTimeout(() => controller.abort(), millis);
261
+ const forward = () => controller.abort();
262
+ external?.addEventListener("abort", forward);
263
+
264
+ try {
265
+ return await run(controller.signal);
266
+ } catch (error) {
267
+ if (controller.signal.aborted && external?.aborted !== true) {
268
+ throw new FetchError(url, { kind: "timeout", millis });
269
+ }
270
+ throw error;
271
+ } finally {
272
+ clearTimeout(timer);
273
+ external?.removeEventListener("abort", forward);
274
+ }
275
+ }
276
+
277
+ /** The body, parsed by content type, then checked against the schema. */
278
+ async function parse<T>(
279
+ response: Response,
280
+ options: RequestOptions<T>,
281
+ url: string,
282
+ ): Promise<mixed> {
283
+ const type = response.headers.get("content-type") ?? "";
284
+ let value: mixed;
285
+ try {
286
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
287
+ value = undefined;
288
+ } else if (type.includes("json")) {
289
+ value = await response.json();
290
+ } else if (type.startsWith("text/") || type === "") {
291
+ value = await response.text();
292
+ } else {
293
+ value = await response.arrayBuffer();
294
+ }
295
+ } catch (cause) {
296
+ throw new FetchError(url, { kind: "parse", cause });
297
+ }
298
+
299
+ const check = options.parse;
300
+ if (check == null) {
301
+ return value;
302
+ }
303
+ const checked = check(value);
304
+ if (!checked.ok) {
305
+ // At the boundary, where the shape came from outside — not three frames
306
+ // later as a `TypeError` about a property of undefined.
307
+ throw new FetchError(url, { kind: "invalid", issues: checked.issues });
308
+ }
309
+ return checked.value;
310
+ }
311
+
312
+ function resolveUrl(settings: $FlowFixMe, path: string, options: RequestOptions<mixed>): string {
313
+ const base = settings.baseURL;
314
+ const joined =
315
+ base == null || /^[a-z][a-z0-9+.-]*:/i.test(path)
316
+ ? path
317
+ : `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
318
+
319
+ const search = options.searchParams;
320
+ if (search == null) {
321
+ return joined;
322
+ }
323
+ const query = new URLSearchParams();
324
+ for (const key of Object.keys(search)) {
325
+ query.set(key, String(search[key]));
326
+ }
327
+ const text = query.toString();
328
+ if (text === "") {
329
+ return joined;
330
+ }
331
+ return joined.includes("?") ? `${joined}&${text}` : `${joined}?${text}`;
332
+ }
333
+
334
+ function pause(millis: number): Promise<void> {
335
+ return new Promise((resolve) => setTimeout(resolve, millis));
336
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@uniflowed/fetch",
3
+ "version": "0.0.0-alpha.2",
4
+ "description": "Typed HTTP over the platform fetch, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/fetch"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "internal"
19
+ ],
20
+ "dependencies": {}
21
+ }