@gusnips/react 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/describe-error.d.ts +33 -2
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +32 -22
- package/dist/describe-error.js.map +1 -1
- package/package.json +1 -1
- package/src/describe-error.test.ts +84 -0
- package/src/describe-error.ts +69 -24
package/README.md
CHANGED
|
@@ -179,6 +179,20 @@ It is required because the default hid its price: `humanizeWait` reads `waitSeco
|
|
|
179
179
|
`waitMinutes` and `waitHours` out of your catalog, so an app that already formats relative time
|
|
180
180
|
with `Intl` had to add six entries per language before it would compile.
|
|
181
181
|
|
|
182
|
+
An error your `codes` map does not name falls back to the server's own `message`. Pass `fallback`
|
|
183
|
+
when that message is written for a log rather than for a person:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
fallback: ({ says, wait }) => ({
|
|
187
|
+
cause: says ?? t("errors.invalid"),
|
|
188
|
+
fix: wait ? t("errors.retryIn", { when: wait }) : undefined,
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Some APIs write `message` for whoever reads the screen. Some write it for whoever reads the log —
|
|
193
|
+
in English, in an app that ships three languages. Your arm also gets the stated wait, which the
|
|
194
|
+
default has nowhere to put.
|
|
195
|
+
|
|
182
196
|
## Also here
|
|
183
197
|
|
|
184
198
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
package/dist/describe-error.d.ts
CHANGED
|
@@ -81,6 +81,19 @@ type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
|
81
81
|
* left adding six entries per language to satisfy a compiler. `formatWait` owns that now.
|
|
82
82
|
*/
|
|
83
83
|
type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
|
|
84
|
+
/**
|
|
85
|
+
* A plural is stored under suffixed names and named by its base, and the gate has to know both.
|
|
86
|
+
*
|
|
87
|
+
* i18next keeps `seatLimitReached_one` and `seatLimitReached_other`; the server sends
|
|
88
|
+
* `serverErrors.seatLimitReached`, because that is the key a person resolves with a `count`. So
|
|
89
|
+
* `Object.keys` on a catalog does NOT contain the name the server actually sends, and a gate
|
|
90
|
+
* built from it alone rejects a sentence the app is carrying — falling back to the server's
|
|
91
|
+
* English for the one refusal that had a written answer. Measured in the seventh migration: two
|
|
92
|
+
* of that API's 112 keys, both of them plan limits, both of them the exact screen where a
|
|
93
|
+
* specific sentence earns its keep.
|
|
94
|
+
*/
|
|
95
|
+
type PluralSuffix = "zero" | "one" | "two" | "few" | "many" | "other";
|
|
96
|
+
type PluralBase<N extends string> = N extends `${infer Base}_${PluralSuffix}` ? Base : N;
|
|
84
97
|
/**
|
|
85
98
|
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
86
99
|
*
|
|
@@ -111,7 +124,7 @@ export interface ErrorContext {
|
|
|
111
124
|
recover: RecoveryKind;
|
|
112
125
|
}
|
|
113
126
|
export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix extends string, ServerName extends string> {
|
|
114
|
-
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
127
|
+
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName | PluralBase<ServerName>}`>;
|
|
115
128
|
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
116
129
|
copyPrefix: Prefix;
|
|
117
130
|
/**
|
|
@@ -144,6 +157,9 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
144
157
|
* option exists to prevent. i18next answers a key it does not have with the key itself, so a
|
|
145
158
|
* deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
|
|
146
159
|
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
160
|
+
*
|
|
161
|
+
* Hand it over as it is. A plural sits in the catalog under suffixed names while the server
|
|
162
|
+
* names the base, so the base is admitted too — see {@link PluralBase}.
|
|
147
163
|
*/
|
|
148
164
|
knownMessageKeys: Record<ServerName, unknown>;
|
|
149
165
|
/**
|
|
@@ -170,6 +186,21 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
170
186
|
* code that does not exist is a compile error at the arm rather than a branch that never runs.
|
|
171
187
|
*/
|
|
172
188
|
codes?: Partial<Record<string, ErrorArm>>;
|
|
189
|
+
/**
|
|
190
|
+
* What to say when nothing in `codes` matches: a code this build has no arm for, or an answer
|
|
191
|
+
* with no envelope at all.
|
|
192
|
+
*
|
|
193
|
+
* The built-in arm hands the reader the server's own `message`, and for one API in the fleet
|
|
194
|
+
* that is right — its message IS the sentence a person should read. For another it is exactly
|
|
195
|
+
* wrong: that repo's own i18n rules say `message` is "the English fallback for logs", and it
|
|
196
|
+
* ships three languages, so the built-in would put developer English in front of a reader who
|
|
197
|
+
* does not speak it. One default cannot be right for both, and the one baked in here was
|
|
198
|
+
* simply the first adopter's convention, mistaken for a fact.
|
|
199
|
+
*
|
|
200
|
+
* An arm here is also handed the stated wait, which the built-in spends on nothing: a refusal
|
|
201
|
+
* with no arm of its own could state its expiry and have that go unsaid.
|
|
202
|
+
*/
|
|
203
|
+
fallback?: ErrorArm;
|
|
173
204
|
/**
|
|
174
205
|
* The codes that mean "this limit does not clear by waiting" — **the same list you hand
|
|
175
206
|
* `queryDefaults`**, because it answers the same question and a second copy is a second
|
|
@@ -181,6 +212,6 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
181
212
|
}
|
|
182
213
|
export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
|
|
183
214
|
/** Build the describer. */
|
|
184
|
-
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
215
|
+
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams, codes, fallback, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
185
216
|
export {};
|
|
186
217
|
//# sourceMappingURL=describe-error.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAkB,MAAM,gBAAgB,CAAC;AAG1D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY;AACtB,mDAAmD;AACjD,OAAO;AACT,gEAAgE;GAC9D,QAAQ;AACV,gFAAgF;GAC9E,MAAM;AACR,iFAAiF;GAC/E,MAAM,CAAC;AAEX,+FAA+F;AAC/F,MAAM,WAAW,SAAS;IACxB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,mFAAmF;IACnF,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D;;;;;;;;GAQG;AACH,KAAK,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/describe-error.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ApiError, retryAfterSecs } from "./api-error.js";
|
|
2
2
|
import { shouldRetry } from "./query.js";
|
|
3
|
+
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
|
|
3
4
|
/**
|
|
4
5
|
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
5
6
|
*
|
|
@@ -21,11 +22,16 @@ export function humanizeWait(t, secs, prefix) {
|
|
|
21
22
|
return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
|
|
22
23
|
}
|
|
23
24
|
/** Build the describer. */
|
|
24
|
-
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams = (params) => params, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams = (params) => params, codes = {}, fallback, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
26
|
+
// Both spellings of every name: the one the catalog stores, and — for a plural — the one the
|
|
27
|
+
// server sends. `Object.keys` and not `in`, which would have accepted "toString".
|
|
28
|
+
const known = new Set();
|
|
29
|
+
for (const name of Object.keys(knownMessageKeys)) {
|
|
30
|
+
known.add(name);
|
|
31
|
+
known.add(name.replace(PLURAL_SUFFIX, ""));
|
|
32
|
+
}
|
|
33
|
+
// Still sound, and still the reason nothing here needs a cast: the set holds exactly the raw
|
|
34
|
+
// keys and their plural bases, which is exactly what the predicate claims.
|
|
29
35
|
const has = (name) => known.has(name);
|
|
30
36
|
function serverSentence(error) {
|
|
31
37
|
const lookup = (named) => {
|
|
@@ -61,6 +67,25 @@ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPref
|
|
|
61
67
|
return "wait";
|
|
62
68
|
return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Anything `codes` does not name. The server's own sentence is still the most specific thing
|
|
72
|
+
* we have, and support can act on it. A 5xx additionally gets "try again shortly", because that
|
|
73
|
+
* one genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
74
|
+
* the reader another attempt.
|
|
75
|
+
*
|
|
76
|
+
* No `code` means no envelope, and then `message` is not the server's: it is the client's own
|
|
77
|
+
* `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
78
|
+
* restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
79
|
+
* whose three apps each guarded against that one string by hand.
|
|
80
|
+
*
|
|
81
|
+
* Replaceable, because reaching for `message` is a convention rather than a fact — see
|
|
82
|
+
* `fallback`.
|
|
83
|
+
*/
|
|
84
|
+
const unmapped = fallback ??
|
|
85
|
+
(({ error, says }) => ({
|
|
86
|
+
cause: says ?? ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
87
|
+
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
88
|
+
}));
|
|
64
89
|
return function describeError(error) {
|
|
65
90
|
if (!(error instanceof ApiError)) {
|
|
66
91
|
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
@@ -82,23 +107,8 @@ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPref
|
|
|
82
107
|
waitSecs,
|
|
83
108
|
recover: recoveryFor(error, waitSecs),
|
|
84
109
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
88
|
-
// the reader another attempt.
|
|
89
|
-
//
|
|
90
|
-
// No `code` means no envelope, and then `message` is not the server's: it is the client's
|
|
91
|
-
// own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
92
|
-
// restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
93
|
-
// whose three apps each guarded against that one string by hand.
|
|
94
|
-
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
95
|
-
const copy = arm
|
|
96
|
-
? arm(ctx)
|
|
97
|
-
: {
|
|
98
|
-
cause: ctx.says ??
|
|
99
|
-
((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
100
|
-
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
101
|
-
};
|
|
110
|
+
const arm = (error.code === undefined ? undefined : codes[error.code]) ?? unmapped;
|
|
111
|
+
const copy = arm(ctx);
|
|
102
112
|
// The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
|
|
103
113
|
// error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
|
|
104
114
|
// `reference` slot for it since the contract was written.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AA2GzC,MAAM,aAAa,GAAG,iCAAiC,CAAC;AA2BxD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAC1B,CAAoC,EACpC,IAAY,EACZ,MAAc;IAEd,IAAI,IAAI,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;IAC1F,IAAI,IAAI,GAAG,EAAE,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IACvF,OAAO,CAAC,CAAC,GAAG,MAAM,WAAW,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;AACrE,CAAC;AAkHD,2BAA2B;AAC3B,MAAM,UAAU,oBAAoB,CAIlC,EACA,CAAC,EACD,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EACnC,KAAK,GAAG,EAAE,EACV,QAAQ,EACR,iBAAiB,GAAG,EAAE,EACtB,gBAAgB,GACwC;IACxD,6FAA6F;IAC7F,kFAAkF;IAClF,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACjD,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED,6FAA6F;IAC7F,2EAA2E;IAC3E,MAAM,GAAG,GAAG,CAAC,IAAY,EAA+C,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE3F,SAAS,cAAc,CAAC,KAAe;QACrC,MAAM,MAAM,GAAG,CAAC,KAAyB,EAAiB,EAAE;YAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,gBAAgB,CAAC;gBAAE,OAAO,IAAI,CAAC;YAC5E,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;YAClD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,gBAAgB,GAAG,IAAI,EAAE,EAAE,cAAc,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC1F,CAAC,CAAC;QACF,wFAAwF;QACxF,4FAA4F;QAC5F,kDAAkD;QAClD,OAAO,CACL,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;YACxB,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,gBAAgB,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAC/E,CAAC;IACJ,CAAC;IAED;;;;;OAKG;IACH,SAAS,WAAW,CAAC,KAAe,EAAE,QAAuB;QAC3D,6FAA6F;QAC7F,0FAA0F;QAC1F,kFAAkF;QAClF,IAAI,KAAK,CAAC,QAAQ;YAAE,OAAO,MAAM,CAAC;QAClC,6FAA6F;QAC7F,0FAA0F;QAC1F,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG;YAAE,OAAO,QAAQ,CAAC;QAC1C,6FAA6F;QAC7F,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO,MAAM,CAAC;QACrC,OAAO,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IACpF,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,QAAQ,GACZ,QAAQ;QACR,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;YACrB,KAAK,EACH,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,UAAU,YAAY,CAAC,CAAC;YAC3F,GAAG,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,UAAU,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;SACnE,CAAC,CAAC,CAAC;IAEN,OAAO,SAAS,aAAa,CAAC,KAAc;QAC1C,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC,EAAE,CAAC;YACjC,0FAA0F;YAC1F,4FAA4F;YAC5F,qEAAqE;YACrE,OAAO;gBACL,KAAK,EAAE,CAAC,CAAC,GAAG,UAAU,SAAS,CAAC;gBAChC,uFAAuF;gBACvF,kFAAkF;gBAClF,GAAG,EAAE,CAAC,CAAC,GAAG,UAAU,aAAa,CAAC;gBAClC,OAAO,EAAE,OAAO;aACjB,CAAC;QACJ,CAAC;QAED,MAAM,QAAQ,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,GAAG,GAAiB;YACxB,KAAK;YACL,IAAI,EAAE,cAAc,CAAC,KAAK,CAAC;YAC3B,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC;YACrD,QAAQ;YACR,OAAO,EAAE,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC;SACtC,CAAC;QAEF,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,QAAQ,CAAC;QACnF,MAAM,IAAI,GAAc,GAAG,CAAC,GAAG,CAAC,CAAC;QAEjC,2FAA2F;QAC3F,sFAAsF;QACtF,0DAA0D;QAC1D,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC;QACpD,OAAO;YACL,GAAG,IAAI;YACP,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,GAAG,CAAC,OAAO;YACpC,GAAG,CAAC,SAAS,KAAK,SAAS,IAAI,EAAE,SAAS,EAAE,CAAC;SAC9C,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -113,6 +113,27 @@ describe("createErrorDescriber", () => {
|
|
|
113
113
|
// The server names a SENTENCE, not any key in the app. A messageKey outside the namespace
|
|
114
114
|
// is ignored rather than resolved — otherwise a compromised or careless server could point
|
|
115
115
|
// the client at arbitrary copy.
|
|
116
|
+
// i18next stores a plural suffixed and the server names the base, so `Object.keys` alone does
|
|
117
|
+
// not contain the key the server sends. Before this, the two plan limits in the seventh
|
|
118
|
+
// migration's API — a seat limit and a profile limit — read as keys the build did not have,
|
|
119
|
+
// and each fell back to English on the one screen where the written sentence earns its keep.
|
|
120
|
+
it("admits the base name of a plural the catalog stores suffixed", () => {
|
|
121
|
+
const describePlural = createErrorDescriber({
|
|
122
|
+
t,
|
|
123
|
+
copyPrefix: "errors.",
|
|
124
|
+
formatWait: (secs) => humanizeWait(t, secs, "errors."),
|
|
125
|
+
messageKeyPrefix: "serverErrors.",
|
|
126
|
+
knownMessageKeys: { seatLimitReached_one: "", seatLimitReached_other: "" },
|
|
127
|
+
});
|
|
128
|
+
const error = new ApiError(402, {
|
|
129
|
+
code: "PAYMENT_REQUIRED",
|
|
130
|
+
message: "seat limit reached",
|
|
131
|
+
messageKey: "serverErrors.seatLimitReached",
|
|
132
|
+
params: { count: 3 },
|
|
133
|
+
});
|
|
134
|
+
expect(describePlural(error).cause).toBe('serverErrors.seatLimitReached({"count":3})');
|
|
135
|
+
});
|
|
136
|
+
|
|
116
137
|
it("ignores a messageKey outside the server namespace", () => {
|
|
117
138
|
const error = new ApiError(500, {
|
|
118
139
|
code: "UNKNOWN",
|
|
@@ -258,6 +279,69 @@ describe("localizeParams", () => {
|
|
|
258
279
|
});
|
|
259
280
|
});
|
|
260
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The seventh migration's second seam, and the same lesson as the first: a default that was one
|
|
284
|
+
* adopter's convention, shipped as if it were a fact.
|
|
285
|
+
*
|
|
286
|
+
* The built-in arm hands an unmapped code the server's `message`. That is right for the API whose
|
|
287
|
+
* message IS the reader's sentence. It is wrong for the one whose own i18n rules call `message`
|
|
288
|
+
* "the English fallback for logs" while the app ships three languages — there the built-in puts
|
|
289
|
+
* developer English on screen, which is the single thing that repo's rules forbid.
|
|
290
|
+
*/
|
|
291
|
+
describe("fallback", () => {
|
|
292
|
+
const options = {
|
|
293
|
+
t,
|
|
294
|
+
copyPrefix: "errors." as const,
|
|
295
|
+
formatWait: (secs: number) => humanizeWait(t, secs, "errors."),
|
|
296
|
+
messageKeyPrefix: "serverErrors." as const,
|
|
297
|
+
knownMessageKeys: { quotaDay: "" },
|
|
298
|
+
};
|
|
299
|
+
const describeError = createErrorDescriber({
|
|
300
|
+
...options,
|
|
301
|
+
fallback: ({ error, says, wait }) => ({
|
|
302
|
+
cause:
|
|
303
|
+
says ??
|
|
304
|
+
(error.code !== undefined && error.status < 500 ? "errors.invalid" : "errors.unexpected"),
|
|
305
|
+
fix: wait ? `errors.retryIn(${wait})` : undefined,
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("keeps the server's English log line off the screen", () => {
|
|
310
|
+
const error = new ApiError(422, {
|
|
311
|
+
code: "SLIDE_TOO_LONG",
|
|
312
|
+
message: "slide 3 exceeds 2200 chars",
|
|
313
|
+
});
|
|
314
|
+
expect(describeError(error).cause).toBe("errors.invalid");
|
|
315
|
+
// What the built-in would have said, which is the whole reason the seam exists.
|
|
316
|
+
expect(createErrorDescriber(options)(error).cause).toBe("slide 3 exceeds 2200 chars");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("still resolves a sentence this build carries", () => {
|
|
320
|
+
// The arm only runs for what `codes` left unnamed; it does not take over the key lookup.
|
|
321
|
+
const error = new ApiError(429, {
|
|
322
|
+
code: "SOMETHING_NEW",
|
|
323
|
+
message: "log line",
|
|
324
|
+
messageKey: "serverErrors.quotaDay",
|
|
325
|
+
});
|
|
326
|
+
expect(describeError(error).cause).toBe("serverErrors.quotaDay");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("hands the arm a stated wait the built-in spends on nothing", () => {
|
|
330
|
+
const error = new ApiError(
|
|
331
|
+
503,
|
|
332
|
+
{ code: "SOMETHING_NEW", message: "log line" },
|
|
333
|
+
{ retryAfterSecs: 120 },
|
|
334
|
+
);
|
|
335
|
+
expect(describeError(error).fix).toBe('errors.retryIn(errors.waitMinutes({"count":2}))');
|
|
336
|
+
expect(createErrorDescriber(options)(error).fix).toBe("errors.retrySoon");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("leaves the derived recovery kind alone unless the arm narrows it", () => {
|
|
340
|
+
const error = new ApiError(503, { code: "SOMETHING_NEW", message: "log line" });
|
|
341
|
+
expect(describeError(error).recover).toBe("retry");
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
261
345
|
/**
|
|
262
346
|
* The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
|
|
263
347
|
* names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
|
package/src/describe-error.ts
CHANGED
|
@@ -93,6 +93,21 @@ type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
|
93
93
|
*/
|
|
94
94
|
type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A plural is stored under suffixed names and named by its base, and the gate has to know both.
|
|
98
|
+
*
|
|
99
|
+
* i18next keeps `seatLimitReached_one` and `seatLimitReached_other`; the server sends
|
|
100
|
+
* `serverErrors.seatLimitReached`, because that is the key a person resolves with a `count`. So
|
|
101
|
+
* `Object.keys` on a catalog does NOT contain the name the server actually sends, and a gate
|
|
102
|
+
* built from it alone rejects a sentence the app is carrying — falling back to the server's
|
|
103
|
+
* English for the one refusal that had a written answer. Measured in the seventh migration: two
|
|
104
|
+
* of that API's 112 keys, both of them plan limits, both of them the exact screen where a
|
|
105
|
+
* specific sentence earns its keep.
|
|
106
|
+
*/
|
|
107
|
+
type PluralSuffix = "zero" | "one" | "two" | "few" | "many" | "other";
|
|
108
|
+
type PluralBase<N extends string> = N extends `${infer Base}_${PluralSuffix}` ? Base : N;
|
|
109
|
+
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
|
|
110
|
+
|
|
96
111
|
/**
|
|
97
112
|
* The paragraph above, pinned so it cannot quietly stop being true.
|
|
98
113
|
*
|
|
@@ -162,7 +177,7 @@ export interface ErrorDescriberOptions<
|
|
|
162
177
|
ServerPrefix extends string,
|
|
163
178
|
ServerName extends string,
|
|
164
179
|
> {
|
|
165
|
-
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
180
|
+
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName | PluralBase<ServerName>}`>;
|
|
166
181
|
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
167
182
|
copyPrefix: Prefix;
|
|
168
183
|
/**
|
|
@@ -195,6 +210,9 @@ export interface ErrorDescriberOptions<
|
|
|
195
210
|
* option exists to prevent. i18next answers a key it does not have with the key itself, so a
|
|
196
211
|
* deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
|
|
197
212
|
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
213
|
+
*
|
|
214
|
+
* Hand it over as it is. A plural sits in the catalog under suffixed names while the server
|
|
215
|
+
* names the base, so the base is admitted too — see {@link PluralBase}.
|
|
198
216
|
*/
|
|
199
217
|
knownMessageKeys: Record<ServerName, unknown>;
|
|
200
218
|
/**
|
|
@@ -223,6 +241,21 @@ export interface ErrorDescriberOptions<
|
|
|
223
241
|
* code that does not exist is a compile error at the arm rather than a branch that never runs.
|
|
224
242
|
*/
|
|
225
243
|
codes?: Partial<Record<string, ErrorArm>>;
|
|
244
|
+
/**
|
|
245
|
+
* What to say when nothing in `codes` matches: a code this build has no arm for, or an answer
|
|
246
|
+
* with no envelope at all.
|
|
247
|
+
*
|
|
248
|
+
* The built-in arm hands the reader the server's own `message`, and for one API in the fleet
|
|
249
|
+
* that is right — its message IS the sentence a person should read. For another it is exactly
|
|
250
|
+
* wrong: that repo's own i18n rules say `message` is "the English fallback for logs", and it
|
|
251
|
+
* ships three languages, so the built-in would put developer English in front of a reader who
|
|
252
|
+
* does not speak it. One default cannot be right for both, and the one baked in here was
|
|
253
|
+
* simply the first adopter's convention, mistaken for a fact.
|
|
254
|
+
*
|
|
255
|
+
* An arm here is also handed the stated wait, which the built-in spends on nothing: a refusal
|
|
256
|
+
* with no arm of its own could state its expiry and have that go unsaid.
|
|
257
|
+
*/
|
|
258
|
+
fallback?: ErrorArm;
|
|
226
259
|
/**
|
|
227
260
|
* The codes that mean "this limit does not clear by waiting" — **the same list you hand
|
|
228
261
|
* `queryDefaults`**, because it answers the same question and a second copy is a second
|
|
@@ -248,15 +281,21 @@ export function createErrorDescriber<
|
|
|
248
281
|
knownMessageKeys,
|
|
249
282
|
localizeParams = (params) => params,
|
|
250
283
|
codes = {},
|
|
284
|
+
fallback,
|
|
251
285
|
durableLimitCodes = [],
|
|
252
286
|
maxRetryWaitSecs,
|
|
253
287
|
}: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError {
|
|
254
|
-
|
|
288
|
+
// Both spellings of every name: the one the catalog stores, and — for a plural — the one the
|
|
289
|
+
// server sends. `Object.keys` and not `in`, which would have accepted "toString".
|
|
290
|
+
const known = new Set<string>();
|
|
291
|
+
for (const name of Object.keys(knownMessageKeys)) {
|
|
292
|
+
known.add(name);
|
|
293
|
+
known.add(name.replace(PLURAL_SUFFIX, ""));
|
|
294
|
+
}
|
|
255
295
|
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
const has = (name: string): name is ServerName => known.has(name);
|
|
296
|
+
// Still sound, and still the reason nothing here needs a cast: the set holds exactly the raw
|
|
297
|
+
// keys and their plural bases, which is exactly what the predicate claims.
|
|
298
|
+
const has = (name: string): name is ServerName | PluralBase<ServerName> => known.has(name);
|
|
260
299
|
|
|
261
300
|
function serverSentence(error: ApiError): string | null {
|
|
262
301
|
const lookup = (named: string | undefined): string | null => {
|
|
@@ -292,6 +331,28 @@ export function createErrorDescriber<
|
|
|
292
331
|
return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
|
|
293
332
|
}
|
|
294
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Anything `codes` does not name. The server's own sentence is still the most specific thing
|
|
336
|
+
* we have, and support can act on it. A 5xx additionally gets "try again shortly", because that
|
|
337
|
+
* one genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
338
|
+
* the reader another attempt.
|
|
339
|
+
*
|
|
340
|
+
* No `code` means no envelope, and then `message` is not the server's: it is the client's own
|
|
341
|
+
* `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
342
|
+
* restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
343
|
+
* whose three apps each guarded against that one string by hand.
|
|
344
|
+
*
|
|
345
|
+
* Replaceable, because reaching for `message` is a convention rather than a fact — see
|
|
346
|
+
* `fallback`.
|
|
347
|
+
*/
|
|
348
|
+
const unmapped: ErrorArm =
|
|
349
|
+
fallback ??
|
|
350
|
+
(({ error, says }) => ({
|
|
351
|
+
cause:
|
|
352
|
+
says ?? ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
353
|
+
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
354
|
+
}));
|
|
355
|
+
|
|
295
356
|
return function describeError(error: unknown): DescribedError {
|
|
296
357
|
if (!(error instanceof ApiError)) {
|
|
297
358
|
// Not a response at all — the request never landed. Almost always the network, and almost
|
|
@@ -315,24 +376,8 @@ export function createErrorDescriber<
|
|
|
315
376
|
recover: recoveryFor(error, waitSecs),
|
|
316
377
|
};
|
|
317
378
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
|
|
321
|
-
// the reader another attempt.
|
|
322
|
-
//
|
|
323
|
-
// No `code` means no envelope, and then `message` is not the server's: it is the client's
|
|
324
|
-
// own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
|
|
325
|
-
// restarts is exactly when that happens, so it reached screens — until the second migration,
|
|
326
|
-
// whose three apps each guarded against that one string by hand.
|
|
327
|
-
const arm = error.code === undefined ? undefined : codes[error.code];
|
|
328
|
-
const copy: ErrorCopy = arm
|
|
329
|
-
? arm(ctx)
|
|
330
|
-
: {
|
|
331
|
-
cause:
|
|
332
|
-
ctx.says ??
|
|
333
|
-
((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
334
|
-
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
335
|
-
};
|
|
379
|
+
const arm = (error.code === undefined ? undefined : codes[error.code]) ?? unmapped;
|
|
380
|
+
const copy: ErrorCopy = arm(ctx);
|
|
336
381
|
|
|
337
382
|
// The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
|
|
338
383
|
// error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
|