@softure-ai/core 0.2.2 → 0.2.3
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/CHANGELOG.md +17 -0
- package/README.md +30 -1
- package/dist/http-json.d.ts +52 -5
- package/dist/http-json.d.ts.map +1 -1
- package/dist/http-json.js +108 -14
- package/dist/http-json.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/http-json.ts +183 -22
- package/src/index.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
|
|
|
4
4
|
production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
|
|
5
5
|
"Definition of done"). Versions before the first one below are described in their GitHub Releases (`core@x.y.z`).
|
|
6
6
|
|
|
7
|
+
## 0.2.3
|
|
8
|
+
|
|
9
|
+
- `requestJson` reads response headers (#652), with no breaking change to calls that do not use the new option.
|
|
10
|
+
- `readHeaders: readonly string[]`: response headers to return, by name (any letter case), an explicit allow-list.
|
|
11
|
+
With it the value is `{ body, headers }` (each requested name a key, `null` when absent) and a failure that had a
|
|
12
|
+
response carries `headers`. A name that is not a valid header name throws a `RangeError` before the request.
|
|
13
|
+
- `retryAfterSeconds: number | null` on every `core.http_rate_limited` and `core.http_unavailable` failure, parsed
|
|
14
|
+
from `Retry-After` (seconds, or an HTTP date measured against the new `clock` option), 0 to 86 400.
|
|
15
|
+
`HttpJsonFailure` is now a union narrowed by `error`, and a rate limited or unavailable failure's `status` is a
|
|
16
|
+
`number`.
|
|
17
|
+
- `parseLinkHeader(value)` returns the `next`, `prev`, `first` and `last` URLs of a `Link` header.
|
|
18
|
+
- New types `HttpJsonAnswer`, `HttpJsonResponseHeaders`, `HttpJsonRetryableCode`, `LinkHeaderLinks`,
|
|
19
|
+
`RequestJsonReadHeadersOptions`.
|
|
20
|
+
- Migrate: nothing is required. A test that deep-compares a 429 or 5xx failure adds `retryAfterSeconds: null` (or
|
|
21
|
+
the parsed value). An app that dropped to raw `fetch` for `Retry-After`, a `Link` header or rate-limit headers
|
|
22
|
+
goes back to `requestJson` with `readHeaders`, and passes `retryAfterSeconds` to `retryJobLater`.
|
|
23
|
+
|
|
7
24
|
## 0.2.2
|
|
8
25
|
|
|
9
26
|
- `requestJson` writes (#635): `method` (`"GET"` by default, `"POST" | "PUT" | "PATCH" | "DELETE"`), `body` sent as
|
package/README.md
CHANGED
|
@@ -305,7 +305,7 @@ if (plan.endedAt !== null) throw new PublicError("This plan has ended; pick anot
|
|
|
305
305
|
`isPublicError` recognises one from another copy of core too (a registry symbol, not `instanceof`).
|
|
306
306
|
`safeError` itself still answers `core.unexpected` for it, since callers switch on its two codes.
|
|
307
307
|
|
|
308
|
-
**External JSON APIs.** `requestJson({ url, method?, body?, idempotencyKey?, fetch?, headers?, timeoutMs?, schema? })`
|
|
308
|
+
**External JSON APIs.** `requestJson({ url, method?, body?, idempotencyKey?, fetch?, headers?, timeoutMs?, schema?, readHeaders?, clock? })`
|
|
309
309
|
calls a third-party JSON API (a GET by default) and returns `Ok<value>` or an `HttpJsonFailure` (`{ ok: false, error, status }`). It sets the details a hand-written
|
|
310
310
|
helper tends to miss:
|
|
311
311
|
|
|
@@ -351,6 +351,35 @@ throws before anything is sent. `idempotencyKey` is sent as `Idempotency-Key`. A
|
|
|
351
351
|
(a 204, or a 200 with nothing) is `ok(undefined)`, schema or not, so a write's value is `T | undefined`; a non-empty
|
|
352
352
|
answer still goes through the schema. A GET carries no body, and an empty answer to it stays an unexpected answer.
|
|
353
353
|
|
|
354
|
+
**Response headers.** A failure with `core.http_rate_limited` (429) or `core.http_unavailable` (5xx) always carries
|
|
355
|
+
`retryAfterSeconds`: the `Retry-After` header as seconds (delay-seconds, or an HTTP date measured against `clock`,
|
|
356
|
+
default `systemClock`, rounded up), between 0 and 86 400, or `null` when the header is missing or malformed. It goes
|
|
357
|
+
straight into `retryJobLater` of `@softure-ai/jobs`. Narrowing on `error` types it; other failures do not carry it.
|
|
358
|
+
|
|
359
|
+
Any other header is read only when named in `readHeaders` (any letter case; an explicit allow-list, so nothing secret
|
|
360
|
+
leaves by accident). The value then becomes `{ body, headers }`, with each requested name as a key and `null` for a
|
|
361
|
+
header the answer lacks; a failure that had a response carries the same `headers`. Without `readHeaders` the value is
|
|
362
|
+
the body, as before. A name that is not a valid header name throws a `RangeError` before anything is sent.
|
|
363
|
+
`parseLinkHeader(value)` reads a `Link` header (RFC 8288) into `{ next?, prev?, first?, last? }`, the URLs as
|
|
364
|
+
written (`previous` counts as `prev`; the first link of a relation wins; `null` or garbage gives `{}`):
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
import { parseLinkHeader, requestJson } from "@softure-ai/core";
|
|
368
|
+
|
|
369
|
+
const page = await requestJson({
|
|
370
|
+
url,
|
|
371
|
+
headers: { Authorization: `Bearer ${env.REPORTS_API_KEY}` },
|
|
372
|
+
schema: z.array(rowSchema),
|
|
373
|
+
readHeaders: ["link", "x-ratelimit-remaining"],
|
|
374
|
+
});
|
|
375
|
+
if (!page.ok) {
|
|
376
|
+
if (page.error === "core.http_rate_limited") return retryJobLater("app.reports_busy", page.retryAfterSeconds ?? 60);
|
|
377
|
+
return page;
|
|
378
|
+
}
|
|
379
|
+
const { next } = parseLinkHeader(page.value.headers.link);
|
|
380
|
+
// page.value.body: the rows; next: the URL of the next page, or undefined on the last one
|
|
381
|
+
```
|
|
382
|
+
|
|
354
383
|
```ts
|
|
355
384
|
const saved = await requestJson({
|
|
356
385
|
url: `https://api.example.com/tasks/${task.id}`,
|
package/dist/http-json.d.ts
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
+
import { type Clock } from "./clock.js";
|
|
2
3
|
import { type Err, type Ok } from "./result.js";
|
|
3
4
|
export type HttpJsonFailureCode = "core.http_unreachable" | "core.http_refused" | "core.http_rate_limited" | "core.http_unavailable" | "core.http_conflict" | "core.http_unexpected_answer";
|
|
4
|
-
/**
|
|
5
|
-
export type
|
|
5
|
+
/** The failure codes whose answer may say when to try again (`Retry-After`). */
|
|
6
|
+
export type HttpJsonRetryableCode = "core.http_rate_limited" | "core.http_unavailable";
|
|
7
|
+
/** The headers named in `readHeaders`, keyed as the caller wrote them; `null` when the answer does not carry one. */
|
|
8
|
+
export type HttpJsonResponseHeaders<H extends string = string> = Readonly<Record<H, string | null>>;
|
|
9
|
+
/**
|
|
10
|
+
* A failed `requestJson`: the code and the HTTP status (`null` when no response arrived). A rate limited or
|
|
11
|
+
* unavailable answer adds `retryAfterSeconds`, and a response read with `readHeaders` adds `headers`. Nothing else.
|
|
12
|
+
*/
|
|
13
|
+
export type HttpJsonFailure = (Err<Exclude<HttpJsonFailureCode, HttpJsonRetryableCode>> & {
|
|
6
14
|
readonly status: number | null;
|
|
15
|
+
readonly headers?: HttpJsonResponseHeaders;
|
|
16
|
+
}) | (Err<HttpJsonRetryableCode> & {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
/** Parsed from `Retry-After` (seconds or an HTTP date), 0 to 86 400; `null` when missing or malformed. */
|
|
19
|
+
readonly retryAfterSeconds: number | null;
|
|
20
|
+
readonly headers?: HttpJsonResponseHeaders;
|
|
21
|
+
});
|
|
22
|
+
/** The value of a call made with `readHeaders`: the body as without it, and the requested headers. */
|
|
23
|
+
export type HttpJsonAnswer<T, H extends string> = {
|
|
24
|
+
readonly body: T;
|
|
25
|
+
readonly headers: HttpJsonResponseHeaders<H>;
|
|
7
26
|
};
|
|
8
27
|
export type HttpJsonWriteMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
|
9
28
|
export type HttpJsonMethod = "GET" | HttpJsonWriteMethod;
|
|
@@ -15,6 +34,16 @@ export interface RequestJsonOptions {
|
|
|
15
34
|
readonly headers?: HeadersInit;
|
|
16
35
|
/** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
|
|
17
36
|
readonly timeoutMs?: number;
|
|
37
|
+
/** Measures a `Retry-After` HTTP date. Defaults to `systemClock`. */
|
|
38
|
+
readonly clock?: Clock;
|
|
39
|
+
}
|
|
40
|
+
/** Response headers to return, by name (any letter case). Only these are read. */
|
|
41
|
+
export interface RequestJsonReadHeadersOptions<H extends string> {
|
|
42
|
+
readonly readHeaders: readonly H[];
|
|
43
|
+
}
|
|
44
|
+
/** The calls without `readHeaders`, whose value is the body itself. */
|
|
45
|
+
interface WithoutReadHeaders {
|
|
46
|
+
readonly readHeaders?: undefined;
|
|
18
47
|
}
|
|
19
48
|
export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
|
|
20
49
|
readonly schema: S;
|
|
@@ -38,13 +67,31 @@ export interface RequestJsonWriteOptions extends RequestJsonOptions {
|
|
|
38
67
|
* for the caller to narrow; with one it is the schema's output. A write answered with an empty body (a 204) is
|
|
39
68
|
* `ok(undefined)`, schema or not. 3xx, 401 and 403 are refused, 409 and 412 a conflict, 429 rate limited, 5xx
|
|
40
69
|
* unavailable, and any other non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
|
|
70
|
+
* With `readHeaders` the value is `{ body, headers }`, and a failure that had a response carries `headers` too.
|
|
71
|
+
*/
|
|
72
|
+
export declare function requestJson<S extends z.ZodType, const H extends string>(options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions & RequestJsonReadHeadersOptions<H>): Promise<Ok<HttpJsonAnswer<z.output<S>, H>> | HttpJsonFailure>;
|
|
73
|
+
export declare function requestJson<S extends z.ZodType, const H extends string>(options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions & RequestJsonReadHeadersOptions<H>): Promise<Ok<HttpJsonAnswer<z.output<S> | undefined, H>> | HttpJsonFailure>;
|
|
74
|
+
export declare function requestJson<const H extends string>(options: (RequestJsonReadOptions | RequestJsonWriteOptions) & RequestJsonReadHeadersOptions<H>): Promise<Ok<HttpJsonAnswer<unknown, H>> | HttpJsonFailure>;
|
|
75
|
+
export declare function requestJson<S extends z.ZodType>(options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions & WithoutReadHeaders): Promise<Ok<z.output<S>> | HttpJsonFailure>;
|
|
76
|
+
export declare function requestJson<S extends z.ZodType>(options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions & WithoutReadHeaders): Promise<Ok<z.output<S> | undefined> | HttpJsonFailure>;
|
|
77
|
+
export declare function requestJson(options: (RequestJsonReadOptions | RequestJsonWriteOptions) & WithoutReadHeaders): Promise<Ok<unknown> | HttpJsonFailure>;
|
|
78
|
+
/** The URLs of the pagination relations in a `Link` header (RFC 8288), as written; missing ones are left out. */
|
|
79
|
+
export interface LinkHeaderLinks {
|
|
80
|
+
readonly next?: string;
|
|
81
|
+
readonly prev?: string;
|
|
82
|
+
readonly first?: string;
|
|
83
|
+
readonly last?: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read `next`, `prev` (or `previous`), `first` and `last` from a `Link` header such as
|
|
87
|
+
* `<https://api.example.com/items?page=2>; rel="next"`. The first link of a relation wins; `null`, an empty value or
|
|
88
|
+
* one that cannot be read gives `{}`.
|
|
41
89
|
*/
|
|
42
|
-
export declare function
|
|
43
|
-
export declare function requestJson<S extends z.ZodType>(options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions): Promise<Ok<z.output<S> | undefined> | HttpJsonFailure>;
|
|
44
|
-
export declare function requestJson(options: RequestJsonReadOptions | RequestJsonWriteOptions): Promise<Ok<unknown> | HttpJsonFailure>;
|
|
90
|
+
export declare function parseLinkHeader(value: string | null | undefined): LinkHeaderLinks;
|
|
45
91
|
/**
|
|
46
92
|
* The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
|
|
47
93
|
* out, so optional parameters can be passed as they are.
|
|
48
94
|
*/
|
|
49
95
|
export declare function toQueryString(params: Readonly<Record<string, string | number | boolean | null | undefined>>): string;
|
|
96
|
+
export {};
|
|
50
97
|
//# sourceMappingURL=http-json.d.ts.map
|
package/dist/http-json.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-json.d.ts","sourceRoot":"","sources":["../src/http-json.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"http-json.d.ts","sourceRoot":"","sources":["../src/http-json.ts"],"names":[],"mappings":"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"}
|
package/dist/http-json.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { systemClock } from "./clock.js";
|
|
1
2
|
import { err, ok } from "./result.js";
|
|
2
3
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
4
|
+
const MAX_RETRY_AFTER_SECONDS = 86_400;
|
|
5
|
+
// An RFC 9110 token: what a header name may be made of.
|
|
6
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
3
7
|
export async function requestJson(options) {
|
|
8
|
+
// Before anything is sent: a write must not go out when its answer cannot be read.
|
|
9
|
+
assertHeaderNames(options.readHeaders);
|
|
4
10
|
const fetchFunction = options.fetch ?? globalThis.fetch;
|
|
5
11
|
const method = options.method ?? "GET";
|
|
6
12
|
const isWrite = method !== "GET";
|
|
@@ -31,11 +37,14 @@ export async function requestJson(options) {
|
|
|
31
37
|
// Network error or timeout. The error's text can quote the URL, so it is dropped on purpose.
|
|
32
38
|
return httpFailure("core.http_unreachable", null);
|
|
33
39
|
}
|
|
34
|
-
const
|
|
35
|
-
|
|
40
|
+
const headers = options.readHeaders === undefined ? undefined : readResponseHeaders(response, options.readHeaders);
|
|
41
|
+
const fail = (code) => withHeaders(responseFailure(code, response, options.clock ?? systemClock), headers);
|
|
42
|
+
const succeed = (body) => ok(headers === undefined ? body : { body, headers });
|
|
43
|
+
const statusFailureCode = getStatusFailureCode(response);
|
|
44
|
+
if (statusFailureCode !== null) {
|
|
36
45
|
// Release the connection; a body that cannot be cancelled changes nothing for the caller.
|
|
37
46
|
await response.body?.cancel().catch(() => undefined);
|
|
38
|
-
return
|
|
47
|
+
return fail(statusFailureCode);
|
|
39
48
|
}
|
|
40
49
|
let text;
|
|
41
50
|
try {
|
|
@@ -43,25 +52,69 @@ export async function requestJson(options) {
|
|
|
43
52
|
}
|
|
44
53
|
catch {
|
|
45
54
|
// The body broke off, usually the timeout firing mid-body.
|
|
46
|
-
return
|
|
55
|
+
return fail("core.http_unreachable");
|
|
47
56
|
}
|
|
48
57
|
if (isWrite && text.trim() === "") {
|
|
49
58
|
// A 204, or a write that answers 200 with nothing: the write succeeded and there is nothing to read.
|
|
50
|
-
return
|
|
59
|
+
return succeed(undefined);
|
|
51
60
|
}
|
|
52
61
|
let answer;
|
|
53
62
|
try {
|
|
54
63
|
answer = JSON.parse(text);
|
|
55
64
|
}
|
|
56
65
|
catch {
|
|
57
|
-
return
|
|
66
|
+
return fail("core.http_unexpected_answer");
|
|
58
67
|
}
|
|
59
68
|
if (options.schema === undefined) {
|
|
60
|
-
return
|
|
69
|
+
return succeed(answer);
|
|
61
70
|
}
|
|
62
71
|
// The schema's issues can quote the body's values, so only the code leaves.
|
|
63
72
|
const parsed = options.schema.safeParse(answer);
|
|
64
|
-
return parsed.success ?
|
|
73
|
+
return parsed.success ? succeed(parsed.data) : fail("core.http_unexpected_answer");
|
|
74
|
+
}
|
|
75
|
+
const REL_PATTERN = /(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,"]+))/i;
|
|
76
|
+
const LINK_RELATIONS = {
|
|
77
|
+
next: "next",
|
|
78
|
+
prev: "prev",
|
|
79
|
+
previous: "prev",
|
|
80
|
+
first: "first",
|
|
81
|
+
last: "last",
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Read `next`, `prev` (or `previous`), `first` and `last` from a `Link` header such as
|
|
85
|
+
* `<https://api.example.com/items?page=2>; rel="next"`. The first link of a relation wins; `null`, an empty value or
|
|
86
|
+
* one that cannot be read gives `{}`.
|
|
87
|
+
*/
|
|
88
|
+
export function parseLinkHeader(value) {
|
|
89
|
+
const links = {};
|
|
90
|
+
if (value === null || value === undefined) {
|
|
91
|
+
return links;
|
|
92
|
+
}
|
|
93
|
+
// One link: the URL in angle brackets and its parameters up to the next `<`. Splitting by commas would break a URL
|
|
94
|
+
// that contains one, and a regular expression would rescan the rest of a hostile value from every `<`.
|
|
95
|
+
let position = 0;
|
|
96
|
+
while (position < value.length) {
|
|
97
|
+
const open = value.indexOf("<", position);
|
|
98
|
+
const close = open === -1 ? -1 : value.indexOf(">", open + 1);
|
|
99
|
+
if (close === -1) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const nextOpen = value.indexOf("<", close + 1);
|
|
103
|
+
position = nextOpen === -1 ? value.length : nextOpen;
|
|
104
|
+
const url = value.slice(open + 1, close).trim();
|
|
105
|
+
const rel = REL_PATTERN.exec(value.slice(close + 1, position));
|
|
106
|
+
const relationTypes = rel?.[1] ?? rel?.[2];
|
|
107
|
+
if (url === "" || relationTypes === undefined) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const relationType of relationTypes.trim().toLowerCase().split(/\s+/)) {
|
|
111
|
+
const relation = LINK_RELATIONS[relationType];
|
|
112
|
+
if (relation !== undefined && links[relation] === undefined) {
|
|
113
|
+
links[relation] = url;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return links;
|
|
65
118
|
}
|
|
66
119
|
/**
|
|
67
120
|
* The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
|
|
@@ -76,26 +129,67 @@ export function toQueryString(params) {
|
|
|
76
129
|
}
|
|
77
130
|
return search.toString();
|
|
78
131
|
}
|
|
79
|
-
function
|
|
132
|
+
function getStatusFailureCode(response) {
|
|
80
133
|
const { status } = response;
|
|
81
134
|
if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
|
|
82
|
-
return
|
|
135
|
+
return "core.http_refused";
|
|
83
136
|
}
|
|
84
137
|
if (status === 409 || status === 412) {
|
|
85
|
-
return
|
|
138
|
+
return "core.http_conflict";
|
|
86
139
|
}
|
|
87
140
|
if (status === 429) {
|
|
88
|
-
return
|
|
141
|
+
return "core.http_rate_limited";
|
|
89
142
|
}
|
|
90
143
|
if (status >= 500) {
|
|
91
|
-
return
|
|
144
|
+
return "core.http_unavailable";
|
|
92
145
|
}
|
|
93
146
|
if (status < 200 || status >= 300) {
|
|
94
|
-
return
|
|
147
|
+
return "core.http_unexpected_answer";
|
|
95
148
|
}
|
|
96
149
|
return null;
|
|
97
150
|
}
|
|
98
151
|
function httpFailure(code, status) {
|
|
99
152
|
return { ...err(code), status };
|
|
100
153
|
}
|
|
154
|
+
function responseFailure(code, response, clock) {
|
|
155
|
+
if (code === "core.http_rate_limited" || code === "core.http_unavailable") {
|
|
156
|
+
const retryAfterSeconds = getRetryAfterSeconds(response.headers.get("retry-after"), clock.now());
|
|
157
|
+
return { ...err(code), status: response.status, retryAfterSeconds };
|
|
158
|
+
}
|
|
159
|
+
return httpFailure(code, response.status);
|
|
160
|
+
}
|
|
161
|
+
function withHeaders(failure, headers) {
|
|
162
|
+
return headers === undefined ? failure : { ...failure, headers };
|
|
163
|
+
}
|
|
164
|
+
function assertHeaderNames(names) {
|
|
165
|
+
for (const name of names ?? []) {
|
|
166
|
+
if (!HEADER_NAME_PATTERN.test(name)) {
|
|
167
|
+
throw new RangeError(`requestJson: readHeaders holds an invalid header name ${JSON.stringify(name)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function readResponseHeaders(response, names) {
|
|
172
|
+
return Object.fromEntries(names.map((name) => [name, response.headers.get(name)]));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Seconds to wait from a `Retry-After` value (RFC 9110): delay-seconds, or an HTTP date measured against `now` and
|
|
176
|
+
* rounded up. A date in the past is 0, anything above a day is a day, and a malformed value is `null`.
|
|
177
|
+
*/
|
|
178
|
+
function getRetryAfterSeconds(value, now) {
|
|
179
|
+
const trimmed = value?.trim() ?? "";
|
|
180
|
+
if (/^\d+$/.test(trimmed)) {
|
|
181
|
+
return Math.min(Number(trimmed), MAX_RETRY_AFTER_SECONDS);
|
|
182
|
+
}
|
|
183
|
+
// Every HTTP date names its day and month; without a letter the value is no date (and `Date.parse` guesses).
|
|
184
|
+
if (!/^[A-Za-z]/.test(trimmed)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// HTTP dates are GMT; the obsolete asctime form says so nowhere, and `Date.parse` would read it as local time.
|
|
188
|
+
const at = Date.parse(/(GMT|UTC)$/i.test(trimmed) ? trimmed : `${trimmed} GMT`);
|
|
189
|
+
if (Number.isNaN(at)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const seconds = Math.ceil((at - now.getTime()) / 1000);
|
|
193
|
+
return Math.min(Math.max(seconds, 0), MAX_RETRY_AFTER_SECONDS);
|
|
194
|
+
}
|
|
101
195
|
//# sourceMappingURL=http-json.js.map
|
package/dist/http-json.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-json.js","sourceRoot":"","sources":["../src/http-json.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"http-json.js","sourceRoot":"","sources":["../src/http-json.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { calendarPeriodSchema, countPeriodDays, doPeriodsOverlap, isDayInPeriod,
|
|
|
3
3
|
export { getLocalDay, isLocalDateTime, localDateTimeSchema, type LocalDateTime } from "./local-date-time.js";
|
|
4
4
|
export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
|
|
5
5
|
export { DEFAULT_DURATION_MESSAGES, formatCalendarDay, formatCalendarMonth, formatDuration, formatMoney, formatPercent, isCalendarMonth, type CalendarDayStyle, type CalendarMonthStyle, type DurationMessages, type FormatDurationOptions, type FormatMoneyOptions, type FormatPercentOptions, } from "./format.js";
|
|
6
|
-
export { requestJson, toQueryString, type FetchFunction, type HttpJsonFailure, type HttpJsonFailureCode, type HttpJsonMethod, type HttpJsonWriteMethod, type RequestJsonOptions, type RequestJsonReadOptions, type RequestJsonSchemaOptions, type RequestJsonWriteOptions, } from "./http-json.js";
|
|
6
|
+
export { parseLinkHeader, requestJson, toQueryString, type FetchFunction, type HttpJsonAnswer, type HttpJsonFailure, type HttpJsonFailureCode, type HttpJsonMethod, type HttpJsonResponseHeaders, type HttpJsonRetryableCode, type HttpJsonWriteMethod, type LinkHeaderLinks, type RequestJsonOptions, type RequestJsonReadHeadersOptions, type RequestJsonReadOptions, type RequestJsonSchemaOptions, type RequestJsonWriteOptions, } from "./http-json.js";
|
|
7
7
|
export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
|
|
8
8
|
export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
|
|
9
9
|
export { formatMessage, getMessage, DEFAULT_LOCALE, isLocale, mergeMessages, pickLocalizedText, selectMessages, selectPlural, type DeepPartial, type Dictionaries, type Locale, type LocalizedText, type MessageOverrides, type MessageTree, type PluralForms, } from "./i18n.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { calendarPeriodSchema, countPeriodDays, doPeriodsOverlap, isDayInPeriod,
|
|
|
5
5
|
export { getLocalDay, isLocalDateTime, localDateTimeSchema } from "./local-date-time.js";
|
|
6
6
|
export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
|
|
7
7
|
export { DEFAULT_DURATION_MESSAGES, formatCalendarDay, formatCalendarMonth, formatDuration, formatMoney, formatPercent, isCalendarMonth, } from "./format.js";
|
|
8
|
-
export { requestJson, toQueryString, } from "./http-json.js";
|
|
8
|
+
export { parseLinkHeader, requestJson, toQueryString, } from "./http-json.js";
|
|
9
9
|
export { memoizeByContent } from "./memoize.js";
|
|
10
10
|
export { createTestClock, systemClock } from "./clock.js";
|
|
11
11
|
export { formatMessage, getMessage, DEFAULT_LOCALE, isLocale, mergeMessages, pickLocalizedText, selectMessages, selectPlural, } from "./i18n.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mGAAmG;AACnG,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,kBAAkB,GAEnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,aAAa,GAEd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,mBAAmB,EAAsB,MAAM,sBAAsB,CAAC;AAC7G,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EACL,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,WAAW,EACX,aAAa,EACb,eAAe,GAOhB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,WAAW,EACX,aAAa,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mGAAmG;AACnG,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,kBAAkB,GAEnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,aAAa,GAEd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,mBAAmB,EAAsB,MAAM,sBAAsB,CAAC;AAC7G,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EACL,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,WAAW,EACX,aAAa,EACb,eAAe,GAOhB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,eAAe,EACf,WAAW,EACX,aAAa,GAed,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,gBAAgB,EAAuD,MAAM,cAAc,CAAC;AACrG,OAAO,EAAE,eAAe,EAAE,WAAW,EAA8B,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,YAAY,GAQb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAkD,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAsB,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,GAQ1B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAuB,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,GAUb,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,GAIjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAuC,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAyC,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAmC,MAAM,oBAAoB,CAAC"}
|
package/package.json
CHANGED
package/src/http-json.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// A JSON call to a third-party API (a GET, or a write with a JSON body), with the details every app's own copy has to
|
|
2
2
|
// get right: a timeout, no redirect followed (a key in a header or the URL never reaches another host), and every
|
|
3
3
|
// failure returned as a code. A caught error's message is never read, since it can quote the URL and some APIs put
|
|
4
|
-
// the key there. The body stays `unknown` unless a zod schema narrows it.
|
|
4
|
+
// the key there. The body stays `unknown` unless a zod schema narrows it. Response headers are read only by name from
|
|
5
|
+
// the caller's allow-list, so nothing secret is surfaced by accident.
|
|
5
6
|
import type { z } from "zod";
|
|
7
|
+
import { systemClock, type Clock } from "./clock.js";
|
|
6
8
|
import { err, ok, type Err, type Ok } from "./result.js";
|
|
7
9
|
|
|
8
10
|
export type HttpJsonFailureCode =
|
|
@@ -13,8 +15,30 @@ export type HttpJsonFailureCode =
|
|
|
13
15
|
| "core.http_conflict"
|
|
14
16
|
| "core.http_unexpected_answer";
|
|
15
17
|
|
|
16
|
-
/**
|
|
17
|
-
export type
|
|
18
|
+
/** The failure codes whose answer may say when to try again (`Retry-After`). */
|
|
19
|
+
export type HttpJsonRetryableCode = "core.http_rate_limited" | "core.http_unavailable";
|
|
20
|
+
|
|
21
|
+
/** The headers named in `readHeaders`, keyed as the caller wrote them; `null` when the answer does not carry one. */
|
|
22
|
+
export type HttpJsonResponseHeaders<H extends string = string> = Readonly<Record<H, string | null>>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A failed `requestJson`: the code and the HTTP status (`null` when no response arrived). A rate limited or
|
|
26
|
+
* unavailable answer adds `retryAfterSeconds`, and a response read with `readHeaders` adds `headers`. Nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export type HttpJsonFailure =
|
|
29
|
+
| (Err<Exclude<HttpJsonFailureCode, HttpJsonRetryableCode>> & {
|
|
30
|
+
readonly status: number | null;
|
|
31
|
+
readonly headers?: HttpJsonResponseHeaders;
|
|
32
|
+
})
|
|
33
|
+
| (Err<HttpJsonRetryableCode> & {
|
|
34
|
+
readonly status: number;
|
|
35
|
+
/** Parsed from `Retry-After` (seconds or an HTTP date), 0 to 86 400; `null` when missing or malformed. */
|
|
36
|
+
readonly retryAfterSeconds: number | null;
|
|
37
|
+
readonly headers?: HttpJsonResponseHeaders;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The value of a call made with `readHeaders`: the body as without it, and the requested headers. */
|
|
41
|
+
export type HttpJsonAnswer<T, H extends string> = { readonly body: T; readonly headers: HttpJsonResponseHeaders<H> };
|
|
18
42
|
|
|
19
43
|
export type HttpJsonWriteMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
|
20
44
|
export type HttpJsonMethod = "GET" | HttpJsonWriteMethod;
|
|
@@ -28,6 +52,18 @@ export interface RequestJsonOptions {
|
|
|
28
52
|
readonly headers?: HeadersInit;
|
|
29
53
|
/** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
|
|
30
54
|
readonly timeoutMs?: number;
|
|
55
|
+
/** Measures a `Retry-After` HTTP date. Defaults to `systemClock`. */
|
|
56
|
+
readonly clock?: Clock;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Response headers to return, by name (any letter case). Only these are read. */
|
|
60
|
+
export interface RequestJsonReadHeadersOptions<H extends string> {
|
|
61
|
+
readonly readHeaders: readonly H[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The calls without `readHeaders`, whose value is the body itself. */
|
|
65
|
+
interface WithoutReadHeaders {
|
|
66
|
+
readonly readHeaders?: undefined;
|
|
31
67
|
}
|
|
32
68
|
|
|
33
69
|
export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
|
|
@@ -51,25 +87,43 @@ export interface RequestJsonWriteOptions extends RequestJsonOptions {
|
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
90
|
+
const MAX_RETRY_AFTER_SECONDS = 86_400;
|
|
91
|
+
// An RFC 9110 token: what a header name may be made of.
|
|
92
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
54
93
|
|
|
55
94
|
/**
|
|
56
95
|
* Call `url` (a GET unless `method` says otherwise) and read its JSON body. Without a schema the value is `unknown`,
|
|
57
96
|
* for the caller to narrow; with one it is the schema's output. A write answered with an empty body (a 204) is
|
|
58
97
|
* `ok(undefined)`, schema or not. 3xx, 401 and 403 are refused, 409 and 412 a conflict, 429 rate limited, 5xx
|
|
59
98
|
* unavailable, and any other non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
|
|
99
|
+
* With `readHeaders` the value is `{ body, headers }`, and a failure that had a response carries `headers` too.
|
|
60
100
|
*/
|
|
101
|
+
export function requestJson<S extends z.ZodType, const H extends string>(
|
|
102
|
+
options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions & RequestJsonReadHeadersOptions<H>,
|
|
103
|
+
): Promise<Ok<HttpJsonAnswer<z.output<S>, H>> | HttpJsonFailure>;
|
|
104
|
+
export function requestJson<S extends z.ZodType, const H extends string>(
|
|
105
|
+
options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions & RequestJsonReadHeadersOptions<H>,
|
|
106
|
+
): Promise<Ok<HttpJsonAnswer<z.output<S> | undefined, H>> | HttpJsonFailure>;
|
|
107
|
+
export function requestJson<const H extends string>(
|
|
108
|
+
options: (RequestJsonReadOptions | RequestJsonWriteOptions) & RequestJsonReadHeadersOptions<H>,
|
|
109
|
+
): Promise<Ok<HttpJsonAnswer<unknown, H>> | HttpJsonFailure>;
|
|
61
110
|
export function requestJson<S extends z.ZodType>(
|
|
62
|
-
options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions,
|
|
111
|
+
options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions & WithoutReadHeaders,
|
|
63
112
|
): Promise<Ok<z.output<S>> | HttpJsonFailure>;
|
|
64
113
|
export function requestJson<S extends z.ZodType>(
|
|
65
|
-
options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions,
|
|
114
|
+
options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions & WithoutReadHeaders,
|
|
66
115
|
): Promise<Ok<z.output<S> | undefined> | HttpJsonFailure>;
|
|
67
116
|
export function requestJson(
|
|
68
|
-
options: RequestJsonReadOptions | RequestJsonWriteOptions,
|
|
117
|
+
options: (RequestJsonReadOptions | RequestJsonWriteOptions) & WithoutReadHeaders,
|
|
69
118
|
): Promise<Ok<unknown> | HttpJsonFailure>;
|
|
70
119
|
export async function requestJson(
|
|
71
|
-
options: (RequestJsonReadOptions | RequestJsonWriteOptions) & {
|
|
120
|
+
options: (RequestJsonReadOptions | RequestJsonWriteOptions) & {
|
|
121
|
+
readonly schema?: z.ZodType;
|
|
122
|
+
readonly readHeaders?: readonly string[];
|
|
123
|
+
},
|
|
72
124
|
): Promise<Ok<unknown> | HttpJsonFailure> {
|
|
125
|
+
// Before anything is sent: a write must not go out when its answer cannot be read.
|
|
126
|
+
assertHeaderNames(options.readHeaders);
|
|
73
127
|
const fetchFunction = options.fetch ?? globalThis.fetch;
|
|
74
128
|
const method = options.method ?? "GET";
|
|
75
129
|
const isWrite = method !== "GET";
|
|
@@ -101,11 +155,16 @@ export async function requestJson(
|
|
|
101
155
|
return httpFailure("core.http_unreachable", null);
|
|
102
156
|
}
|
|
103
157
|
|
|
104
|
-
const
|
|
105
|
-
|
|
158
|
+
const headers = options.readHeaders === undefined ? undefined : readResponseHeaders(response, options.readHeaders);
|
|
159
|
+
const fail = (code: HttpJsonFailureCode): HttpJsonFailure =>
|
|
160
|
+
withHeaders(responseFailure(code, response, options.clock ?? systemClock), headers);
|
|
161
|
+
const succeed = (body: unknown): Ok<unknown> => ok(headers === undefined ? body : { body, headers });
|
|
162
|
+
|
|
163
|
+
const statusFailureCode = getStatusFailureCode(response);
|
|
164
|
+
if (statusFailureCode !== null) {
|
|
106
165
|
// Release the connection; a body that cannot be cancelled changes nothing for the caller.
|
|
107
166
|
await response.body?.cancel().catch(() => undefined);
|
|
108
|
-
return
|
|
167
|
+
return fail(statusFailureCode);
|
|
109
168
|
}
|
|
110
169
|
|
|
111
170
|
let text: string;
|
|
@@ -113,27 +172,83 @@ export async function requestJson(
|
|
|
113
172
|
text = await response.text();
|
|
114
173
|
} catch {
|
|
115
174
|
// The body broke off, usually the timeout firing mid-body.
|
|
116
|
-
return
|
|
175
|
+
return fail("core.http_unreachable");
|
|
117
176
|
}
|
|
118
177
|
|
|
119
178
|
if (isWrite && text.trim() === "") {
|
|
120
179
|
// A 204, or a write that answers 200 with nothing: the write succeeded and there is nothing to read.
|
|
121
|
-
return
|
|
180
|
+
return succeed(undefined);
|
|
122
181
|
}
|
|
123
182
|
|
|
124
183
|
let answer: unknown;
|
|
125
184
|
try {
|
|
126
185
|
answer = JSON.parse(text);
|
|
127
186
|
} catch {
|
|
128
|
-
return
|
|
187
|
+
return fail("core.http_unexpected_answer");
|
|
129
188
|
}
|
|
130
189
|
|
|
131
190
|
if (options.schema === undefined) {
|
|
132
|
-
return
|
|
191
|
+
return succeed(answer);
|
|
133
192
|
}
|
|
134
193
|
// The schema's issues can quote the body's values, so only the code leaves.
|
|
135
194
|
const parsed = options.schema.safeParse(answer);
|
|
136
|
-
return parsed.success ?
|
|
195
|
+
return parsed.success ? succeed(parsed.data) : fail("core.http_unexpected_answer");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The URLs of the pagination relations in a `Link` header (RFC 8288), as written; missing ones are left out. */
|
|
199
|
+
export interface LinkHeaderLinks {
|
|
200
|
+
readonly next?: string;
|
|
201
|
+
readonly prev?: string;
|
|
202
|
+
readonly first?: string;
|
|
203
|
+
readonly last?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type LinkRelation = keyof LinkHeaderLinks;
|
|
207
|
+
|
|
208
|
+
const REL_PATTERN = /(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,"]+))/i;
|
|
209
|
+
const LINK_RELATIONS: Readonly<Record<string, LinkRelation>> = {
|
|
210
|
+
next: "next",
|
|
211
|
+
prev: "prev",
|
|
212
|
+
previous: "prev",
|
|
213
|
+
first: "first",
|
|
214
|
+
last: "last",
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read `next`, `prev` (or `previous`), `first` and `last` from a `Link` header such as
|
|
219
|
+
* `<https://api.example.com/items?page=2>; rel="next"`. The first link of a relation wins; `null`, an empty value or
|
|
220
|
+
* one that cannot be read gives `{}`.
|
|
221
|
+
*/
|
|
222
|
+
export function parseLinkHeader(value: string | null | undefined): LinkHeaderLinks {
|
|
223
|
+
const links: Partial<Record<LinkRelation, string>> = {};
|
|
224
|
+
if (value === null || value === undefined) {
|
|
225
|
+
return links;
|
|
226
|
+
}
|
|
227
|
+
// One link: the URL in angle brackets and its parameters up to the next `<`. Splitting by commas would break a URL
|
|
228
|
+
// that contains one, and a regular expression would rescan the rest of a hostile value from every `<`.
|
|
229
|
+
let position = 0;
|
|
230
|
+
while (position < value.length) {
|
|
231
|
+
const open = value.indexOf("<", position);
|
|
232
|
+
const close = open === -1 ? -1 : value.indexOf(">", open + 1);
|
|
233
|
+
if (close === -1) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
const nextOpen = value.indexOf("<", close + 1);
|
|
237
|
+
position = nextOpen === -1 ? value.length : nextOpen;
|
|
238
|
+
const url = value.slice(open + 1, close).trim();
|
|
239
|
+
const rel = REL_PATTERN.exec(value.slice(close + 1, position));
|
|
240
|
+
const relationTypes = rel?.[1] ?? rel?.[2];
|
|
241
|
+
if (url === "" || relationTypes === undefined) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
for (const relationType of relationTypes.trim().toLowerCase().split(/\s+/)) {
|
|
245
|
+
const relation = LINK_RELATIONS[relationType];
|
|
246
|
+
if (relation !== undefined && links[relation] === undefined) {
|
|
247
|
+
links[relation] = url;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return links;
|
|
137
252
|
}
|
|
138
253
|
|
|
139
254
|
/**
|
|
@@ -150,26 +265,72 @@ export function toQueryString(params: Readonly<Record<string, string | number |
|
|
|
150
265
|
return search.toString();
|
|
151
266
|
}
|
|
152
267
|
|
|
153
|
-
function
|
|
268
|
+
function getStatusFailureCode(response: Response): HttpJsonFailureCode | null {
|
|
154
269
|
const { status } = response;
|
|
155
270
|
if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
|
|
156
|
-
return
|
|
271
|
+
return "core.http_refused";
|
|
157
272
|
}
|
|
158
273
|
if (status === 409 || status === 412) {
|
|
159
|
-
return
|
|
274
|
+
return "core.http_conflict";
|
|
160
275
|
}
|
|
161
276
|
if (status === 429) {
|
|
162
|
-
return
|
|
277
|
+
return "core.http_rate_limited";
|
|
163
278
|
}
|
|
164
279
|
if (status >= 500) {
|
|
165
|
-
return
|
|
280
|
+
return "core.http_unavailable";
|
|
166
281
|
}
|
|
167
282
|
if (status < 200 || status >= 300) {
|
|
168
|
-
return
|
|
283
|
+
return "core.http_unexpected_answer";
|
|
169
284
|
}
|
|
170
285
|
return null;
|
|
171
286
|
}
|
|
172
287
|
|
|
173
|
-
function httpFailure(code: HttpJsonFailureCode, status: number | null): HttpJsonFailure {
|
|
288
|
+
function httpFailure(code: Exclude<HttpJsonFailureCode, HttpJsonRetryableCode>, status: number | null): HttpJsonFailure {
|
|
174
289
|
return { ...err(code), status };
|
|
175
290
|
}
|
|
291
|
+
|
|
292
|
+
function responseFailure(code: HttpJsonFailureCode, response: Response, clock: Clock): HttpJsonFailure {
|
|
293
|
+
if (code === "core.http_rate_limited" || code === "core.http_unavailable") {
|
|
294
|
+
const retryAfterSeconds = getRetryAfterSeconds(response.headers.get("retry-after"), clock.now());
|
|
295
|
+
return { ...err(code), status: response.status, retryAfterSeconds };
|
|
296
|
+
}
|
|
297
|
+
return httpFailure(code, response.status);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function withHeaders(failure: HttpJsonFailure, headers: HttpJsonResponseHeaders | undefined): HttpJsonFailure {
|
|
301
|
+
return headers === undefined ? failure : { ...failure, headers };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function assertHeaderNames(names: readonly string[] | undefined): void {
|
|
305
|
+
for (const name of names ?? []) {
|
|
306
|
+
if (!HEADER_NAME_PATTERN.test(name)) {
|
|
307
|
+
throw new RangeError(`requestJson: readHeaders holds an invalid header name ${JSON.stringify(name)}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readResponseHeaders(response: Response, names: readonly string[]): HttpJsonResponseHeaders {
|
|
313
|
+
return Object.fromEntries(names.map((name) => [name, response.headers.get(name)]));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Seconds to wait from a `Retry-After` value (RFC 9110): delay-seconds, or an HTTP date measured against `now` and
|
|
318
|
+
* rounded up. A date in the past is 0, anything above a day is a day, and a malformed value is `null`.
|
|
319
|
+
*/
|
|
320
|
+
function getRetryAfterSeconds(value: string | null, now: Date): number | null {
|
|
321
|
+
const trimmed = value?.trim() ?? "";
|
|
322
|
+
if (/^\d+$/.test(trimmed)) {
|
|
323
|
+
return Math.min(Number(trimmed), MAX_RETRY_AFTER_SECONDS);
|
|
324
|
+
}
|
|
325
|
+
// Every HTTP date names its day and month; without a letter the value is no date (and `Date.parse` guesses).
|
|
326
|
+
if (!/^[A-Za-z]/.test(trimmed)) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
// HTTP dates are GMT; the obsolete asctime form says so nowhere, and `Date.parse` would read it as local time.
|
|
330
|
+
const at = Date.parse(/(GMT|UTC)$/i.test(trimmed) ? trimmed : `${trimmed} GMT`);
|
|
331
|
+
if (Number.isNaN(at)) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const seconds = Math.ceil((at - now.getTime()) / 1000);
|
|
335
|
+
return Math.min(Math.max(seconds, 0), MAX_RETRY_AFTER_SECONDS);
|
|
336
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,14 +36,20 @@ export {
|
|
|
36
36
|
type FormatPercentOptions,
|
|
37
37
|
} from "./format.js";
|
|
38
38
|
export {
|
|
39
|
+
parseLinkHeader,
|
|
39
40
|
requestJson,
|
|
40
41
|
toQueryString,
|
|
41
42
|
type FetchFunction,
|
|
43
|
+
type HttpJsonAnswer,
|
|
42
44
|
type HttpJsonFailure,
|
|
43
45
|
type HttpJsonFailureCode,
|
|
44
46
|
type HttpJsonMethod,
|
|
47
|
+
type HttpJsonResponseHeaders,
|
|
48
|
+
type HttpJsonRetryableCode,
|
|
45
49
|
type HttpJsonWriteMethod,
|
|
50
|
+
type LinkHeaderLinks,
|
|
46
51
|
type RequestJsonOptions,
|
|
52
|
+
type RequestJsonReadHeadersOptions,
|
|
47
53
|
type RequestJsonReadOptions,
|
|
48
54
|
type RequestJsonSchemaOptions,
|
|
49
55
|
type RequestJsonWriteOptions,
|