@gusnips/react 0.2.0 → 0.3.0

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.
@@ -23,8 +23,21 @@ export interface DescribedError {
23
23
  /** What to do about it. Its absence should be a decision, not an oversight. */
24
24
  hint?: string;
25
25
  }
26
- /** The subset of i18next's `t` this module needs — typed here so `i18next` stays optional. */
27
- export type Translate = (key: string, params?: Record<string, unknown>) => string;
26
+ /**
27
+ * The subset of i18next's `t` this module needs — typed here so `i18next` stays optional.
28
+ *
29
+ * `Key` is a parameter and not plain `string` for a reason the first migration found. Every app
30
+ * on this stack declares `CustomTypeOptions.resources`, which types `t` to accept ONLY the keys
31
+ * its catalog has — so a `t` is not assignable to anything asking for `(key: string) => string`,
32
+ * and the adopter is left with a cast. Naming the keys instead turns that around: each function
33
+ * below asks for exactly the keys it looks up, a typed `t` accepts a superset of them and goes
34
+ * in directly, and a catalog missing one is a compile error rather than a raw key on screen.
35
+ */
36
+ export type Translate<Key extends string = string> = (key: Key, params?: Record<string, unknown>) => string;
37
+ /** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
38
+ type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
39
+ /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
40
+ type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
28
41
  /** Seconds until a refusal clears, when the envelope carries them. */
29
42
  export declare function retryAfterSecs(error: ApiError): number | null;
30
43
  /**
@@ -32,31 +45,10 @@ export declare function retryAfterSecs(error: ApiError): number | null;
32
45
  *
33
46
  * The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
34
47
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
35
- * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` in the catalog, each with a
48
+ * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
36
49
  * `count` plural.
37
50
  */
38
- export declare function humanizeWait(t: Translate, secs: number, prefix?: string): string;
39
- export interface ErrorDescriberOptions<Code extends string> {
40
- t: Translate;
41
- /**
42
- * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
43
- * ignored — the server names a sentence, it does not get to name any key in the app.
44
- */
45
- messageKeyPrefix?: string;
46
- /**
47
- * The keys this build actually carries, usually `Object.keys(en.serverErrors)`.
48
- *
49
- * Membership is checked with `hasOwnProperty` and not `in`, which would accept `"toString"`.
50
- * A key that is absent falls back to the server's English `message`, because a deploy can land
51
- * ahead of the bundle a tab is still running and a missing translation must degrade to
52
- * readable rather than render the key itself at somebody.
53
- */
54
- knownMessageKeys?: readonly string[] | Record<string, unknown>;
55
- /** Catalog namespace for this module's own copy. Defaults to `"errors."`. */
56
- copyPrefix?: string;
57
- /** Per-code copy. Everything not listed falls through to the default arm below. */
58
- codes?: Partial<Record<Code, (ctx: ErrorContext) => DescribedError>>;
59
- }
51
+ export declare function humanizeWait<Prefix extends string>(t: Translate<`${Prefix}${WaitName}`>, secs: number, prefix: Prefix): string;
60
52
  export interface ErrorContext {
61
53
  error: ApiError;
62
54
  /** The server's own localized sentence, when `messageKey` named one this build carries. */
@@ -64,11 +56,40 @@ export interface ErrorContext {
64
56
  /** Seconds until it clears, already humanized. Null when the envelope did not say. */
65
57
  wait: string | null;
66
58
  waitSecs: number | null;
67
- t: Translate;
68
59
  }
69
- /**
70
- * Build the describer. Needs `network`, `networkHint`, `unexpected`, `retrySoon`,
71
- * `waitSeconds`, `waitMinutes` and `waitHours` under `copyPrefix` in every locale.
72
- */
73
- export declare function createErrorDescriber<Code extends string>({ t, messageKeyPrefix, knownMessageKeys, copyPrefix, codes, }: ErrorDescriberOptions<Code>): (error: unknown) => DescribedError;
60
+ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix extends string, ServerName extends string> {
61
+ t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
62
+ /** Catalog namespace for this module's own copy, e.g. `"errors."`. */
63
+ copyPrefix: Prefix;
64
+ /**
65
+ * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
66
+ * ignored — the server names a sentence, it does not get to name any key in the app.
67
+ */
68
+ messageKeyPrefix: ServerPrefix;
69
+ /**
70
+ * The keys this build actually carries, usually the `serverErrors` object out of the English
71
+ * catalog. Passing the catalog itself is both the membership test and the proof the compiler
72
+ * wants: a name that is in it IS a key, so the lookup below needs no cast.
73
+ *
74
+ * It is required because leaving it out is not a smaller version of this — it is the bug the
75
+ * option exists to prevent. i18next answers a key it does not have with the key itself, so a
76
+ * deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
77
+ * somebody. A name that is absent falls back to the server's English `message` instead.
78
+ */
79
+ knownMessageKeys: Record<ServerName, unknown>;
80
+ /**
81
+ * Per-code copy. Everything not listed falls through to the default arm.
82
+ *
83
+ * The arms look up the app's own keys, so they close over the app's `t` rather than being
84
+ * handed one — this module has no names for that copy and no business typing it.
85
+ *
86
+ * Annotate your object `Partial<Record<YourErrorCode, ErrorArm>>` where you write it, and a
87
+ * code that does not exist is a compile error at the arm rather than a branch that never runs.
88
+ */
89
+ codes?: Partial<Record<string, ErrorArm>>;
90
+ }
91
+ export type ErrorArm = (ctx: ErrorContext) => DescribedError;
92
+ /** Build the describer. */
93
+ export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
94
+ export {};
74
95
  //# 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,EAAE,MAAM,gBAAgB,CAAC;AAE1C;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,8FAA8F;AAC9F,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC;AAElF,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,SAAK,GAAG,MAAM,CAK5E;AAED,MAAM,WAAW,qBAAqB,CAAC,IAAI,SAAS,MAAM;IACxD,CAAC,EAAE,SAAS,CAAC;IACb;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/D,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mFAAmF;IACnF,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC,CAAC,CAAC;CACtE;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,CAAC,EAAE,SAAS,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,SAAS,MAAM,EAAE,EACxD,CAAC,EACD,gBAAkC,EAClC,gBAAgB,EAChB,UAAsB,EACtB,KAAU,GACX,EAAE,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CA2ClE"}
1
+ {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;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,iGAAiG;AACjG,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA8BlF,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC;AAE7D,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,GACX,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CA0C9F"}
@@ -12,39 +12,35 @@ export function retryAfterSecs(error) {
12
12
  *
13
13
  * The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
14
14
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
15
- * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` in the catalog, each with a
15
+ * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
16
16
  * `count` plural.
17
17
  */
18
- export function humanizeWait(t, secs, prefix = "") {
19
- const key = (name) => `${prefix}${name}`;
18
+ export function humanizeWait(t, secs, prefix) {
20
19
  if (secs < 90)
21
- return t(key("waitSeconds"), { count: Math.max(1, Math.round(secs)) });
20
+ return t(`${prefix}waitSeconds`, { count: Math.max(1, Math.round(secs)) });
22
21
  if (secs < 90 * 60)
23
- return t(key("waitMinutes"), { count: Math.round(secs / 60) });
24
- return t(key("waitHours"), { count: Math.round(secs / 3600) });
22
+ return t(`${prefix}waitMinutes`, { count: Math.round(secs / 60) });
23
+ return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
25
24
  }
26
- /**
27
- * Build the describer. Needs `network`, `networkHint`, `unexpected`, `retrySoon`,
28
- * `waitSeconds`, `waitMinutes` and `waitHours` under `copyPrefix` in every locale.
29
- */
30
- export function createErrorDescriber({ t, messageKeyPrefix = "serverErrors.", knownMessageKeys, copyPrefix = "errors.", codes = {}, }) {
31
- const known = Array.isArray(knownMessageKeys)
32
- ? new Set(knownMessageKeys)
33
- : new Set(Object.keys(knownMessageKeys ?? {}));
34
- const has = (name) => known.size === 0 || known.has(name);
35
- const key = (name) => `${copyPrefix}${name}`;
25
+ /** Build the describer. */
26
+ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, }) {
27
+ const known = new Set(Object.keys(knownMessageKeys));
28
+ // Sound, and the reason nothing here needs a cast: the set was built from the keys of
29
+ // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
30
+ // `in`, which would have accepted "toString".
31
+ const has = (name) => known.has(name);
36
32
  function serverSentence(error) {
37
33
  const messageKey = error.messageKey;
38
34
  if (messageKey === undefined || !messageKey.startsWith(messageKeyPrefix))
39
35
  return null;
40
36
  const name = messageKey.slice(messageKeyPrefix.length);
41
- return has(name) ? t(messageKey, error.params) : null;
37
+ return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
42
38
  }
43
39
  return function describeError(error) {
44
40
  if (!(error instanceof ApiError)) {
45
41
  // Not a response at all — the request never landed. Almost always the network, and almost
46
42
  // never worth showing a stack trace for.
47
- return { cause: t(key("network")), hint: t(key("networkHint")) };
43
+ return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
48
44
  }
49
45
  const waitSecs = retryAfterSecs(error);
50
46
  const ctx = {
@@ -52,7 +48,6 @@ export function createErrorDescriber({ t, messageKeyPrefix = "serverErrors.", kn
52
48
  says: serverSentence(error),
53
49
  wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
54
50
  waitSecs,
55
- t,
56
51
  };
57
52
  const arm = error.code === undefined ? undefined : codes[error.code];
58
53
  if (arm)
@@ -62,8 +57,8 @@ export function createErrorDescriber({ t, messageKeyPrefix = "serverErrors.", kn
62
57
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
63
58
  // the reader another attempt.
64
59
  return {
65
- cause: ctx.says ?? (error.message || t(key("unexpected"))),
66
- hint: error.status >= 500 ? t(key("retrySoon")) : undefined,
60
+ cause: ctx.says ?? (error.message || t(`${copyPrefix}unexpected`)),
61
+ hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
67
62
  };
68
63
  };
69
64
  }
@@ -1 +1 @@
1
- {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshRes
4
4
  export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
5
5
  export { queryDefaults, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
6
6
  export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
7
- export { createErrorDescriber, humanizeWait, retryAfterSecs, type DescribedError, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
7
+ export { createErrorDescriber, humanizeWait, retryAfterSecs, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
8
8
  export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError, } from "./chunk-reload.ts";
9
9
  export { hydrateOrMount, PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./hydrate.ts";
10
10
  export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEnF,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEnF,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,EAAE;AACF,iGAAiG;AACjG,6FAA6F;AAC7F,iGAAiG;AACjG,+FAA+F;AAC/F,0FAA0F;AAC1F,qFAAqF;AAErF,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,GAKf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEnF,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,EAAE;AACF,iGAAiG;AACjG,6FAA6F;AAC7F,iGAAiG;AACjG,+FAA+F;AAC/F,0FAA0F;AAC1F,qFAAqF;AAErF,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,GAMf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEnF,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
@@ -42,7 +42,9 @@ describe("retryAfterSecs", () => {
42
42
  describe("createErrorDescriber", () => {
43
43
  const describeError = createErrorDescriber({
44
44
  t,
45
- knownMessageKeys: ["quotaDay", "suspended"],
45
+ copyPrefix: "errors.",
46
+ messageKeyPrefix: "serverErrors.",
47
+ knownMessageKeys: { quotaDay: "", suspended: "" },
46
48
  codes: {
47
49
  QUOTA_EXCEEDED: ({ says, wait }) => ({
48
50
  cause: says ?? "errors.quota",
@@ -26,8 +26,54 @@ export interface DescribedError {
26
26
  hint?: string;
27
27
  }
28
28
 
29
- /** The subset of i18next's `t` this module needs — typed here so `i18next` stays optional. */
30
- export type Translate = (key: string, params?: Record<string, unknown>) => string;
29
+ /**
30
+ * The subset of i18next's `t` this module needs — typed here so `i18next` stays optional.
31
+ *
32
+ * `Key` is a parameter and not plain `string` for a reason the first migration found. Every app
33
+ * on this stack declares `CustomTypeOptions.resources`, which types `t` to accept ONLY the keys
34
+ * its catalog has — so a `t` is not assignable to anything asking for `(key: string) => string`,
35
+ * and the adopter is left with a cast. Naming the keys instead turns that around: each function
36
+ * below asks for exactly the keys it looks up, a typed `t` accepts a superset of them and goes
37
+ * in directly, and a catalog missing one is a compile error rather than a raw key on screen.
38
+ */
39
+ export type Translate<Key extends string = string> = (
40
+ key: Key,
41
+ params?: Record<string, unknown>,
42
+ ) => string;
43
+
44
+ /** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
45
+ type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
46
+
47
+ /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
48
+ type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
49
+
50
+ /**
51
+ * The paragraph above, pinned so it cannot quietly stop being true.
52
+ *
53
+ * `ClosedCatalog` is what an app's `t` looks like once it declares `CustomTypeOptions.resources`:
54
+ * a closed union, nothing else accepted. It must go straight into the options below. Widen `Key`
55
+ * back to `string` and this stops compiling HERE, rather than at the eleventh adopter reaching
56
+ * for the cast the house rules forbid.
57
+ */
58
+ type Satisfied<T extends true> = T;
59
+ type ClosedCatalog = (
60
+ key:
61
+ | "errors.network"
62
+ | "errors.networkHint"
63
+ | "errors.unexpected"
64
+ | "errors.retrySoon"
65
+ | "errors.waitSeconds"
66
+ | "errors.waitMinutes"
67
+ | "errors.waitHours"
68
+ | "serverErrors.quotaDay"
69
+ | "billing.plan",
70
+ params?: Record<string, unknown>,
71
+ ) => string;
72
+ type _TypedCatalogNeedsNoCast = Satisfied<
73
+ ClosedCatalog extends ErrorDescriberOptions<"errors.", "serverErrors.", "quotaDay">["t"]
74
+ ? true
75
+ : false
76
+ >;
31
77
 
32
78
  /** Seconds until a refusal clears, when the envelope carries them. */
33
79
  export function retryAfterSecs(error: ApiError): number | null {
@@ -42,78 +88,97 @@ export function retryAfterSecs(error: ApiError): number | null {
42
88
  *
43
89
  * The thresholds are deliberately not round: 90 seconds rather than 60, so "in 75 seconds" does
44
90
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
45
- * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` in the catalog, each with a
91
+ * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
46
92
  * `count` plural.
47
93
  */
48
- export function humanizeWait(t: Translate, secs: number, prefix = ""): string {
49
- const key = (name: string): string => `${prefix}${name}`;
50
- if (secs < 90) return t(key("waitSeconds"), { count: Math.max(1, Math.round(secs)) });
51
- if (secs < 90 * 60) return t(key("waitMinutes"), { count: Math.round(secs / 60) });
52
- return t(key("waitHours"), { count: Math.round(secs / 3600) });
94
+ export function humanizeWait<Prefix extends string>(
95
+ t: Translate<`${Prefix}${WaitName}`>,
96
+ secs: number,
97
+ prefix: Prefix,
98
+ ): string {
99
+ if (secs < 90) return t(`${prefix}waitSeconds`, { count: Math.max(1, Math.round(secs)) });
100
+ if (secs < 90 * 60) return t(`${prefix}waitMinutes`, { count: Math.round(secs / 60) });
101
+ return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
102
+ }
103
+
104
+ export interface ErrorContext {
105
+ error: ApiError;
106
+ /** The server's own localized sentence, when `messageKey` named one this build carries. */
107
+ says: string | null;
108
+ /** Seconds until it clears, already humanized. Null when the envelope did not say. */
109
+ wait: string | null;
110
+ waitSecs: number | null;
53
111
  }
54
112
 
55
- export interface ErrorDescriberOptions<Code extends string> {
56
- t: Translate;
113
+ export interface ErrorDescriberOptions<
114
+ Prefix extends string,
115
+ ServerPrefix extends string,
116
+ ServerName extends string,
117
+ > {
118
+ t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
119
+ /** Catalog namespace for this module's own copy, e.g. `"errors."`. */
120
+ copyPrefix: Prefix;
57
121
  /**
58
122
  * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
59
123
  * ignored — the server names a sentence, it does not get to name any key in the app.
60
124
  */
61
- messageKeyPrefix?: string;
125
+ messageKeyPrefix: ServerPrefix;
126
+ /**
127
+ * The keys this build actually carries, usually the `serverErrors` object out of the English
128
+ * catalog. Passing the catalog itself is both the membership test and the proof the compiler
129
+ * wants: a name that is in it IS a key, so the lookup below needs no cast.
130
+ *
131
+ * It is required because leaving it out is not a smaller version of this — it is the bug the
132
+ * option exists to prevent. i18next answers a key it does not have with the key itself, so a
133
+ * deploy landing ahead of the bundle a tab is still running would render `serverErrors.foo` at
134
+ * somebody. A name that is absent falls back to the server's English `message` instead.
135
+ */
136
+ knownMessageKeys: Record<ServerName, unknown>;
62
137
  /**
63
- * The keys this build actually carries, usually `Object.keys(en.serverErrors)`.
138
+ * Per-code copy. Everything not listed falls through to the default arm.
139
+ *
140
+ * The arms look up the app's own keys, so they close over the app's `t` rather than being
141
+ * handed one — this module has no names for that copy and no business typing it.
64
142
  *
65
- * Membership is checked with `hasOwnProperty` and not `in`, which would accept `"toString"`.
66
- * A key that is absent falls back to the server's English `message`, because a deploy can land
67
- * ahead of the bundle a tab is still running and a missing translation must degrade to
68
- * readable rather than render the key itself at somebody.
143
+ * Annotate your object `Partial<Record<YourErrorCode, ErrorArm>>` where you write it, and a
144
+ * code that does not exist is a compile error at the arm rather than a branch that never runs.
69
145
  */
70
- knownMessageKeys?: readonly string[] | Record<string, unknown>;
71
- /** Catalog namespace for this module's own copy. Defaults to `"errors."`. */
72
- copyPrefix?: string;
73
- /** Per-code copy. Everything not listed falls through to the default arm below. */
74
- codes?: Partial<Record<Code, (ctx: ErrorContext) => DescribedError>>;
146
+ codes?: Partial<Record<string, ErrorArm>>;
75
147
  }
76
148
 
77
- export interface ErrorContext {
78
- error: ApiError;
79
- /** The server's own localized sentence, when `messageKey` named one this build carries. */
80
- says: string | null;
81
- /** Seconds until it clears, already humanized. Null when the envelope did not say. */
82
- wait: string | null;
83
- waitSecs: number | null;
84
- t: Translate;
85
- }
149
+ export type ErrorArm = (ctx: ErrorContext) => DescribedError;
86
150
 
87
- /**
88
- * Build the describer. Needs `network`, `networkHint`, `unexpected`, `retrySoon`,
89
- * `waitSeconds`, `waitMinutes` and `waitHours` under `copyPrefix` in every locale.
90
- */
91
- export function createErrorDescriber<Code extends string>({
151
+ /** Build the describer. */
152
+ export function createErrorDescriber<
153
+ Prefix extends string,
154
+ ServerPrefix extends string,
155
+ ServerName extends string,
156
+ >({
92
157
  t,
93
- messageKeyPrefix = "serverErrors.",
158
+ copyPrefix,
159
+ messageKeyPrefix,
94
160
  knownMessageKeys,
95
- copyPrefix = "errors.",
96
161
  codes = {},
97
- }: ErrorDescriberOptions<Code>): (error: unknown) => DescribedError {
98
- const known = Array.isArray(knownMessageKeys)
99
- ? new Set<string>(knownMessageKeys)
100
- : new Set(Object.keys(knownMessageKeys ?? {}));
162
+ }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError {
163
+ const known = new Set<string>(Object.keys(knownMessageKeys));
101
164
 
102
- const has = (name: string): boolean => known.size === 0 || known.has(name);
103
- const key = (name: string): string => `${copyPrefix}${name}`;
165
+ // Sound, and the reason nothing here needs a cast: the set was built from the keys of
166
+ // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
167
+ // `in`, which would have accepted "toString".
168
+ const has = (name: string): name is ServerName => known.has(name);
104
169
 
105
170
  function serverSentence(error: ApiError): string | null {
106
171
  const messageKey = error.messageKey;
107
172
  if (messageKey === undefined || !messageKey.startsWith(messageKeyPrefix)) return null;
108
173
  const name = messageKey.slice(messageKeyPrefix.length);
109
- return has(name) ? t(messageKey, error.params) : null;
174
+ return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
110
175
  }
111
176
 
112
177
  return function describeError(error: unknown): DescribedError {
113
178
  if (!(error instanceof ApiError)) {
114
179
  // Not a response at all — the request never landed. Almost always the network, and almost
115
180
  // never worth showing a stack trace for.
116
- return { cause: t(key("network")), hint: t(key("networkHint")) };
181
+ return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
117
182
  }
118
183
 
119
184
  const waitSecs = retryAfterSecs(error);
@@ -122,10 +187,9 @@ export function createErrorDescriber<Code extends string>({
122
187
  says: serverSentence(error),
123
188
  wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
124
189
  waitSecs,
125
- t,
126
190
  };
127
191
 
128
- const arm = error.code === undefined ? undefined : codes[error.code as Code];
192
+ const arm = error.code === undefined ? undefined : codes[error.code];
129
193
  if (arm) return arm(ctx);
130
194
 
131
195
  // Anything unmapped: the server's own sentence is still the most specific thing we have,
@@ -133,8 +197,8 @@ export function createErrorDescriber<Code extends string>({
133
197
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
134
198
  // the reader another attempt.
135
199
  return {
136
- cause: ctx.says ?? (error.message || t(key("unexpected"))),
137
- hint: error.status >= 500 ? t(key("retrySoon")) : undefined,
200
+ cause: ctx.says ?? (error.message || t(`${copyPrefix}unexpected`)),
201
+ hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
138
202
  };
139
203
  };
140
204
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export {
44
44
  humanizeWait,
45
45
  retryAfterSecs,
46
46
  type DescribedError,
47
+ type ErrorArm,
47
48
  type ErrorContext,
48
49
  type ErrorDescriberOptions,
49
50
  type Translate,