@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.3.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
  },
@@ -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. `AbortSignal.timeout` rejects with a **TimeoutError**, not an AbortError,
154
- * so `isAbortError` stays false for it — which is what lets a timeout be retried and reported
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 exactly
161
- // what `AbortSignal.any` in the client exists to cover.
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 DOMException).name).toBe("TimeoutError");
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
- // `AbortSignal.any` rather than a listener: it also catches the already-aborted race, where
219
- // a caller's signal fired before we ever attached. It keeps the caller's abort and our
220
- // timeout distinguishable at the source, which is what lets `isAbortError` stay honest.
221
- const timeout = AbortSignal.timeout(budget);
222
- return fetch(`${baseUrl}${path}`, {
223
- ...init,
224
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
225
- headers: buildHeaders(token, headers),
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: { requestId?: string; expected?: boolean; message?: string } = {},
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.
@@ -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, retryAfterSecs } from "./describe-error.ts";
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
+ });
@@ -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 messageKey = error.messageKey;
172
- if (messageKey === undefined || !messageKey.startsWith(messageKeyPrefix)) return null;
173
- const name = messageKey.slice(messageKeyPrefix.length);
174
- return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
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";