@gusnips/react 0.3.0 → 0.4.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/README.md +10 -2
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +69 -10
- package/dist/api-client.js.map +1 -1
- package/dist/api-error.d.ts +28 -0
- package/dist/api-error.d.ts.map +1 -1
- package/dist/api-error.js +51 -0
- package/dist/api-error.js.map +1 -1
- package/dist/describe-error.d.ts +6 -2
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +12 -14
- package/dist/describe-error.js.map +1 -1
- package/dist/hydrate.d.ts +5 -0
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -10
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +17 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +29 -4
- package/dist/query.js.map +1 -1
- package/package.json +8 -1
- package/src/api-client.test.ts +74 -6
- package/src/api-client.ts +73 -10
- package/src/api-error.test.ts +74 -0
- package/src/api-error.ts +57 -1
- package/src/chunk-reload.test.ts +69 -2
- package/src/describe-error.test.ts +37 -18
- package/src/describe-error.ts +19 -13
- package/src/hydrate.ts +5 -0
- package/src/index.ts +20 -10
- package/src/query.test.ts +53 -1
- package/src/query.ts +44 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, chunk-reload recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"types": "./dist/guards.d.ts",
|
|
27
27
|
"default": "./dist/guards.js"
|
|
28
28
|
},
|
|
29
|
+
"./hydrate": {
|
|
30
|
+
"types": "./dist/hydrate.d.ts",
|
|
31
|
+
"default": "./dist/hydrate.js"
|
|
32
|
+
},
|
|
29
33
|
"./contract": {
|
|
30
34
|
"types": "./dist/prerender-contract.d.ts",
|
|
31
35
|
"default": "./dist/prerender-contract.js"
|
|
@@ -92,6 +96,9 @@
|
|
|
92
96
|
"i18next": {
|
|
93
97
|
"optional": true
|
|
94
98
|
},
|
|
99
|
+
"react-dom": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
95
102
|
"react-i18next": {
|
|
96
103
|
"optional": true
|
|
97
104
|
},
|
package/src/api-client.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ApiError, isAbortError } from "./api-error.ts";
|
|
2
|
+
import { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
|
|
3
3
|
import { createApiClient, type RefreshResult, type SessionAdapter } from "./api-client.ts";
|
|
4
4
|
|
|
5
5
|
function stubFetch(handler: (url: string, init: RequestInit) => Response | Promise<Response>) {
|
|
@@ -65,6 +65,43 @@ describe("createApiClient", () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The second migration's API states the wait in the HEADER and puts nothing in the body — the
|
|
70
|
+
* standard place, and the one this package did not read. `retryAfterSecs` now answers for
|
|
71
|
+
* either convention, which is what lets one retry rule and one piece of copy serve both.
|
|
72
|
+
*/
|
|
73
|
+
it("reads the wait off the Retry-After header", async () => {
|
|
74
|
+
stubFetch(
|
|
75
|
+
() =>
|
|
76
|
+
new Response(JSON.stringify({ error: { code: "RATE_LIMIT_EXCEEDED", message: "slow" } }), {
|
|
77
|
+
status: 429,
|
|
78
|
+
headers: { "Retry-After": "60" },
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
const api = createApiClient({
|
|
82
|
+
baseUrl: "https://api.test",
|
|
83
|
+
session: session(),
|
|
84
|
+
onSessionDead: () => {},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const error = await api.get("/x").catch((e: unknown) => e);
|
|
88
|
+
expect(error).toMatchObject({ status: 429, retryAfterSecs: 60 });
|
|
89
|
+
expect(retryAfterSecs(error as ApiError)).toBe(60);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("leaves the wait unset when the answer did not state one", async () => {
|
|
93
|
+
stubFetch(() => new Response("{}", { status: 500 }));
|
|
94
|
+
const api = createApiClient({
|
|
95
|
+
baseUrl: "https://api.test",
|
|
96
|
+
session: session(),
|
|
97
|
+
onSessionDead: () => {},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const error = await api.get("/x").catch((e: unknown) => e);
|
|
101
|
+
expect((error as ApiError).retryAfterSecs).toBeUndefined();
|
|
102
|
+
expect(retryAfterSecs(error as ApiError)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
68
105
|
/**
|
|
69
106
|
* The single-flight rule, and the reason all three donors found it the hard way: the auth
|
|
70
107
|
* server rotates the refresh token on use, so six parallel 401s sending six refreshes means
|
|
@@ -150,15 +187,15 @@ describe("createApiClient", () => {
|
|
|
150
187
|
|
|
151
188
|
/**
|
|
152
189
|
* providerkit's invariant 2, on the web side: a caller's Stop and our deadline must stay
|
|
153
|
-
* distinguishable.
|
|
154
|
-
*
|
|
190
|
+
* distinguishable. The deadline aborts with a **TimeoutError**, not an AbortError, so
|
|
191
|
+
* `isAbortError` stays false for it — which is what lets a timeout be retried and reported
|
|
155
192
|
* while a navigation-cancelled request is silently dropped.
|
|
156
193
|
*/
|
|
157
194
|
it("distinguishes its own timeout from the caller's abort", async () => {
|
|
158
195
|
// Note the `aborted` check before the listener. A signal that fired before fetch was ever
|
|
159
196
|
// called never emits the event, so a listener alone hangs forever — real `fetch` checks the
|
|
160
|
-
// flag first, and so must anything standing in for it. That already-aborted race is
|
|
161
|
-
//
|
|
197
|
+
// flag first, and so must anything standing in for it. That already-aborted race is one of
|
|
198
|
+
// the three things `withDeadline` exists to get right.
|
|
162
199
|
stubFetch(
|
|
163
200
|
(_url, init) =>
|
|
164
201
|
new Promise<Response>((_resolve, reject) => {
|
|
@@ -175,13 +212,44 @@ describe("createApiClient", () => {
|
|
|
175
212
|
});
|
|
176
213
|
|
|
177
214
|
const timedOut = await api.get("/slow").catch((e: unknown) => e);
|
|
178
|
-
expect((timedOut as
|
|
215
|
+
expect((timedOut as Error).name).toBe("TimeoutError");
|
|
179
216
|
expect(isAbortError(timedOut)).toBe(false);
|
|
180
217
|
|
|
181
218
|
const controller = new AbortController();
|
|
182
219
|
const cancelled = api.get("/slow", { signal: controller.signal }).catch((e: unknown) => e);
|
|
183
220
|
controller.abort();
|
|
184
221
|
expect(isAbortError(await cancelled)).toBe(true);
|
|
222
|
+
|
|
223
|
+
// The already-aborted race: a signal that fired BEFORE the call. A listener alone never
|
|
224
|
+
// hears it, so the request would go out and run to full term with nobody waiting.
|
|
225
|
+
const already = new AbortController();
|
|
226
|
+
already.abort();
|
|
227
|
+
const dead = await api.get("/slow", { signal: already.signal }).catch((e: unknown) => e);
|
|
228
|
+
expect(isAbortError(dead)).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* React Native replaces the global `AbortSignal` with abort-controller@3, which has neither
|
|
233
|
+
* `timeout` nor `any`. The client used both, so `createApiClient` threw
|
|
234
|
+
* `AbortSignal.timeout is not a function` on the phone's first request — in the main entry,
|
|
235
|
+
* the one this package promises works there. Deleting the statics is the cheapest honest
|
|
236
|
+
* stand-in for that runtime.
|
|
237
|
+
*/
|
|
238
|
+
it("works where AbortSignal has no static helpers, as on a phone", async () => {
|
|
239
|
+
const { timeout, any } = AbortSignal as unknown as Record<string, unknown>;
|
|
240
|
+
delete (AbortSignal as unknown as Record<string, unknown>).timeout;
|
|
241
|
+
delete (AbortSignal as unknown as Record<string, unknown>).any;
|
|
242
|
+
try {
|
|
243
|
+
stubFetch(() => ok({ id: "1" }));
|
|
244
|
+
const api = createApiClient({
|
|
245
|
+
baseUrl: "https://api.test",
|
|
246
|
+
session: session(),
|
|
247
|
+
onSessionDead: () => {},
|
|
248
|
+
});
|
|
249
|
+
await expect(api.get("/me")).resolves.toEqual({ id: "1" });
|
|
250
|
+
} finally {
|
|
251
|
+
Object.assign(AbortSignal, { timeout, any });
|
|
252
|
+
}
|
|
185
253
|
});
|
|
186
254
|
|
|
187
255
|
// A 204 has no body, so `res.json()` on one throws `Unexpected end of JSON input` — which
|
package/src/api-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isApiError, type ApiSuccess } from "@gusnips/http";
|
|
2
|
-
import { ApiError } from "./api-error.ts";
|
|
2
|
+
import { ApiError, parseRetryAfter } from "./api-error.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The app's one door to the API. Nothing else should call `fetch`.
|
|
@@ -144,6 +144,67 @@ export interface ApiClient {
|
|
|
144
144
|
|
|
145
145
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* One signal that fires on the caller's abort or on the deadline, whichever comes first.
|
|
149
|
+
*
|
|
150
|
+
* `AbortSignal.timeout` plus `AbortSignal.any` is the obvious way to write this, and it is
|
|
151
|
+
* not portable: React Native replaces the global `AbortSignal` with abort-controller@3, which
|
|
152
|
+
* has neither static — so the phone got `AbortSignal.timeout is not a function` on its very
|
|
153
|
+
* first request. This is the same two rules with an `AbortController`, which every runtime
|
|
154
|
+
* this package targets does have, and it is the ONLY path rather than a fallback: one branch
|
|
155
|
+
* cannot drift from the other.
|
|
156
|
+
*
|
|
157
|
+
* Three details that are the whole reason this is not three lines:
|
|
158
|
+
*
|
|
159
|
+
* - **The already-aborted race.** A caller whose signal fired before we attached would never
|
|
160
|
+
* fire the listener, so the request would go out and run to full term. Checked first.
|
|
161
|
+
* - **A timeout must not look like a cancellation.** `isAbortError` exists so a request nobody
|
|
162
|
+
* is waiting for stays silent; a request that ran out of time is a real failure somebody
|
|
163
|
+
* should see. A bare `controller.abort()` raises an `AbortError` and would be swallowed, so
|
|
164
|
+
* the deadline aborts with a plain `Error` — not a `DOMException`, which `isAbortError` is
|
|
165
|
+
* the one thing that checks for.
|
|
166
|
+
* - **The timer is cleared.** Otherwise every completed request leaves a pending timeout for
|
|
167
|
+
* the rest of its budget, which on a phone is a wake-up nobody asked for.
|
|
168
|
+
*/
|
|
169
|
+
function withDeadline(
|
|
170
|
+
ms: number,
|
|
171
|
+
caller: AbortSignal | null | undefined,
|
|
172
|
+
): { signal: AbortSignal; clear: () => void } {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
// Named, not typed: callers identify a deadline by `name === "TimeoutError"`, the same
|
|
176
|
+
// way `AbortSignal.timeout` reports it. A `DOMException` would be the faithful copy and is
|
|
177
|
+
// not constructible everywhere this runs.
|
|
178
|
+
const expired = new Error(`Request timed out after ${String(ms)}ms`);
|
|
179
|
+
expired.name = "TimeoutError";
|
|
180
|
+
controller.abort(expired);
|
|
181
|
+
}, ms);
|
|
182
|
+
|
|
183
|
+
let detach = (): void => {};
|
|
184
|
+
if (caller) {
|
|
185
|
+
if (caller.aborted) {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
controller.abort(caller.reason);
|
|
188
|
+
} else {
|
|
189
|
+
const onAbort = (): void => {
|
|
190
|
+
controller.abort(caller.reason);
|
|
191
|
+
};
|
|
192
|
+
caller.addEventListener("abort", onAbort, { once: true });
|
|
193
|
+
detach = () => {
|
|
194
|
+
caller.removeEventListener("abort", onAbort);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
clear: () => {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
detach();
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
147
208
|
export function createApiClient({
|
|
148
209
|
baseUrl,
|
|
149
210
|
session,
|
|
@@ -215,15 +276,16 @@ export function createApiClient({
|
|
|
215
276
|
if (budget === null)
|
|
216
277
|
return fetch(`${baseUrl}${path}`, { ...init, signal, headers: buildHeaders(token, headers) });
|
|
217
278
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
279
|
+
const deadline = withDeadline(budget, signal);
|
|
280
|
+
try {
|
|
281
|
+
return await fetch(`${baseUrl}${path}`, {
|
|
282
|
+
...init,
|
|
283
|
+
signal: deadline.signal,
|
|
284
|
+
headers: buildHeaders(token, headers),
|
|
285
|
+
});
|
|
286
|
+
} finally {
|
|
287
|
+
deadline.clear();
|
|
288
|
+
}
|
|
227
289
|
}
|
|
228
290
|
|
|
229
291
|
async function request(path: string, options: RequestOptions = {}): Promise<Response> {
|
|
@@ -249,6 +311,7 @@ export function createApiClient({
|
|
|
249
311
|
const body: unknown = await res.json().catch(() => null);
|
|
250
312
|
const error = new ApiError(res.status, isApiError(body) ? body.error : null, {
|
|
251
313
|
requestId: res.headers.get(requestIdHeader) ?? undefined,
|
|
314
|
+
retryAfterSecs: parseRetryAfter(res.headers.get("retry-after")),
|
|
252
315
|
});
|
|
253
316
|
// A listener that throws must not become the error the caller sees: the API failure is
|
|
254
317
|
// the real news, and swallowing it for a bug in a side effect would send everyone
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ApiError, parseRetryAfter, retryAfterSecs } from "./api-error.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Two APIs, two places for the same fact: one states the wait in the `Retry-After` header (the
|
|
6
|
+
* standard one), the other in `details.retryAfterSecs`. A caller asking "how long" should not
|
|
7
|
+
* have to know which server answered.
|
|
8
|
+
*/
|
|
9
|
+
describe("retryAfterSecs", () => {
|
|
10
|
+
it("reads the header the client captured", () => {
|
|
11
|
+
expect(retryAfterSecs(new ApiError(429, null, { retryAfterSecs: 60 }))).toBe(60);
|
|
12
|
+
// Zero is a real answer — "now" — and must not read as "did not say".
|
|
13
|
+
expect(retryAfterSecs(new ApiError(429, null, { retryAfterSecs: 0 }))).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("prefers the header to the body", () => {
|
|
17
|
+
const both = new ApiError(
|
|
18
|
+
429,
|
|
19
|
+
{ code: "X", message: "", details: { retryAfterSecs: 30 } },
|
|
20
|
+
{
|
|
21
|
+
retryAfterSecs: 60,
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
expect(retryAfterSecs(both)).toBe(60);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reads the number out of details, and refuses anything else", () => {
|
|
28
|
+
expect(
|
|
29
|
+
retryAfterSecs(
|
|
30
|
+
new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: 30 } }),
|
|
31
|
+
),
|
|
32
|
+
).toBe(30);
|
|
33
|
+
expect(
|
|
34
|
+
retryAfterSecs(
|
|
35
|
+
new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: "30" } }),
|
|
36
|
+
),
|
|
37
|
+
).toBeNull();
|
|
38
|
+
expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
|
|
39
|
+
expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Both spec forms are real — RFC 9110 allows delay-seconds or an HTTP-date, and servers pick
|
|
45
|
+
* differently. The date half is the one that gets skipped, which silently turns a stated wait
|
|
46
|
+
* into no wait at all.
|
|
47
|
+
*/
|
|
48
|
+
describe("parseRetryAfter", () => {
|
|
49
|
+
it("reads delay-seconds", () => {
|
|
50
|
+
expect(parseRetryAfter("60")).toBe(60);
|
|
51
|
+
expect(parseRetryAfter(" 60 ")).toBe(60);
|
|
52
|
+
expect(parseRetryAfter("0")).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("reads an HTTP-date", () => {
|
|
56
|
+
const at = new Date(Date.now() + 120_000).toUTCString();
|
|
57
|
+
expect(parseRetryAfter(at)).toBeGreaterThanOrEqual(119);
|
|
58
|
+
expect(parseRetryAfter(at)).toBeLessThanOrEqual(120);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// A date already gone means "now", not a negative delay that would run the backoff backwards.
|
|
62
|
+
it("clamps a date in the past to zero", () => {
|
|
63
|
+
expect(parseRetryAfter(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("answers nothing for a header that is missing or not a wait", () => {
|
|
67
|
+
expect(parseRetryAfter(null)).toBeUndefined();
|
|
68
|
+
expect(parseRetryAfter("")).toBeUndefined();
|
|
69
|
+
expect(parseRetryAfter(" ")).toBeUndefined();
|
|
70
|
+
expect(parseRetryAfter("soon")).toBeUndefined();
|
|
71
|
+
expect(parseRetryAfter("-5")).toBeUndefined();
|
|
72
|
+
expect(parseRetryAfter("9007199254740993")).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/api-error.ts
CHANGED
|
@@ -18,6 +18,17 @@ export class ApiError extends Error {
|
|
|
18
18
|
readonly params: Record<string, string | number> | undefined;
|
|
19
19
|
readonly details: unknown;
|
|
20
20
|
readonly requestId: string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Seconds until this refusal clears, when the answer said so.
|
|
23
|
+
*
|
|
24
|
+
* Read from the `Retry-After` HEADER, which is where HTTP puts it and where a rate limiter
|
|
25
|
+
* written by anyone puts it. The second migration is why this exists: that API sends
|
|
26
|
+
* `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
|
|
27
|
+
* which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
|
|
28
|
+
* both the copy and the retry rule were flying blind on the one refusal that states its own
|
|
29
|
+
* expiry. Two conventions, same fact; {@link retryAfterSecs} reads either.
|
|
30
|
+
*/
|
|
31
|
+
readonly retryAfterSecs: number | undefined;
|
|
21
32
|
|
|
22
33
|
/**
|
|
23
34
|
* The client raised this itself to stop a caller while it was already handling the
|
|
@@ -33,7 +44,12 @@ export class ApiError extends Error {
|
|
|
33
44
|
constructor(
|
|
34
45
|
status: number,
|
|
35
46
|
body: ApiErrorBody["error"] | null,
|
|
36
|
-
opts: {
|
|
47
|
+
opts: {
|
|
48
|
+
requestId?: string;
|
|
49
|
+
expected?: boolean;
|
|
50
|
+
message?: string;
|
|
51
|
+
retryAfterSecs?: number;
|
|
52
|
+
} = {},
|
|
37
53
|
) {
|
|
38
54
|
super(opts.message ?? body?.message ?? `Request failed (${status})`);
|
|
39
55
|
this.name = "ApiError";
|
|
@@ -43,10 +59,50 @@ export class ApiError extends Error {
|
|
|
43
59
|
this.params = body?.params;
|
|
44
60
|
this.details = body?.details;
|
|
45
61
|
this.requestId = opts.requestId;
|
|
62
|
+
this.retryAfterSecs = opts.retryAfterSecs;
|
|
46
63
|
this.expected = opts.expected ?? false;
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Seconds until a refusal clears, from whichever place the API put them.
|
|
69
|
+
*
|
|
70
|
+
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
71
|
+
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
72
|
+
* caller asking "how long" should not have to know which server it is talking to.
|
|
73
|
+
*/
|
|
74
|
+
export function retryAfterSecs(error: ApiError): number | null {
|
|
75
|
+
if (error.retryAfterSecs !== undefined) return error.retryAfterSecs;
|
|
76
|
+
const details = error.details;
|
|
77
|
+
if (typeof details !== "object" || details === null) return null;
|
|
78
|
+
const value = (details as { retryAfterSecs?: unknown }).retryAfterSecs;
|
|
79
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
84
|
+
*
|
|
85
|
+
* Both forms are real — RFC 9110 allows either and different servers pick differently — and the
|
|
86
|
+
* date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
|
|
87
|
+
* at zero: a date already in the past means "now", not a negative delay.
|
|
88
|
+
*/
|
|
89
|
+
export function parseRetryAfter(value: string | null | undefined): number | undefined {
|
|
90
|
+
const raw = value?.trim();
|
|
91
|
+
if (!raw) return undefined;
|
|
92
|
+
|
|
93
|
+
// A bare integer is the delay-seconds form, malformed included: `Date.parse` accepts some of
|
|
94
|
+
// them as years, so falling through would read "-5" as a date long past and answer 0 — a
|
|
95
|
+
// wait invented out of a header that never stated one.
|
|
96
|
+
if (/^[+-]?\d+$/.test(raw)) {
|
|
97
|
+
const secs = Number(raw);
|
|
98
|
+
return Number.isSafeInteger(secs) && secs >= 0 ? secs : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const at = Date.parse(raw);
|
|
102
|
+
if (Number.isNaN(at)) return undefined;
|
|
103
|
+
return Math.max(0, Math.round((at - Date.now()) / 1000));
|
|
104
|
+
}
|
|
105
|
+
|
|
50
106
|
/**
|
|
51
107
|
* An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
|
|
52
108
|
* component waiting on it.
|
package/src/chunk-reload.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { isChunkLoadError, isPreloadHintFailure } from "./chunk-reload.ts";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError } from "./chunk-reload.ts";
|
|
3
3
|
|
|
4
4
|
// No browser gives a failed dynamic import a shared type, so this is matched by message and
|
|
5
5
|
// every phrasing has to be here. A miss is a white screen after a deploy — the exact failure
|
|
@@ -30,6 +30,9 @@ describe("isChunkLoadError", () => {
|
|
|
30
30
|
it("does not match an ordinary render error", () => {
|
|
31
31
|
expect(isChunkLoadError(new TypeError("Cannot read properties of undefined"))).toBe(false);
|
|
32
32
|
expect(isChunkLoadError(null)).toBe(false);
|
|
33
|
+
// A thrown string reaches the `String(error)` branch, which is the one a non-Error throw
|
|
34
|
+
// takes — and the one that would blow up if it were written `error.message`.
|
|
35
|
+
expect(isChunkLoadError("something failed")).toBe(false);
|
|
33
36
|
});
|
|
34
37
|
});
|
|
35
38
|
|
|
@@ -44,3 +47,67 @@ describe("isPreloadHintFailure", () => {
|
|
|
44
47
|
);
|
|
45
48
|
});
|
|
46
49
|
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The guard, which is the whole reason this function exists rather than a bare
|
|
53
|
+
* `location.reload()` at the call site. Reloading on a chunk error the reload does not fix is
|
|
54
|
+
* an infinite loop with a person inside it, and it only reproduces on a real broken deploy —
|
|
55
|
+
* so this is the only place the rule gets checked.
|
|
56
|
+
*
|
|
57
|
+
* Two globals, stubbed rather than run under a DOM: `window` and `sessionStorage` are the only
|
|
58
|
+
* ones this module touches, and adding jsdom to a package whose whole point is a small
|
|
59
|
+
* dependency list would cost more than it explains.
|
|
60
|
+
*/
|
|
61
|
+
describe("reloadOnceForChunkError", () => {
|
|
62
|
+
const reload = vi.fn();
|
|
63
|
+
let store: Record<string, string> = {};
|
|
64
|
+
/** Throws from both methods, the way a browser does when site data is blocked. */
|
|
65
|
+
let blocked = false;
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
reload.mockClear();
|
|
70
|
+
store = {};
|
|
71
|
+
blocked = false;
|
|
72
|
+
vi.stubGlobal("window", { location: { reload } });
|
|
73
|
+
vi.stubGlobal("sessionStorage", {
|
|
74
|
+
getItem: (key: string): string | null => {
|
|
75
|
+
if (blocked) throw new Error("site data blocked");
|
|
76
|
+
return store[key] ?? null;
|
|
77
|
+
},
|
|
78
|
+
setItem: (key: string, value: string): void => {
|
|
79
|
+
if (blocked) throw new Error("site data blocked");
|
|
80
|
+
store[key] = value;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.unstubAllGlobals();
|
|
87
|
+
vi.useRealTimers();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reloads on the first chunk failure", () => {
|
|
91
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
92
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("refuses a second reload inside the window, so a dead chunk cannot loop", () => {
|
|
96
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
97
|
+
expect(reloadOnceForChunkError()).toBe(false);
|
|
98
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("allows another one after the window, because the next deploy is a new problem", () => {
|
|
102
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
103
|
+
vi.setSystemTime(Date.now() + 61_000);
|
|
104
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
105
|
+
expect(reload).toHaveBeenCalledTimes(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("still reloads when storage is blocked — a privacy setting is not a reason to strand", () => {
|
|
109
|
+
blocked = true;
|
|
110
|
+
expect(reloadOnceForChunkError()).toBe(true);
|
|
111
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { ApiError } from "./api-error.ts";
|
|
3
|
-
import { createErrorDescriber, humanizeWait
|
|
3
|
+
import { createErrorDescriber, humanizeWait } from "./describe-error.ts";
|
|
4
4
|
|
|
5
5
|
/** A translator that echoes the key, so a test can see exactly which one was asked for. */
|
|
6
6
|
const t = (key: string, params?: Record<string, unknown>) =>
|
|
@@ -22,23 +22,6 @@ describe("humanizeWait", () => {
|
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
describe("retryAfterSecs", () => {
|
|
26
|
-
it("reads the number out of details, and refuses anything else", () => {
|
|
27
|
-
expect(
|
|
28
|
-
retryAfterSecs(
|
|
29
|
-
new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: 30 } }),
|
|
30
|
-
),
|
|
31
|
-
).toBe(30);
|
|
32
|
-
expect(
|
|
33
|
-
retryAfterSecs(
|
|
34
|
-
new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: "30" } }),
|
|
35
|
-
),
|
|
36
|
-
).toBeNull();
|
|
37
|
-
expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
|
|
38
|
-
expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
25
|
describe("createErrorDescriber", () => {
|
|
43
26
|
const describeError = createErrorDescriber({
|
|
44
27
|
t,
|
|
@@ -102,3 +85,39 @@ describe("createErrorDescriber", () => {
|
|
|
102
85
|
expect(describeError(new ApiError(403, null)).hint).toBeUndefined();
|
|
103
86
|
});
|
|
104
87
|
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
|
|
91
|
+
* names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
|
|
92
|
+
* and its client used to resolve them itself, which is how the translation ended up inside the
|
|
93
|
+
* transport.
|
|
94
|
+
*/
|
|
95
|
+
describe("createErrorDescriber, when the code names the sentence", () => {
|
|
96
|
+
const describeError = createErrorDescriber({
|
|
97
|
+
t,
|
|
98
|
+
copyPrefix: "errors.",
|
|
99
|
+
messageKeyPrefix: "errors.",
|
|
100
|
+
knownMessageKeys: { FORECAST_NOT_FOUND: "", network: "" },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("resolves a code this build has copy for", () => {
|
|
104
|
+
const error = new ApiError(404, { code: "FORECAST_NOT_FOUND", message: "Forecast not found" });
|
|
105
|
+
expect(describeError(error).cause).toBe("errors.FORECAST_NOT_FOUND");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to the server's own message for a code with no copy", () => {
|
|
109
|
+
// The server writes for a developer, so this is the worse of the two — but it is still the
|
|
110
|
+
// most specific thing anyone has, and support can act on it.
|
|
111
|
+
const error = new ApiError(429, { code: "RESEARCH_LIMIT", message: "Concurrent limit hit" });
|
|
112
|
+
expect(describeError(error).cause).toBe("Concurrent limit hit");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("prefers messageKey when the server sends both", () => {
|
|
116
|
+
const error = new ApiError(404, {
|
|
117
|
+
code: "FORECAST_NOT_FOUND",
|
|
118
|
+
message: "Forecast not found",
|
|
119
|
+
messageKey: "errors.network",
|
|
120
|
+
});
|
|
121
|
+
expect(describeError(error).cause).toBe("errors.network");
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/describe-error.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiError } from "./api-error.ts";
|
|
1
|
+
import { ApiError, retryAfterSecs } from "./api-error.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Turning anything thrown into copy a person can act on.
|
|
@@ -75,14 +75,6 @@ type _TypedCatalogNeedsNoCast = Satisfied<
|
|
|
75
75
|
: false
|
|
76
76
|
>;
|
|
77
77
|
|
|
78
|
-
/** Seconds until a refusal clears, when the envelope carries them. */
|
|
79
|
-
export function retryAfterSecs(error: ApiError): number | null {
|
|
80
|
-
const details = error.details;
|
|
81
|
-
if (typeof details !== "object" || details === null) return null;
|
|
82
|
-
const value = (details as { retryAfterSecs?: unknown }).retryAfterSecs;
|
|
83
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
78
|
/**
|
|
87
79
|
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
88
80
|
*
|
|
@@ -121,6 +113,12 @@ export interface ErrorDescriberOptions<
|
|
|
121
113
|
/**
|
|
122
114
|
* The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
|
|
123
115
|
* ignored — the server names a sentence, it does not get to name any key in the app.
|
|
116
|
+
*
|
|
117
|
+
* The `code` is read under this prefix too, for an API that has no `messageKey` field and
|
|
118
|
+
* lets the code name the sentence — one repo in the fleet has 98 of those, `errors.NOT_FOUND`
|
|
119
|
+
* and its siblings, against another repo's `messageKey`. Both are the same claim ("the server
|
|
120
|
+
* named a sentence this app carries"), both pass the same gate below, and an app that uses
|
|
121
|
+
* neither is unaffected because nothing matches.
|
|
124
122
|
*/
|
|
125
123
|
messageKeyPrefix: ServerPrefix;
|
|
126
124
|
/**
|
|
@@ -168,10 +166,18 @@ export function createErrorDescriber<
|
|
|
168
166
|
const has = (name: string): name is ServerName => known.has(name);
|
|
169
167
|
|
|
170
168
|
function serverSentence(error: ApiError): string | null {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
const lookup = (named: string | undefined): string | null => {
|
|
170
|
+
if (named === undefined || !named.startsWith(messageKeyPrefix)) return null;
|
|
171
|
+
const name = named.slice(messageKeyPrefix.length);
|
|
172
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
173
|
+
};
|
|
174
|
+
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
175
|
+
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
176
|
+
// under the same prefix so the gate is identical.
|
|
177
|
+
return (
|
|
178
|
+
lookup(error.messageKey) ??
|
|
179
|
+
(error.code === undefined ? null : lookup(`${messageKeyPrefix}${error.code}`))
|
|
180
|
+
);
|
|
175
181
|
}
|
|
176
182
|
|
|
177
183
|
return function describeError(error: unknown): DescribedError {
|
package/src/hydrate.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The constants themselves live in `prerender-contract.ts`, which imports nothing, because the
|
|
10
10
|
* BUILD needs them too and must not pull `react-dom/client` into a Node process to get a string.
|
|
11
|
+
*
|
|
12
|
+
* This module sits behind `@gusnips/react/hydrate` rather than in the barrel for the same reason
|
|
13
|
+
* one rung further out: it is the package's ONLY `react-dom` import, and a React Native app has
|
|
14
|
+
* no react-dom to give. Leaving it in the barrel made `react-dom` a required peer and shut the
|
|
15
|
+
* whole package out of every phone — for one function a phone would never call.
|
|
11
16
|
*/
|
|
12
17
|
import type { ReactNode } from "react";
|
|
13
18
|
import { createRoot, hydrateRoot } from "react-dom/client";
|