@alxia/react-router 0.10.0 → 0.11.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.
- package/README.md +176 -1
- package/dist/errors/api-error.d.ts +86 -0
- package/dist/errors/api-error.d.ts.map +1 -0
- package/dist/errors/api-errors.d.ts +96 -0
- package/dist/errors/api-errors.d.ts.map +1 -0
- package/dist/errors/call-failure.d.ts +45 -0
- package/dist/errors/call-failure.d.ts.map +1 -0
- package/dist/errors/catalogue-check.d.ts +24 -0
- package/dist/errors/catalogue-check.d.ts.map +1 -0
- package/dist/errors/catalogue-read.d.ts +33 -0
- package/dist/errors/catalogue-read.d.ts.map +1 -0
- package/dist/errors/catalogue.d.ts +59 -0
- package/dist/errors/catalogue.d.ts.map +1 -0
- package/dist/errors/code-resolver.d.ts +44 -0
- package/dist/errors/code-resolver.d.ts.map +1 -0
- package/dist/errors/describe-refusal.d.ts +6 -0
- package/dist/errors/describe-refusal.d.ts.map +1 -0
- package/dist/errors/describe.d.ts +3 -0
- package/dist/errors/describe.d.ts.map +1 -0
- package/dist/errors/fetcher-result.d.ts +21 -0
- package/dist/errors/fetcher-result.d.ts.map +1 -0
- package/dist/errors/form-errors.d.ts +8 -0
- package/dist/errors/form-errors.d.ts.map +1 -0
- package/dist/errors/index.d.ts +15 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +602 -0
- package/dist/errors/index.js.map +29 -0
- package/dist/errors/json-actions.d.ts +38 -0
- package/dist/errors/json-actions.d.ts.map +1 -0
- package/dist/errors/log.d.ts +8 -0
- package/dist/errors/log.d.ts.map +1 -0
- package/dist/errors/message-values.d.ts +11 -0
- package/dist/errors/message-values.d.ts.map +1 -0
- package/dist/errors/messages.d.ts +63 -0
- package/dist/errors/messages.d.ts.map +1 -0
- package/dist/errors/page-path.d.ts +9 -0
- package/dist/errors/page-path.d.ts.map +1 -0
- package/dist/errors/provider.d.ts +20 -0
- package/dist/errors/provider.d.ts.map +1 -0
- package/dist/errors/request-id.d.ts +5 -0
- package/dist/errors/request-id.d.ts.map +1 -0
- package/dist/errors/route-error.d.ts +31 -0
- package/dist/errors/route-error.d.ts.map +1 -0
- package/dist/errors/standard-schema.d.ts +34 -0
- package/dist/errors/standard-schema.d.ts.map +1 -0
- package/dist/errors/submit-form.d.ts +16 -0
- package/dist/errors/submit-form.d.ts.map +1 -0
- package/dist/errors/use-json-fetcher.d.ts +18 -0
- package/dist/errors/use-json-fetcher.d.ts.map +1 -0
- package/docs/README.md +2 -1
- package/docs/guide/errors.md +838 -0
- package/docs/roadmap.md +19 -0
- package/docs/troubleshooting.md +481 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ client's files and the app's middlewares. So does React Router's prerendering.
|
|
|
95
95
|
optional peer, for `/vite`; `react` 19 is an optional peer, for `/pwa/react`. The six `workbox-*`
|
|
96
96
|
packages (`workbox-core`, `workbox-expiration`, `workbox-navigation-preload`,
|
|
97
97
|
`workbox-precaching`, `workbox-routing`, `workbox-strategies`) are optional
|
|
98
|
-
peers too, for `/pwa/worker` (the [PWA section](#installable-and-offline-pwa) has the install line). The package declares no dependency.
|
|
98
|
+
peers too, for `/pwa/worker` (the [PWA section](#installable-and-offline-pwa) has the install line). `/errors` imports `react-router`, a required peer that is already installed. The package declares no dependency.
|
|
99
99
|
|
|
100
100
|
Its `typescript` peer is 6 or 7. The template's TypeScript 5.9 typechecks,
|
|
101
101
|
but `bun add` warns about it; `bun add -d typescript@^6` silences the
|
|
@@ -621,6 +621,155 @@ The [PWA guide](https://github.com/softistx/alxia/blob/develop/packages/react-ro
|
|
|
621
621
|
has the worker's options, the offline page, the manifest route, the update
|
|
622
622
|
banner and the rest.
|
|
623
623
|
|
|
624
|
+
## An API's refusals, worded for the page
|
|
625
|
+
|
|
626
|
+
`@alxia/react-router/errors` has no UI; its one runtime import is `react-router`, never `@alxia/core`. It
|
|
627
|
+
holds the shape of a GraphQL API's refusal, declared structurally (no `@nxgt/graphql-client` at
|
|
628
|
+
runtime), the request id an API echoes, the page a request is for, the
|
|
629
|
+
catalogue of the API's messages an app keeps, the words of a form's checks,
|
|
630
|
+
the provider that fills the catalogue in the browser, the wording of a refusal, the loaders and actions that answer with it, the JSON actions (CSRF check) with their fetcher hook, and what a failed call to the API becomes (`apiCallFailure`):
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
import { isApiErrorLike, nextOf, requestIdOf } from '@alxia/react-router/errors';
|
|
634
|
+
|
|
635
|
+
export function describe(error: unknown, request: Request) {
|
|
636
|
+
return isApiErrorLike(error)
|
|
637
|
+
? { code: error.code, status: error.status, next: nextOf(request.url) }
|
|
638
|
+
: undefined;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export const idOf = (response: Response) => requestIdOf(response);
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
The catalogue is an instance made with the app's ICU formatter. A form's checks
|
|
645
|
+
read it through `apiCodeResolver`, and the provider fills it from the root's data:
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
import {
|
|
649
|
+
ApiMessagesProvider,
|
|
650
|
+
apiCodeResolver,
|
|
651
|
+
createApiMessages,
|
|
652
|
+
} from '@alxia/react-router/errors';
|
|
653
|
+
|
|
654
|
+
type Language = 'en' | 'fr';
|
|
655
|
+
|
|
656
|
+
export const apiMessages = createApiMessages<Language>({
|
|
657
|
+
format: (message) => message, // the app's ICU formatter goes here
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
export const resolver = apiCodeResolver(apiMessages, {
|
|
661
|
+
translate: (key) => key,
|
|
662
|
+
language: () => 'en',
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// In the root: <ApiMessagesProvider messages={apiMessages} value={data.catalogue}>…
|
|
666
|
+
export const provider = ApiMessagesProvider({
|
|
667
|
+
messages: apiMessages,
|
|
668
|
+
value: { en: {}, fr: {} },
|
|
669
|
+
children: null,
|
|
670
|
+
});
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
A failed call is worded in the page's language by `createApiErrors`:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
import { createApiErrors } from '@alxia/react-router/errors';
|
|
677
|
+
import { apiMessages } from './api-messages';
|
|
678
|
+
|
|
679
|
+
export const apiErrors = createApiErrors<'en' | 'fr'>({
|
|
680
|
+
messages: apiMessages,
|
|
681
|
+
unavailable: (language) => (language === 'en' ? 'Unavailable' : 'Indisponible'),
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// In an action's catch: { message, status }, or undefined (rethrow it).
|
|
685
|
+
export const refusal = (error: unknown) => apiErrors.apiRefusal(error, 'en');
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
A loader throws the API's error as a route error, with the call's request id
|
|
689
|
+
for the error page; an action answers a refusal as data:
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
import { errorRequestId, routeError } from '@alxia/react-router/errors';
|
|
693
|
+
import { apiErrors } from './api-errors';
|
|
694
|
+
|
|
695
|
+
// In a loader's catch: a 401, 403, 404 or 503 ApiError becomes a route error.
|
|
696
|
+
export const thrown = (error: unknown) => routeError(error);
|
|
697
|
+
|
|
698
|
+
// In the root's ErrorBoundary: the reference the user can quote.
|
|
699
|
+
export const reference = (error: unknown) => errorRequestId(error);
|
|
700
|
+
|
|
701
|
+
// A fetcher's action: { ok: true, …value }, or { ok: false, error } with a status.
|
|
702
|
+
export const rename = (run: () => Promise<{ title: string }>) =>
|
|
703
|
+
apiErrors.fetcherResult('en', { intent: 'rename' }, run);
|
|
704
|
+
|
|
705
|
+
// A form's action: { ok: true, value }, or the form's errors with a status.
|
|
706
|
+
export const create = (run: () => Promise<{ id: string }>) =>
|
|
707
|
+
apiErrors.submitForm('en', run, (path) =>
|
|
708
|
+
path === 'todo.title' ? 'title' : undefined,
|
|
709
|
+
);
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
JSON actions refuse a post from another site, and read a Standard Schema's input:
|
|
713
|
+
|
|
714
|
+
```ts
|
|
715
|
+
import { jsonActions, useJsonFetcher } from '@alxia/react-router/errors';
|
|
716
|
+
import { z } from 'zod';
|
|
717
|
+
|
|
718
|
+
// Behind a TLS proxy named with createServer({ proxy }), nothing to set; else: origin: () => process.env.APP_ORIGIN
|
|
719
|
+
export const { readInput } = jsonActions();
|
|
720
|
+
|
|
721
|
+
const schema = z.object({ title: z.string().min(1) });
|
|
722
|
+
|
|
723
|
+
export async function action({ request }: { request: Request }) {
|
|
724
|
+
return readInput(request, schema); // 400 when the input is refused
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// In the component: const { post, busy } = useJsonFetcher<typeof action>();
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
A call that failed is turned into what a loader throws, and logged with the API's request id, by `apiCallFailure(error, { api, operation: operationName(document), requestId })`
|
|
731
|
+
(your own `execute` calls it in its `catch`):
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
import { apiCallFailure, operationName } from '@alxia/react-router/errors';
|
|
735
|
+
import type { DocumentNode } from 'graphql';
|
|
736
|
+
|
|
737
|
+
export function failed(error: unknown, document: DocumentNode, requestId?: string, signal?: AbortSignal) {
|
|
738
|
+
return apiCallFailure(error, { api: 'my-api', operation: operationName(document), requestId, signal });
|
|
739
|
+
}
|
|
740
|
+
// In execute(): catch (error) { throw failed(error, document, requestId, signal); }
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
The API's catalogue is read and kept fresh by `createErrorCatalogue`; the root's
|
|
744
|
+
middleware awaits `freshAll()`, its loader returns `clientMessages()` and
|
|
745
|
+
`server.ts` calls `warm()`:
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
import { createErrorCatalogue } from '@alxia/react-router/errors';
|
|
749
|
+
import { apiMessages } from './api-messages';
|
|
750
|
+
|
|
751
|
+
export const catalogue = createErrorCatalogue<'en' | 'fr'>({
|
|
752
|
+
messages: apiMessages,
|
|
753
|
+
languages: ['en', 'fr'],
|
|
754
|
+
read: async (language) => fetchCatalogue(language), // { version, messages }
|
|
755
|
+
versions: async () => fetchVersions(), // { en: '…', fr: '…' }
|
|
756
|
+
ttlMs: () => Number(process.env.CATALOGUE_TTL_MS ?? 60_000),
|
|
757
|
+
clientCodes: ['validation.errors.required'],
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
declare function fetchCatalogue(
|
|
761
|
+
language: string,
|
|
762
|
+
): Promise<{ version: string; messages: { code: string; message: string; params: { name: string; kind: 'TEXT' }[] }[] }>;
|
|
763
|
+
declare function fetchVersions(): Promise<{ en: string; fr: string }>;
|
|
764
|
+
|
|
765
|
+
// The root's middleware: await catalogue.freshAll();
|
|
766
|
+
// The root's loader: return { messages: catalogue.clientMessages() };
|
|
767
|
+
// server.ts, at startup: catalogue.warm();
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
The [errors guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/errors.md)
|
|
771
|
+
has the rest.
|
|
772
|
+
|
|
624
773
|
## Traps
|
|
625
774
|
|
|
626
775
|
- **A context key made in `app/` reaches the loaders only through the
|
|
@@ -736,6 +885,31 @@ From `@alxia/react-router/pwa/build` (Bun; Node's built-ins its only imports):
|
|
|
736
885
|
| `BuildWorkerOptions` | `file` (required), `root`, `client`, `contract`, `offline`, `precacheAssets`, `minify` |
|
|
737
886
|
| `WorkerBuild` | `{ file, precache, precacheBytes, bytes }` |
|
|
738
887
|
|
|
888
|
+
From `@alxia/react-router/errors` (`react-router` is its only package import, and never `@alxia/core`):
|
|
889
|
+
|
|
890
|
+
| export | |
|
|
891
|
+
| --- | --- |
|
|
892
|
+
| `isApiErrorLike(error)` | whether `error` is an `@nxgt/graphql-client` `ApiError`, like the client's `isApiError`, plus a numeric `status`, which `ApiErrorLike` promises; it holds across two copies of the client |
|
|
893
|
+
| `ApiErrorLike`, `ApiErrorEntryLike`, `ApiErrorExtensionsLike`, `ApiErrorParamsLike`, `ApiFieldErrorLike` | the shape of the refusal: `errors`, `code`, `status`, `requestId?`, and each error's `extensions` (`code`, `params`, `fields`, `field`, `minutes`, `retryAfter`) |
|
|
894
|
+
| `waitMinutes(extensions)` | the minutes of a throttle's wait: `minutes` (at least one), else `retryAfter` rounded up |
|
|
895
|
+
| `REQUEST_ID_HEADER`, `requestIdOf(response)` | `x-request-id`, and the id a response carries, if the API sent one |
|
|
896
|
+
| `nextOf(url)` | the path and search of the page a request is for, without React Router's `.data`, `/_.data`, `_routes` and empty `index`; it never starts with two slashes, but an app still validates `next` before redirecting to it |
|
|
897
|
+
| `FormErrors` | what a form action answers when the API refused the input: `fieldErrors` by field and a `formError` (`undefined` when every message sits under a field), already translated; `submitForm` answers it |
|
|
898
|
+
| `routeError(error, { statuses? }?)`, `RouteErrorOptions` | what a loader throws for the API's error: an `ApiError` of 401, 403, 404 or 503, or such a `Response` carrying a request id, becomes a route error of that status with the `code`, `message` and `requestId`; any other `ApiError` with an id becomes a 500 carrying the id; anything else is returned as it is |
|
|
899
|
+
| `errorRequestId(error)`, `RouteErrorData` | the support reference an error page shows: the `requestId` a route error carries, else `undefined`; `RouteErrorData` is the data `routeError` carries (`code?`, `message?`, `requestId?`) |
|
|
900
|
+
| `createApiMessages({ format })`, `ApiMessages`, `ApiMessagesOptions` | the catalogue store of an app, an instance (`set`, `add`, `clear` (for tests), `messageOf`, `format`, `apiMessage(code, values, language)`); `format` is the app's ICU formatter, such as `@nxgt/material`'s `formatMessage` |
|
|
901
|
+
| `CatalogueMessage`, `CatalogueMessages`, `ParamKind`, `MessageFormat`, `MessageValues` | one API message with the kind of each argument (`TEXT`, `NUMBER`, `DATE`, `PLURAL`, `SELECT`), a language's messages by code, and the formatter's type |
|
|
902
|
+
| `apiCodeResolver(messages, { translate, language, codeOf?, isApiCode? })`, `ApiCodeResolver`, `ApiCodeResolverOptions`, `CodeIssueLike` | the `{ translate, codeOf }` a form's `useCodeResolver(schema, resolver)` takes: a check's API code is worded as the API words it, any other key by the app's translator; `CodeIssueLike` is the issue a custom `codeOf` receives |
|
|
903
|
+
| `ApiMessagesProvider({ messages, value, children })`, `ApiMessagesProviderProps` | `messages` is the store and `value` the root loader's data; fills the store in the browser from the root's data before the children render; a server render writes nothing. A function with no JSX, so no React import |
|
|
904
|
+
| `createApiErrors({ messages, unavailable, refused?, byCode?, isApiCode? })`, `ApiErrors`, `ApiErrorsOptions` | what a page says of a refusal, in a language: `apiErrorMessage(error, language)` (the catalogue's text of the code, else the API's message; `refused` replaces it for an error with no API code and `byCode` words given codes, such as a query's limits), `fieldErrors(error, language)` by input path, `translateCode(code, fallback, language, values?)`, `unavailableMessage(language)` and `apiRefusal(error, language)`; for an action, `fetcherResult(language, about, run, route?)` (`{ ok: true, …about, …value }`, or `{ ok: false, …about, error }` with the refusal's status), `fetcherRefusal(error, language, route?)` (the refusal, or the thrown route error) and `submitForm(language, run, fieldOf, route?)` (`{ ok: true, value }`, or a `FormErrors` with the refusal's status); `route` is the `RouteErrorOptions` of the `routeError` they throw through |
|
|
905
|
+
| `apiCallFailure(error, { api, operation?, requestId?, signal?, passedThrough?, log? })`, `CallFailureOptions` | what a failed call to the API becomes, for a loader or an action to throw, logged with the request id: an `ApiError` as it is (with `requestId`), a `Response` of its own status for a 401, 403 or 404 without a GraphQL body, a 503 `Response` with no body for an unreachable, slow, garbled or aborted call (the reason is in the log), any other error as it is; the log lines start with `api`, and a `log` that throws never replaces the outcome. It recognises `@nxgt/graphql-client`'s errors by `name`, not `instanceof` |
|
|
906
|
+
| `operationName(document)`, `DocumentLike` | the name of the operation a document holds, for the log (else `query`, `mutation` or `subscription`); a structural `{ definitions }`, so `graphql`'s `DocumentNode` fits |
|
|
907
|
+
| `createErrorCatalogue({ messages, languages, read, versions, ttlMs, clientCodes, retryMs?, firstReadWaitMs?, log? })`, `ErrorCatalogue`, `ErrorCatalogueOptions`, `CatalogueReply` | keeps the API's error catalogue in the store: `fresh(language, now?)` and `freshAll(now?)` (a cold language is awaited for `firstReadWaitMs`, 1500 by default, and read once however many requests ask; a held one is served, and its version checked in the background once per `ttlMs`, a number or a getter; only a language whose version moved is read again; a failure is retried after `retryMs`, 5000 by default), `warm()` for startup, `clientMessages()` (the `clientCodes` the browser words itself, by language, for the root's loader), and, for tests, `reset()`, `heldVersion(language)`, `settled()` and the `now` parameters. `read` and `versions` are the app's calls to its API |
|
|
908
|
+
| `ApiRefusal` | `{ message, status }`: a failed call as a page says it; what `apiRefusal` returns for an API refusal (its status) or an unreachable API (503), else `undefined` |
|
|
909
|
+
| `jsonActions({ origin? })`, `JsonActions`, `JsonActionsOptions` | the JSON helpers of the actions, bound to the app's origin (a string, or a getter read at each request; the request's own by default): `readJson(request)` (403 `Cross-site request refused` for another origin or a cross-site fetch, 415 `Expected a JSON body`, 400 `Malformed JSON body`), `requireJsonPost(request)` (a native post goes back to its page) and `readInput(request, schema)` (the body's `input` through a Standard Schema, async allowed; 400 `The form sent an input the API would refuse`) |
|
|
910
|
+
| `useJsonFetcher(key?, action?)`, `JsonBody` | a fetcher that posts JSON: `{ fetcher, post(body, to?), busy }`; `to` is the action of one call |
|
|
911
|
+
| `StandardSchemaV1` | the Standard Schema v1 type `readInput` takes, declared in `/errors` (type only, so no `@alxia/core`; `readInput`'s output is inferred from the schema's `~standard.types`); a Zod 4, Valibot or ArkType schema fits |
|
|
912
|
+
|
|
739
913
|
The `alxia-react-router` bin, run with `bunx`:
|
|
740
914
|
|
|
741
915
|
| command | |
|
|
@@ -751,6 +925,7 @@ The `alxia-react-router` bin, run with `bunx`:
|
|
|
751
925
|
- [Typing the loaders](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/typing-the-loaders.md): `Register`, and the app's own context keys.
|
|
752
926
|
- [A CSP nonce](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/csp-nonce.md), [Beside the pages](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/beside-the-pages.md) (middlewares, routes, WebSockets, the client's files), [OpenAPI](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/openapi.md), [Testing](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/testing.md) and [Deploying](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/deploying.md), with Docker.
|
|
753
927
|
- [Installable and offline](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/pwa.md): what the subpaths hold, the worker's contract, the worker, what it caches, the offline page, building the worker with `alxia()` or `buildWorker`, the hooks in React, registering the worker and offering an update, serving the worker's script, the web app manifest, and clearing at sign-out.
|
|
928
|
+
- [An API's refusals](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/errors.md): what `/errors` holds, recognising an API error, the request id, the page a request is for, the catalogue store, the form checks in the API's words, the provider, wording a refusal, loaders and the error page, actions, JSON actions and CSRF, and keeping the catalogue fresh.
|
|
754
929
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md): each message, and the traps that print none.
|
|
755
930
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/roadmap.md): what is coming, and what is not planned.
|
|
756
931
|
- [Example](https://github.com/softistx/alxia/tree/develop/examples/react-router): the official template, these four changes, then an `app/server.ts` with a session, an `/api`, secure headers with a nonce and a streamed page.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of an API's refusal, as `@nxgt/graphql-client`'s `ApiError`
|
|
3
|
+
* carries it, declared structurally: `/errors` never imports the client, so
|
|
4
|
+
* an app needs it only if it calls the API through it. `api-error.spec.ts`
|
|
5
|
+
* holds the two in step.
|
|
6
|
+
*/
|
|
7
|
+
/** One refused input field: `extensions.fields` of a validation refusal. */
|
|
8
|
+
export interface ApiFieldErrorLike {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly code: string;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
/** The values the field's message interpolates. */
|
|
13
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
14
|
+
}
|
|
15
|
+
/** The values a message interpolates; a throttle's wait is `minutes` or `retryAfter`. */
|
|
16
|
+
export interface ApiErrorParamsLike {
|
|
17
|
+
readonly minutes?: unknown;
|
|
18
|
+
readonly retryAfter?: unknown;
|
|
19
|
+
readonly [key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One GraphQL error's `extensions`. `code` is the API's own
|
|
23
|
+
* `<module>.errors.<slug>`; the rest is what its messages name.
|
|
24
|
+
*/
|
|
25
|
+
export interface ApiErrorExtensionsLike {
|
|
26
|
+
readonly code?: string;
|
|
27
|
+
/** The values the message interpolates, when the API sends them. */
|
|
28
|
+
readonly params?: ApiErrorParamsLike;
|
|
29
|
+
/** A validation refusal: every field at fault, by input path. */
|
|
30
|
+
readonly fields?: readonly ApiFieldErrorLike[];
|
|
31
|
+
/** Any other refusal about one input field, by its path. */
|
|
32
|
+
readonly field?: string;
|
|
33
|
+
/** A throttle's wait, in minutes (`waitMinutes` coerces a string). */
|
|
34
|
+
readonly minutes?: unknown;
|
|
35
|
+
/** A throttle's wait, in seconds. */
|
|
36
|
+
readonly retryAfter?: unknown;
|
|
37
|
+
readonly [key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
/** One error of a response's `errors`. */
|
|
40
|
+
export interface ApiErrorEntryLike {
|
|
41
|
+
readonly message: string;
|
|
42
|
+
readonly extensions?: ApiErrorExtensionsLike;
|
|
43
|
+
}
|
|
44
|
+
/** A refusal read from a GraphQL API: what `ApiError` has, and the request id. */
|
|
45
|
+
export interface ApiErrorLike extends Error {
|
|
46
|
+
readonly errors: readonly ApiErrorEntryLike[];
|
|
47
|
+
/** The first error's `extensions.code`. */
|
|
48
|
+
readonly code: string | undefined;
|
|
49
|
+
/** The HTTP status to answer with. */
|
|
50
|
+
readonly status: number;
|
|
51
|
+
/** The id the API logged the request under, when the caller kept it. */
|
|
52
|
+
readonly requestId?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Whether `error` is an `Error` of this `name`: the client's errors are told by it, never `instanceof`. */
|
|
55
|
+
export declare const isNamed: (error: unknown, name: string) => error is Error;
|
|
56
|
+
/**
|
|
57
|
+
* Whether `error` is an `ApiError` of `@nxgt/graphql-client`, by the test
|
|
58
|
+
* that client's `isApiError` makes (an `Error` named `ApiError` with an
|
|
59
|
+
* `errors` array), plus a numeric `status`, which `ApiErrorLike` promises.
|
|
60
|
+
* It holds across two copies of the client.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isApiErrorLike(error: unknown): error is ApiErrorLike;
|
|
63
|
+
/**
|
|
64
|
+
* The minutes a wait lasts, as the API words it: `minutes` (at least one, in
|
|
65
|
+
* `extensions.params` or beside it), else the `retryAfter` seconds rounded
|
|
66
|
+
* up, else one.
|
|
67
|
+
*/
|
|
68
|
+
export declare function waitMinutes(extensions: ApiErrorExtensionsLike | undefined): number;
|
|
69
|
+
/**
|
|
70
|
+
* The values a message names: `extensions.params` over the extensions
|
|
71
|
+
* themselves, where older errors and the extras of a few sit.
|
|
72
|
+
*/
|
|
73
|
+
export declare function valuesOf(extensions: ApiErrorExtensionsLike | undefined): Record<string, unknown> | undefined;
|
|
74
|
+
/** `@nxgt/graphql-client`'s `ApiStatusError`, as far as the failure reads it. */
|
|
75
|
+
export interface StatusErrorLike extends Error {
|
|
76
|
+
status: number;
|
|
77
|
+
statusText?: string | undefined;
|
|
78
|
+
body?: unknown;
|
|
79
|
+
}
|
|
80
|
+
/** `@nxgt/graphql-client`'s `ApiUnavailableError`, as far as the failure reads it. */
|
|
81
|
+
export interface UnavailableErrorLike extends Error {
|
|
82
|
+
reason: string;
|
|
83
|
+
}
|
|
84
|
+
export declare const isStatusError: (error: unknown) => error is StatusErrorLike;
|
|
85
|
+
export declare const isUnavailableError: (error: unknown) => error is UnavailableErrorLike;
|
|
86
|
+
//# sourceMappingURL=api-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-error.d.ts","sourceRoot":"","sources":["../../src/errors/api-error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACpD;AAED,yFAAyF;AACzF,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAChC;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,kBAAkB,CAAC;IACrC,iEAAiE;IACjE,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC/C,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,qCAAqC;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAChC;AAED,0CAA0C;AAC1C,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,sBAAsB,CAAC;CAC7C;AAED,kFAAkF;AAClF,MAAM,WAAW,YAAa,SAAQ,KAAK;IAC1C,QAAQ,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC9C,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,sCAAsC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,4GAA4G;AAC5G,eAAO,MAAM,OAAO,GAAI,OAAO,OAAO,EAAE,MAAM,MAAM,KAAG,KAAK,IAAI,KAClB,CAAC;AAE/C;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAC1B,UAAU,EAAE,sBAAsB,GAAG,SAAS,GAC5C,MAAM,CAKR;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CACvB,UAAU,EAAE,sBAAsB,GAAG,SAAS,GAC5C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAKrC;AAED,iFAAiF;AACjF,MAAM,WAAW,eAAgB,SAAQ,KAAK;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAED,sFAAsF;AACtF,MAAM,WAAW,oBAAqB,SAAQ,KAAK;IAClD,MAAM,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,eAEG,CAAC;AAE5D,eAAO,MAAM,kBAAkB,GAC9B,OAAO,OAAO,KACZ,KAAK,IAAI,oBAE+C,CAAC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { data } from 'react-router';
|
|
2
|
+
import type { ApiErrorLike } from './api-error';
|
|
3
|
+
import type { FormErrors } from './form-errors';
|
|
4
|
+
import type { ApiMessages } from './messages';
|
|
5
|
+
import type { RouteErrorOptions } from './route-error';
|
|
6
|
+
/** A refused call, as a page says it: the message, and the HTTP status to answer. */
|
|
7
|
+
export type ApiRefusal = {
|
|
8
|
+
message: string;
|
|
9
|
+
status: number;
|
|
10
|
+
};
|
|
11
|
+
export interface ApiErrorsOptions<L extends string> {
|
|
12
|
+
/** The catalogue the API's codes are worded from. */
|
|
13
|
+
messages: ApiMessages<L>;
|
|
14
|
+
/**
|
|
15
|
+
* The app's own word for an API it cannot reach (a 503 `Response`): not
|
|
16
|
+
* an API error, so never one of the catalogue's codes.
|
|
17
|
+
*/
|
|
18
|
+
unavailable(language: L): string;
|
|
19
|
+
/**
|
|
20
|
+
* The app's generic refusal, said for an error with no API code (a raw
|
|
21
|
+
* `BAD_USER_INPUT` before the API's validation layer), never the raw
|
|
22
|
+
* message. Without it, such an error says the message the API sent.
|
|
23
|
+
*/
|
|
24
|
+
refused?(language: L): string;
|
|
25
|
+
/**
|
|
26
|
+
* An app's own wording for given codes, such as the API's request limits
|
|
27
|
+
* (depth, cost, introspection), whose params differ and name no field.
|
|
28
|
+
* Checked after `refused`, before the catalogue.
|
|
29
|
+
*/
|
|
30
|
+
byCode?: Readonly<Record<string, (error: ApiErrorLike, language: L) => string>>;
|
|
31
|
+
/** Whether a code is an API code; `<module>.errors.<slug>` by default. */
|
|
32
|
+
isApiCode?(code: string): boolean;
|
|
33
|
+
}
|
|
34
|
+
/** What a page says of an API's refusals, in a language. */
|
|
35
|
+
export interface ApiErrors<L extends string> {
|
|
36
|
+
/**
|
|
37
|
+
* The catalogue's text of an API code in `language`, else `fallback` (the
|
|
38
|
+
* message the API already worded). `values` are the message's arguments
|
|
39
|
+
* (`extensions.params`).
|
|
40
|
+
*/
|
|
41
|
+
translateCode(code: string | undefined, fallback: string, language: L, values?: Readonly<Record<string, unknown>>): string;
|
|
42
|
+
/**
|
|
43
|
+
* What to show for an API error: `refused` for an error with no API code,
|
|
44
|
+
* when given; else `byCode`'s wording of its code; else the catalogue's
|
|
45
|
+
* text; else the message the API already worded.
|
|
46
|
+
*/
|
|
47
|
+
apiErrorMessage(error: ApiErrorLike, language: L): string;
|
|
48
|
+
/**
|
|
49
|
+
* The message of each refused field, by input path, ready for a form: the
|
|
50
|
+
* entries of `extensions.fields`, else the one field `extensions.field`
|
|
51
|
+
* names, with the error's own message. Empty for neither.
|
|
52
|
+
*/
|
|
53
|
+
fieldErrors(error: ApiErrorLike, language: L): Record<string, string>;
|
|
54
|
+
/** `unavailable`, in `language`. */
|
|
55
|
+
unavailableMessage(language: L): string;
|
|
56
|
+
/**
|
|
57
|
+
* What a failed call says to the user: an API refusal's message and its
|
|
58
|
+
* status, or an API it cannot reach (a 503 `Response`: the unavailable
|
|
59
|
+
* word). Undefined for anything else, which the caller rethrows.
|
|
60
|
+
*/
|
|
61
|
+
apiRefusal(error: unknown, language: L): ApiRefusal | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* A fetcher's refusal: `apiRefusal`'s, so the page stays where it was. A
|
|
64
|
+
* 401 (the session caller redirects first) or any other failure is
|
|
65
|
+
* thrown, through `routeError`, for the error page.
|
|
66
|
+
*/
|
|
67
|
+
fetcherRefusal(error: unknown, language: L, route?: RouteErrorOptions): ApiRefusal;
|
|
68
|
+
/**
|
|
69
|
+
* Runs the mutation of an action a fetcher posted to: `{ ok: true }` with
|
|
70
|
+
* `about` and the mutation's value, or `{ ok: false, error }` with `about`
|
|
71
|
+
* and the refusal's message, answered with its HTTP status.
|
|
72
|
+
*/
|
|
73
|
+
fetcherResult<About extends object, Value extends object = Record<never, never>>(language: L, about: About, run: () => Promise<Value>, route?: RouteErrorOptions): Promise<({
|
|
74
|
+
ok: true;
|
|
75
|
+
} & About & Value) | ReturnType<typeof data<{
|
|
76
|
+
ok: false;
|
|
77
|
+
error: string;
|
|
78
|
+
} & About>>>;
|
|
79
|
+
/**
|
|
80
|
+
* Runs a form's mutation: `{ ok: true, value }`, or the form's errors
|
|
81
|
+
* (`fieldErrors` under the form field `fieldOf` names an input path by,
|
|
82
|
+
* and a `formError` for the rest) with the refusal's status. A 404 about
|
|
83
|
+
* none of the form's fields, a 401 and other failures are thrown for the
|
|
84
|
+
* error page, through `routeError` (with `route`).
|
|
85
|
+
*/
|
|
86
|
+
submitForm<T>(language: L, run: () => Promise<T>, fieldOf: (path: string) => string | undefined, route?: RouteErrorOptions): Promise<{
|
|
87
|
+
ok: true;
|
|
88
|
+
value: T;
|
|
89
|
+
} | ReturnType<typeof data<FormErrors>>>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The wording of an API's refusals for the languages `L`, from the
|
|
93
|
+
* catalogue `messages` holds. Make one per app, beside its `createApiMessages`.
|
|
94
|
+
*/
|
|
95
|
+
export declare function createApiErrors<L extends string>(options: ApiErrorsOptions<L>): ApiErrors<L>;
|
|
96
|
+
//# sourceMappingURL=api-errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-errors.d.ts","sourceRoot":"","sources":["../../src/errors/api-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEvD,qFAAqF;AACrF,MAAM,MAAM,UAAU,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7D,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM;IACjD,qDAAqD;IACrD,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IACzB;;;OAGG;IACH,WAAW,CAAC,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC;IACjC;;;;OAIG;IACH,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC;IAC9B;;;;OAIG;IACH,MAAM,CAAC,EAAE,QAAQ,CAChB,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,CAAC,KAAK,MAAM,CAAC,CAC5D,CAAC;IACF,0EAA0E;IAC1E,SAAS,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CAClC;AAED,4DAA4D;AAC5D,MAAM,WAAW,SAAS,CAAC,CAAC,SAAS,MAAM;IAC1C;;;;OAIG;IACH,aAAa,CACZ,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,CAAC,EACX,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACxC,MAAM,CAAC;IACV;;;;OAIG;IACH,eAAe,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC;IAC1D;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtE,oCAAoC;IACpC,kBAAkB,CAAC,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC;IACxC;;;;OAIG;IACH,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,UAAU,GAAG,SAAS,CAAC;IAChE;;;;OAIG;IACH,cAAc,CACb,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,CAAC,EACX,KAAK,CAAC,EAAE,iBAAiB,GACvB,UAAU,CAAC;IACd;;;;OAIG;IACH,aAAa,CACZ,KAAK,SAAS,MAAM,EACpB,KAAK,SAAS,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAE3C,QAAQ,EAAE,CAAC,EACX,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,EACzB,KAAK,CAAC,EAAE,iBAAiB,GACvB,OAAO,CACP,CAAC;QAAE,EAAE,EAAE,IAAI,CAAA;KAAE,GAAG,KAAK,GAAG,KAAK,CAAC,GAC9B,UAAU,CAAC,OAAO,IAAI,CAAC;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAC,CAAC,CAC/D,CAAC;IACF;;;;;;OAMG;IACH,UAAU,CAAC,CAAC,EACX,QAAQ,EAAE,CAAC,EACX,GAAG,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACrB,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC7C,KAAK,CAAC,EAAE,iBAAiB,GACvB,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAC;QAAC,KAAK,EAAE,CAAC,CAAA;KAAE,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;CACzE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAC/C,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAC1B,SAAS,CAAC,CAAC,CAAC,CAEd"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface CallFailureOptions {
|
|
2
|
+
/** What a log line names the API by, such as `my-api https://api.example.com/graphql`. */
|
|
3
|
+
api: string;
|
|
4
|
+
/** The operation's name, for the log (`operationName(document)`). */
|
|
5
|
+
operation?: string;
|
|
6
|
+
/** The `x-request-id` the API answered, when it did (`requestIdOf(response)`). */
|
|
7
|
+
requestId?: string;
|
|
8
|
+
/** The incoming request's signal: an aborted call is answered 503. */
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
/** The statuses without a GraphQL body that become a `Response` of their own; 401, 403 and 404 by default. */
|
|
11
|
+
passedThrough?: ReadonlySet<number>;
|
|
12
|
+
/** Where the lines go; `console` by default. */
|
|
13
|
+
log?: Pick<Console, 'warn' | 'error'>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What a call became once it failed, for a loader or an action to throw: the
|
|
17
|
+
* `ApiError` as it is (carrying `requestId`), a `Response` of the API's own
|
|
18
|
+
* status for a 401, 403 or 404 without a GraphQL body, a 503 `Response` (with no body; the reason is in the log) for an
|
|
19
|
+
* unreachable, slow, garbled or aborted API, and any other error as it is.
|
|
20
|
+
* Every case it recognises is logged with the request id, when the API gave one, and never
|
|
21
|
+
* the headers, the variables nor a token; the id rides on the `ApiError` as
|
|
22
|
+
* `requestId` and on a `Response` as `x-request-id`, where `routeError` reads
|
|
23
|
+
* it.
|
|
24
|
+
*
|
|
25
|
+
* It recognises `@nxgt/graphql-client`'s errors by their `name`, never with
|
|
26
|
+
* `instanceof`, so it needs no import of the client and holds across two
|
|
27
|
+
* copies of it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function apiCallFailure(error: unknown, { api, operation, requestId, signal, passedThrough, log: appLog, }: CallFailureOptions): unknown;
|
|
30
|
+
/** A document, as far as `operationName` reads it: `graphql`'s `DocumentNode` fits. */
|
|
31
|
+
export interface DocumentLike {
|
|
32
|
+
readonly definitions: readonly {
|
|
33
|
+
readonly kind: string;
|
|
34
|
+
readonly operation?: string | undefined;
|
|
35
|
+
readonly name?: {
|
|
36
|
+
readonly value: string;
|
|
37
|
+
} | undefined;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The name of the operation a document holds, for a log line: its name, else
|
|
42
|
+
* its kind (`query`, `mutation`, `subscription`), else `operation`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function operationName(document: DocumentLike): string;
|
|
45
|
+
//# sourceMappingURL=call-failure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"call-failure.d.ts","sourceRoot":"","sources":["../../src/errors/call-failure.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,kBAAkB;IAClC,0FAA0F;IAC1F,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sEAAsE;IACtE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8GAA8G;IAC9G,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACpC,gDAAgD;IAChD,GAAG,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC;CACtC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC7B,KAAK,EAAE,OAAO,EACd,EACC,GAAG,EACH,SAAuB,EACvB,SAAS,EACT,MAAM,EACN,aAA8B,EAC9B,GAAG,EAAE,MAAgB,GACrB,EAAE,kBAAkB,GACnB,OAAO,CAyCT;AAED,uFAAuF;AACvF,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,WAAW,EAAE,SAAS;QAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QACxC,QAAQ,CAAC,IAAI,CAAC,EAAE;YAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAC;KACvD,EAAE,CAAC;CACJ;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,CAO5D"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Held } from './catalogue-read';
|
|
2
|
+
/** What the TTL check needs: the reader's state and the app's own calls. */
|
|
3
|
+
export interface CheckContext<L extends string> {
|
|
4
|
+
reader: {
|
|
5
|
+
held: Map<L, Held>;
|
|
6
|
+
load(language: L, now: number): Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
versions(): Promise<Partial<Record<L, string>>>;
|
|
9
|
+
ttl(): number;
|
|
10
|
+
retryMs: number;
|
|
11
|
+
log: Pick<Console, 'error'>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Asks the versions in one call and reads again the messages of each
|
|
15
|
+
* language that moved. An API that cannot be reached keeps the messages
|
|
16
|
+
* held (stale, still worded) and is asked again after `retryMs`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkVersions<L extends string>({ reader, versions, ttl, retryMs, log }: CheckContext<L>, now: number): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* The TTL, whose getter is the app's: one that throws, or that is not a
|
|
21
|
+
* finite number of at least 0, is logged and `fallback` is used.
|
|
22
|
+
*/
|
|
23
|
+
export declare function safeTtl(ttlMs: number | (() => number), fallback: number, log: Pick<Console, 'error'>): () => number;
|
|
24
|
+
//# sourceMappingURL=catalogue-check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalogue-check.d.ts","sourceRoot":"","sources":["../../src/errors/catalogue-check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAE7C,4EAA4E;AAC5E,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM;IAC7C,MAAM,EAAE;QACP,IAAI,EAAE,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;QACnB,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KAC9C,CAAC;IACF,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAChD,GAAG,IAAI,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,CAAC,SAAS,MAAM,EACnD,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,YAAY,CAAC,CAAC,CAAC,EACxD,GAAG,EAAE,MAAM,iBAoBX;AAED;;;GAGG;AACH,wBAAgB,OAAO,CACtB,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,EAC9B,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,gBAc3B"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ApiMessages, CatalogueMessage } from './messages';
|
|
2
|
+
/** What the API answers for one language: its version and every message. */
|
|
3
|
+
export type CatalogueReply = {
|
|
4
|
+
version: string;
|
|
5
|
+
messages: ReadonlyArray<CatalogueMessage & {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
}>;
|
|
8
|
+
};
|
|
9
|
+
/** A language's catalogue as held, and when its version was last confirmed. */
|
|
10
|
+
export type Held = {
|
|
11
|
+
version: string;
|
|
12
|
+
checkedAt: number;
|
|
13
|
+
};
|
|
14
|
+
export interface CatalogueReaderOptions<L extends string> {
|
|
15
|
+
messages: ApiMessages<L>;
|
|
16
|
+
read(language: L): Promise<CatalogueReply>;
|
|
17
|
+
retryMs: number;
|
|
18
|
+
firstReadWaitMs: number;
|
|
19
|
+
log: Pick<Console, 'error'>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The reading half of the catalogue: the first read of each language (one in
|
|
23
|
+
* flight, a bounded wait, a retry delay after a failure) and the version
|
|
24
|
+
* each one holds. `createErrorCatalogue` adds the TTL check on top.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createCatalogueReader<L extends string>({ messages, read, retryMs, firstReadWaitMs, log, }: CatalogueReaderOptions<L>): {
|
|
27
|
+
held: Map<L, Held>;
|
|
28
|
+
load: (language: L, now: number) => Promise<void>;
|
|
29
|
+
/** Reads a language that is not held yet, unless a failure is still fresh. */
|
|
30
|
+
first(language: L, now: number): Promise<void>;
|
|
31
|
+
reset(): void;
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=catalogue-read.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalogue-read.d.ts","sourceRoot":"","sources":["../../src/errors/catalogue-read.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEhE,4EAA4E;AAC5E,MAAM,MAAM,cAAc,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC,gBAAgB,GAAG;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACtE,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,IAAI,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,MAAM,WAAW,sBAAsB,CAAC,CAAC,SAAS,MAAM;IACvD,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IACzB,IAAI,CAAC,QAAQ,EAAE,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,GAAG,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;CAC5B;AAqBD;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,MAAM,EAAE,EACvD,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,eAAe,EACf,GAAG,GACH,EAAE,sBAAsB,CAAC,CAAC,CAAC;;qBAMG,CAAC,OAAO,MAAM;IAwC3C,8EAA8E;oBACxD,CAAC,OAAO,MAAM;;EAYrC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type CatalogueReply } from './catalogue-read';
|
|
2
|
+
import type { ApiMessages, CatalogueMessages } from './messages';
|
|
3
|
+
export type { CatalogueReply } from './catalogue-read';
|
|
4
|
+
export interface ErrorCatalogueOptions<L extends string> {
|
|
5
|
+
/** The store the page words refusals from (`createApiMessages`). */
|
|
6
|
+
messages: ApiMessages<L>;
|
|
7
|
+
/** The languages to hold, each read once and kept fresh. */
|
|
8
|
+
languages: readonly L[];
|
|
9
|
+
/** Reads one language's catalogue from the API. */
|
|
10
|
+
read(language: L): Promise<CatalogueReply>;
|
|
11
|
+
/** Asks the API for the version of every language, in one call. */
|
|
12
|
+
versions(): Promise<Partial<Record<L, string>>>;
|
|
13
|
+
/** How long a held catalogue is served before its version is checked. */
|
|
14
|
+
ttlMs: number | (() => number);
|
|
15
|
+
/** How soon a check or a first read that failed is tried again. Default 5000. */
|
|
16
|
+
retryMs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* How long a request waits for a language's first read; past it the
|
|
19
|
+
* request goes on and the read carries on in the background. Default 1500.
|
|
20
|
+
*/
|
|
21
|
+
firstReadWaitMs?: number;
|
|
22
|
+
/** The codes the browser words itself (`clientMessages`). */
|
|
23
|
+
clientCodes: readonly string[];
|
|
24
|
+
/** Where failures are reported. Default `console`. */
|
|
25
|
+
log?: Pick<Console, 'error'>;
|
|
26
|
+
}
|
|
27
|
+
/** The API's error catalogue, kept fresh for the server. */
|
|
28
|
+
export interface ErrorCatalogue<L extends string> {
|
|
29
|
+
/**
|
|
30
|
+
* The catalogue of `language`, ready to word a refusal. Only a language
|
|
31
|
+
* with no catalogue yet is awaited, for at most `firstReadWaitMs`. A held
|
|
32
|
+
* one is served as it is, and once older than the TTL its version is
|
|
33
|
+
* checked in the background, one check at a time.
|
|
34
|
+
*/
|
|
35
|
+
fresh(language: L, now?: number): Promise<void>;
|
|
36
|
+
/** Every language, fresh: what the root middleware awaits. */
|
|
37
|
+
freshAll(now?: number): Promise<void>;
|
|
38
|
+
/** At startup: reads every language without holding the boot back. */
|
|
39
|
+
warm(): void;
|
|
40
|
+
/** The messages the browser words itself, by language, for the root's loader. */
|
|
41
|
+
clientMessages(): Record<L, CatalogueMessages>;
|
|
42
|
+
/**
|
|
43
|
+
* Forgets everything held and in flight. For use between tests, with
|
|
44
|
+
* nothing in flight: a read or check still running when it is called
|
|
45
|
+
* finishes and holds its result again.
|
|
46
|
+
*/
|
|
47
|
+
reset(): void;
|
|
48
|
+
/** The version held for a language, if any. */
|
|
49
|
+
heldVersion(language: L): string | undefined;
|
|
50
|
+
/** Waits for the check in flight, if any (tests). */
|
|
51
|
+
settled(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Keeps the API's error catalogue in `messages`: read on first use, checked
|
|
55
|
+
* against the API's versions once per TTL, and re-read only for the
|
|
56
|
+
* languages whose version moved. It is an instance, not module state.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createErrorCatalogue<L extends string>({ messages, languages, read, versions, ttlMs, retryMs, firstReadWaitMs, clientCodes, log: appLog, }: ErrorCatalogueOptions<L>): ErrorCatalogue<L>;
|
|
59
|
+
//# sourceMappingURL=catalogue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalogue.d.ts","sourceRoot":"","sources":["../../src/errors/catalogue.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,cAAc,EAAyB,MAAM,kBAAkB,CAAC;AAE9E,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEjE,YAAY,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEvD,MAAM,WAAW,qBAAqB,CAAC,CAAC,SAAS,MAAM;IACtD,oEAAoE;IACpE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IACzB,4DAA4D;IAC5D,SAAS,EAAE,SAAS,CAAC,EAAE,CAAC;IACxB,mDAAmD;IACnD,IAAI,CAAC,QAAQ,EAAE,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC3C,mEAAmE;IACnE,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAChD,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,CAAC;IAC/B,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,sDAAsD;IACtD,GAAG,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;CAC7B;AAED,4DAA4D;AAC5D,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,MAAM;IAC/C;;;;;OAKG;IACH,KAAK,CAAC,QAAQ,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,8DAA8D;IAC9D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,sEAAsE;IACtE,IAAI,IAAI,IAAI,CAAC;IACb,iFAAiF;IACjF,cAAc,IAAI,MAAM,CAAC,CAAC,EAAE,iBAAiB,CAAC,CAAC;IAC/C;;;;OAIG;IACH,KAAK,IAAI,IAAI,CAAC;IACd,+CAA+C;IAC/C,WAAW,CAAC,QAAQ,EAAE,CAAC,GAAG,MAAM,GAAG,SAAS,CAAC;IAC7C,qDAAqD;IACrD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAqBD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,MAAM,EAAE,EACtD,QAAQ,EACR,SAAS,EACT,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,OAAe,EACf,eAAuB,EACvB,WAAW,EACX,GAAG,EAAE,MAAgB,GACrB,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAoD9C"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ApiMessages } from './messages';
|
|
2
|
+
/** The part of a zod issue a code reads, by shape (`@nxgt/material-core`'s `CodeIssue`). */
|
|
3
|
+
export type CodeIssueLike = {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
/** A custom issue's params: `.refine(…, { params: { code } })`. */
|
|
6
|
+
readonly params?: Record<string, unknown> | undefined;
|
|
7
|
+
readonly input?: unknown;
|
|
8
|
+
readonly origin?: string | undefined;
|
|
9
|
+
readonly minimum?: unknown;
|
|
10
|
+
readonly maximum?: unknown;
|
|
11
|
+
readonly expected?: unknown;
|
|
12
|
+
readonly message?: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
/** An API error code (`<module>.errors.<slug>`), as opposed to a GraphQL one (`BAD_USER_INPUT`). */
|
|
15
|
+
export declare const API_CODE: RegExp;
|
|
16
|
+
export interface ApiCodeResolverOptions<L extends string> {
|
|
17
|
+
/**
|
|
18
|
+
* The app's translator, for every key that is not an API code. Method
|
|
19
|
+
* syntax: the app's own `Translate<LocaleKey>` is accepted.
|
|
20
|
+
*/
|
|
21
|
+
translate(key: string, context?: Readonly<Record<string, unknown>>, language?: L | (() => L)): string;
|
|
22
|
+
/** The page's language, when a call names none. */
|
|
23
|
+
language(): L;
|
|
24
|
+
/** Which code an issue carries; the issue's `params.code` by default. */
|
|
25
|
+
codeOf?(issue: CodeIssueLike): string | undefined;
|
|
26
|
+
/** Whether a key is an API code; `<module>.errors.<slug>` by default. */
|
|
27
|
+
isApiCode?(key: string): boolean;
|
|
28
|
+
}
|
|
29
|
+
/** What `@nxgt/material`'s `useCodeResolver(schema, resolver)` takes. */
|
|
30
|
+
export interface ApiCodeResolver<L extends string> {
|
|
31
|
+
translate: (key: string, context?: Readonly<Record<string, unknown>>, language?: L | (() => L)) => string;
|
|
32
|
+
codeOf: (issue: CodeIssueLike) => string | undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The words of what a form's checks ask for: a refinement carrying an API
|
|
36
|
+
* code in `params.code` reads exactly as the API words it (from the
|
|
37
|
+
* catalogue `messages` holds), any other key takes the app's translator, and
|
|
38
|
+
* a code the catalogue lacks comes back as it is, so the form library words
|
|
39
|
+
* the issue with its own locale instead. Both functions are stable: a
|
|
40
|
+
* resolver is made once per schema and language, so build this once, at
|
|
41
|
+
* module scope.
|
|
42
|
+
*/
|
|
43
|
+
export declare function apiCodeResolver<L extends string>(messages: ApiMessages<L>, options: ApiCodeResolverOptions<L>): ApiCodeResolver<L>;
|
|
44
|
+
//# sourceMappingURL=code-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"code-resolver.d.ts","sourceRoot":"","sources":["../../src/errors/code-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,4FAA4F;AAC5F,MAAM,MAAM,aAAa,GAAG;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IACtD,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACtC,CAAC;AAEF,oGAAoG;AACpG,eAAO,MAAM,QAAQ,QAAkC,CAAC;AAExD,MAAM,WAAW,sBAAsB,CAAC,CAAC,SAAS,MAAM;IACvD;;;OAGG;IACH,SAAS,CACR,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC3C,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,GACtB,MAAM,CAAC;IACV,mDAAmD;IACnD,QAAQ,IAAI,CAAC,CAAC;IACd,yEAAyE;IACzE,MAAM,CAAC,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAAC;IAClD,yEAAyE;IACzE,SAAS,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC;AAED,yEAAyE;AACzE,MAAM,WAAW,eAAe,CAAC,CAAC,SAAS,MAAM;IAChD,SAAS,EAAE,CACV,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC3C,QAAQ,CAAC,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,KACpB,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,MAAM,GAAG,SAAS,CAAC;CACrD;AAOD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAC/C,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,EACxB,OAAO,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAChC,eAAe,CAAC,CAAC,CAAC,CAiBpB"}
|