@uniflowed/router 0.0.0-alpha.6 → 0.0.0-alpha.7
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/client.js +6 -1
- package/index.js +22 -0
- package/internal/runtime.js +556 -17
- package/package.json +2 -2
- package/server.js +56 -3
package/client.js
CHANGED
|
@@ -21,8 +21,13 @@ export async function hydrate(options: {|
|
|
|
21
21
|
readonly App: React.ComponentType<AppProps>,
|
|
22
22
|
readonly routes: RouteTable["routes"],
|
|
23
23
|
readonly notFound: RouteTable["notFound"],
|
|
24
|
+
readonly errors: RouteTable["errors"],
|
|
24
25
|
|}): Promise<void> {
|
|
25
|
-
const table: RouteTable = {
|
|
26
|
+
const table: RouteTable = {
|
|
27
|
+
routes: options.routes,
|
|
28
|
+
notFound: options.notFound,
|
|
29
|
+
errors: options.errors,
|
|
30
|
+
};
|
|
26
31
|
installRoutes(table);
|
|
27
32
|
|
|
28
33
|
const url = window.location.pathname + window.location.search;
|
package/index.js
CHANGED
|
@@ -6,17 +6,27 @@
|
|
|
6
6
|
// `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
|
|
7
7
|
// is generated from the directory at build time; this module is the runtime
|
|
8
8
|
// that matches, loads, navigates and renders it.
|
|
9
|
+
//
|
|
10
|
+
// `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
|
|
11
|
+
// path that matched nothing, and what renders in place of a subtree that threw.
|
|
12
|
+
// Both are segment files, resolved by the nearest one above the path.
|
|
13
|
+
|
|
14
|
+
import type { RouteError } from "./internal/runtime.js";
|
|
9
15
|
|
|
10
16
|
export type {
|
|
11
17
|
AppProps,
|
|
18
|
+
ErrorBoundary,
|
|
19
|
+
ErrorModule,
|
|
12
20
|
LayoutModule,
|
|
13
21
|
LinkPrefetch,
|
|
14
22
|
LoaderArgs,
|
|
15
23
|
Metadata,
|
|
16
24
|
MetadataArgs,
|
|
17
25
|
NavigateOptions,
|
|
26
|
+
NotFoundBoundary,
|
|
18
27
|
PageModule,
|
|
19
28
|
ResolvedRoute,
|
|
29
|
+
RouteError,
|
|
20
30
|
RouteInfo,
|
|
21
31
|
RouteMatch,
|
|
22
32
|
RouteParamSpec,
|
|
@@ -28,19 +38,25 @@ export type {
|
|
|
28
38
|
} from "./internal/runtime.js";
|
|
29
39
|
|
|
30
40
|
export {
|
|
41
|
+
ForbiddenError,
|
|
31
42
|
Link,
|
|
32
43
|
NotFoundError,
|
|
33
44
|
RedirectError,
|
|
34
45
|
RouteView,
|
|
35
46
|
RouterProvider,
|
|
47
|
+
UnauthorizedError,
|
|
48
|
+
forbidden,
|
|
36
49
|
matchRoute,
|
|
37
50
|
notFound,
|
|
38
51
|
parseSearch,
|
|
39
52
|
permanentRedirect,
|
|
40
53
|
redirect,
|
|
54
|
+
resolveFailure,
|
|
41
55
|
resolveMatch,
|
|
56
|
+
routeErrorStatus,
|
|
42
57
|
routerView,
|
|
43
58
|
splitUrl,
|
|
59
|
+
unauthorized,
|
|
44
60
|
useIsServer,
|
|
45
61
|
useLoaderData,
|
|
46
62
|
useRoute,
|
|
@@ -57,6 +73,12 @@ export type PageProps<
|
|
|
57
73
|
readonly data: TData,
|
|
58
74
|
|};
|
|
59
75
|
|
|
76
|
+
/** Props an `_uf.error.js` component receives. */
|
|
77
|
+
export type ErrorProps = {|
|
|
78
|
+
readonly error: RouteError,
|
|
79
|
+
readonly reset: () => void,
|
|
80
|
+
|};
|
|
81
|
+
|
|
60
82
|
/** Props a layout receives. */
|
|
61
83
|
export type LayoutProps<
|
|
62
84
|
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
package/internal/runtime.js
CHANGED
|
@@ -99,6 +99,22 @@ export type LayoutModule = {
|
|
|
99
99
|
...
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* What an error module may export. The component is `default` or `Error`.
|
|
104
|
+
*
|
|
105
|
+
* `Error` shadows the global inside the file that writes it, which is the
|
|
106
|
+
* cost of naming the export after what it is; a file that needs the
|
|
107
|
+
* constructor still has `globalThis.Error`. The alternative was a name the
|
|
108
|
+
* convention would have to explain — `ErrorPage`, `Boundary` — for a file
|
|
109
|
+
* whose whole job is already in its name.
|
|
110
|
+
*/
|
|
111
|
+
export type ErrorModule = {
|
|
112
|
+
readonly default?: RouteComponent,
|
|
113
|
+
readonly Error?: RouteComponent,
|
|
114
|
+
readonly metadata?: Metadata,
|
|
115
|
+
...
|
|
116
|
+
};
|
|
117
|
+
|
|
102
118
|
/** Document metadata a page or layout declares. */
|
|
103
119
|
export type Metadata = {
|
|
104
120
|
readonly title?: string,
|
|
@@ -134,18 +150,48 @@ export type RouteRecord = {|
|
|
|
134
150
|
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
135
151
|
|};
|
|
136
152
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
153
|
+
/**
|
|
154
|
+
* One not-found boundary: the page for a path under `path` that matched
|
|
155
|
+
* nothing.
|
|
156
|
+
*
|
|
157
|
+
* `_uf.not-found.js` is a segment file, so `path` is the route path of the
|
|
158
|
+
* directory that declares it and `layouts` are the layouts in scope *there* —
|
|
159
|
+
* which is what the boundary renders inside. A project with one at the router
|
|
160
|
+
* root has one of these; a project whose manual answers its own 404 has two.
|
|
161
|
+
*/
|
|
162
|
+
export type NotFoundBoundary = {|
|
|
163
|
+
readonly path: string,
|
|
139
164
|
readonly mdx: boolean,
|
|
140
165
|
readonly file: string,
|
|
141
166
|
readonly page: () => Promise<PageModule>,
|
|
142
167
|
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
143
168
|
|};
|
|
144
169
|
|
|
145
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* One error boundary: what renders in place of the subtree under `path` when
|
|
172
|
+
* something in it throws.
|
|
173
|
+
*
|
|
174
|
+
* The same nearest-ancestor shape as [`NotFoundBoundary`], and `layouts` means
|
|
175
|
+
* the same thing — the layouts in scope where the file is, which stay mounted
|
|
176
|
+
* around the error and are why the rest of the document is still there.
|
|
177
|
+
*/
|
|
178
|
+
export type ErrorBoundary = {|
|
|
179
|
+
readonly path: string,
|
|
180
|
+
readonly file: string,
|
|
181
|
+
readonly module: () => Promise<ErrorModule>,
|
|
182
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
183
|
+
|};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A route table plus the boundaries declared under it.
|
|
187
|
+
*
|
|
188
|
+
* `errors` is the error boundaries a project declared, not failures that
|
|
189
|
+
* happened.
|
|
190
|
+
*/
|
|
146
191
|
export type RouteTable = {|
|
|
147
192
|
readonly routes: $ReadOnlyArray<RouteRecord>,
|
|
148
|
-
readonly notFound:
|
|
193
|
+
readonly notFound: $ReadOnlyArray<NotFoundBoundary>,
|
|
194
|
+
readonly errors: $ReadOnlyArray<ErrorBoundary>,
|
|
149
195
|
|};
|
|
150
196
|
|
|
151
197
|
/** A URL matched against the table. */
|
|
@@ -154,7 +200,40 @@ export type RouteMatch = {|
|
|
|
154
200
|
readonly params: RouteParams,
|
|
155
201
|
|};
|
|
156
202
|
|
|
157
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* Why the router is rendering an error boundary instead of a page.
|
|
205
|
+
*
|
|
206
|
+
* One union rather than one file convention per status. `forbidden()` and
|
|
207
|
+
* `unauthorized()` are not different *kinds* of file to write; they are
|
|
208
|
+
* different sentences an error page says, and `match` over this is where a
|
|
209
|
+
* page says all three and the checker confirms it covered them. Deciding it
|
|
210
|
+
* the other way — `_uf.forbidden.js` and `_uf.unauthorized.js` beside
|
|
211
|
+
* `_uf.error.js`, which is what Next.js does — is three files per segment to
|
|
212
|
+
* express one thing, and nothing would check that any of them handled the
|
|
213
|
+
* case it was named for.
|
|
214
|
+
*
|
|
215
|
+
* The thrown value is carried but deliberately not rendered by the default
|
|
216
|
+
* boundary: a server exception's message is written for the person who
|
|
217
|
+
* deployed the application, not for whoever asks for the page.
|
|
218
|
+
*/
|
|
219
|
+
export type RouteError =
|
|
220
|
+
| {| readonly kind: "thrown", readonly error: mixed |}
|
|
221
|
+
| {| readonly kind: "unauthorized" |}
|
|
222
|
+
| {| readonly kind: "forbidden" |};
|
|
223
|
+
|
|
224
|
+
/** The status a `RouteError` answers with. */
|
|
225
|
+
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
226
|
+
return match (error) {
|
|
227
|
+
{kind: "unauthorized"} => 401,
|
|
228
|
+
{kind: "forbidden"} => 403,
|
|
229
|
+
{kind: "thrown"} => 500,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A match whose modules are loaded and whose loader has run — or, when `error`
|
|
235
|
+
* is set, the error page that stands in for it.
|
|
236
|
+
*/
|
|
158
237
|
export type ResolvedRoute = {|
|
|
159
238
|
readonly pathname: string,
|
|
160
239
|
readonly search: string,
|
|
@@ -165,7 +244,26 @@ export type ResolvedRoute = {|
|
|
|
165
244
|
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
166
245
|
readonly data: mixed,
|
|
167
246
|
readonly metadata: Metadata,
|
|
168
|
-
readonly status: 200 | 404,
|
|
247
|
+
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
248
|
+
/**
|
|
249
|
+
* Set when this resolution *is* the error page: the loader threw, or the
|
|
250
|
+
* server render did and the renderer resolved again. `null` on the ordinary
|
|
251
|
+
* path.
|
|
252
|
+
*/
|
|
253
|
+
readonly error: ?RouteError,
|
|
254
|
+
/**
|
|
255
|
+
* The boundary that would catch a throw while rendering this route.
|
|
256
|
+
*
|
|
257
|
+
* Always present, because every route has an answer for a throw: `module`
|
|
258
|
+
* is `null` when the project declares no `_uf.error.js` above the path, and
|
|
259
|
+
* the framework's own error page renders instead. `above` is how many of
|
|
260
|
+
* `layouts` are outside the boundary — the ones that stay mounted, which is
|
|
261
|
+
* what "the rest of the document is still interactive" means.
|
|
262
|
+
*/
|
|
263
|
+
readonly errorBoundary: {|
|
|
264
|
+
readonly module: ?ErrorModule,
|
|
265
|
+
readonly above: number,
|
|
266
|
+
|},
|
|
169
267
|
|};
|
|
170
268
|
|
|
171
269
|
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
@@ -176,6 +274,22 @@ export class NotFoundError extends Error {
|
|
|
176
274
|
}
|
|
177
275
|
}
|
|
178
276
|
|
|
277
|
+
/** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
|
|
278
|
+
export class UnauthorizedError extends Error {
|
|
279
|
+
constructor() {
|
|
280
|
+
super("unauthorized");
|
|
281
|
+
this.name = "UnauthorizedError";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Thrown by `forbidden()`; the renderer answers with the error boundary. */
|
|
286
|
+
export class ForbiddenError extends Error {
|
|
287
|
+
constructor() {
|
|
288
|
+
super("forbidden");
|
|
289
|
+
this.name = "ForbiddenError";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
179
293
|
/** Thrown by `redirect()`; the renderer answers with a redirect. */
|
|
180
294
|
export class RedirectError extends Error {
|
|
181
295
|
to: string;
|
|
@@ -289,6 +403,60 @@ export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string
|
|
|
289
403
|
return best;
|
|
290
404
|
}
|
|
291
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Whether a boundary declared at `segments` is at or above `parts`.
|
|
408
|
+
*
|
|
409
|
+
* The same segment kinds as [`matchSegments`], stopping when the boundary's
|
|
410
|
+
* own segments run out instead of requiring the path to: `/guide` covers
|
|
411
|
+
* `/guide/nope`, and `/guide` covers `/guide` itself.
|
|
412
|
+
*/
|
|
413
|
+
function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
|
|
414
|
+
let index = 0;
|
|
415
|
+
for (const segment of segments) {
|
|
416
|
+
const next = match (segment) {
|
|
417
|
+
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
418
|
+
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
419
|
+
{kind: "catchAll"} => parts.length,
|
|
420
|
+
};
|
|
421
|
+
if (next === -1) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
index = next;
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The nearest boundary above `pathname`, or `null` when none covers it.
|
|
431
|
+
*
|
|
432
|
+
* The one rule both `_uf.not-found.js` and `_uf.error.js` are resolved by, and
|
|
433
|
+
* the same one layouts already follow: nearest means the longest path that
|
|
434
|
+
* covers the URL. It is decided here rather than by the table's order — the
|
|
435
|
+
* table is sorted by path so the generated module is stable, and a resolver
|
|
436
|
+
* that read "nearest" as "first" would silently depend on that sort. Two
|
|
437
|
+
* boundaries can share a path (a route group's directory does not appear in
|
|
438
|
+
* the URL), and then the first in the table wins.
|
|
439
|
+
*/
|
|
440
|
+
function nearestBoundary<TBoundary: { readonly path: string, ... }>(
|
|
441
|
+
boundaries: $ReadOnlyArray<TBoundary>,
|
|
442
|
+
pathname: string,
|
|
443
|
+
): ?TBoundary {
|
|
444
|
+
const parts = pathname.split("/").filter((part) => part !== "");
|
|
445
|
+
let best: ?TBoundary = null;
|
|
446
|
+
let bestDepth = -1;
|
|
447
|
+
for (const boundary of boundaries) {
|
|
448
|
+
const segments = compile(boundary.path);
|
|
449
|
+
if (!covers(segments, parts)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (segments.length > bestDepth) {
|
|
453
|
+
best = boundary;
|
|
454
|
+
bestDepth = segments.length;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return best;
|
|
458
|
+
}
|
|
459
|
+
|
|
292
460
|
/** Split a URL into its pathname and search string. */
|
|
293
461
|
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
294
462
|
const hash = url.indexOf("#");
|
|
@@ -342,11 +510,39 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
342
510
|
* `data` is what the loader returned; on the client after hydration it is the
|
|
343
511
|
* value the server embedded, so the loader does not run twice for the first
|
|
344
512
|
* page.
|
|
513
|
+
*
|
|
514
|
+
* # This resolves or redirects; it does not reject
|
|
515
|
+
*
|
|
516
|
+
* Everything a route can go wrong with is a route to render: no match and
|
|
517
|
+
* `notFound()` are the not-found boundary, a loader that threw and
|
|
518
|
+
* `forbidden()`/`unauthorized()` are the error boundary. Only `redirect()`
|
|
519
|
+
* comes back out, because a redirect is a response rather than a page and the
|
|
520
|
+
* caller is what has one to send.
|
|
521
|
+
*
|
|
522
|
+
* That guarantee is the point rather than a convenience. `hydrate` awaits this
|
|
523
|
+
* before `hydrateRoot`, so a rejection there is not an error page — it is no
|
|
524
|
+
* `hydrateRoot` call at all, and the document the server sent stays on screen
|
|
525
|
+
* with nothing attached to it.
|
|
345
526
|
*/
|
|
346
527
|
export async function resolveMatch(
|
|
347
528
|
table: RouteTable,
|
|
348
529
|
url: string,
|
|
349
530
|
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
531
|
+
): Promise<ResolvedRoute> {
|
|
532
|
+
try {
|
|
533
|
+
return await resolveRoute(table, url, options);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error instanceof RedirectError) {
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
return resolveFailure(table, url, error);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function resolveRoute(
|
|
543
|
+
table: RouteTable,
|
|
544
|
+
url: string,
|
|
545
|
+
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
350
546
|
): Promise<ResolvedRoute> {
|
|
351
547
|
const { pathname, search } = splitUrl(url);
|
|
352
548
|
const searchParams = parseSearch(search);
|
|
@@ -360,17 +556,14 @@ export async function resolveMatch(
|
|
|
360
556
|
loadOnce(matched.route.page),
|
|
361
557
|
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
362
558
|
]);
|
|
559
|
+
// Started here and awaited at the end: the boundary's module does not depend
|
|
560
|
+
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
561
|
+
// never rejects, so an early throw below leaves no unhandled rejection.
|
|
562
|
+
const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
|
|
363
563
|
|
|
364
564
|
let data: mixed = options?.data;
|
|
365
565
|
if (options?.skipLoader !== true && typeof page.loader === "function") {
|
|
366
|
-
|
|
367
|
-
data = await page.loader({ params: matched.params, searchParams, pathname });
|
|
368
|
-
} catch (error) {
|
|
369
|
-
if (error instanceof NotFoundError) {
|
|
370
|
-
return resolveNotFound(table, pathname, search, searchParams);
|
|
371
|
-
}
|
|
372
|
-
throw error;
|
|
373
|
-
}
|
|
566
|
+
data = await page.loader({ params: matched.params, searchParams, pathname });
|
|
374
567
|
}
|
|
375
568
|
|
|
376
569
|
const metadata = await resolveMetadata(page, layouts, {
|
|
@@ -389,16 +582,168 @@ export async function resolveMatch(
|
|
|
389
582
|
data,
|
|
390
583
|
metadata,
|
|
391
584
|
status: 200,
|
|
585
|
+
error: null,
|
|
586
|
+
errorBoundary: await boundary,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The route to render after something threw.
|
|
592
|
+
*
|
|
593
|
+
* Two callers, one behaviour: [`resolveMatch`] when a loader or a module
|
|
594
|
+
* import threw, and `createRenderer` when the *render* did — React's error
|
|
595
|
+
* boundaries do not run in `renderToString`, so the server has to catch it
|
|
596
|
+
* itself and resolve again.
|
|
597
|
+
*/
|
|
598
|
+
export async function resolveFailure(
|
|
599
|
+
table: RouteTable,
|
|
600
|
+
url: string,
|
|
601
|
+
error: mixed,
|
|
602
|
+
): Promise<ResolvedRoute> {
|
|
603
|
+
const { pathname, search } = splitUrl(url);
|
|
604
|
+
const searchParams = parseSearch(search);
|
|
605
|
+
if (error instanceof NotFoundError) {
|
|
606
|
+
try {
|
|
607
|
+
return await resolveNotFound(table, pathname, search, searchParams);
|
|
608
|
+
} catch (failure) {
|
|
609
|
+
// The not-found page itself would not load. Falling through to the error
|
|
610
|
+
// boundary rather than rethrowing is what keeps the promise above: the
|
|
611
|
+
// page a project wrote to explain a 404 is not more load-bearing than
|
|
612
|
+
// the document staying on screen.
|
|
613
|
+
return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return resolveError(table, pathname, search, searchParams, routeErrorFor(error));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** What a thrown value means to the router. */
|
|
620
|
+
function routeErrorFor(error: mixed): RouteError {
|
|
621
|
+
if (error instanceof UnauthorizedError) {
|
|
622
|
+
return { kind: "unauthorized" };
|
|
623
|
+
}
|
|
624
|
+
if (error instanceof ForbiddenError) {
|
|
625
|
+
return { kind: "forbidden" };
|
|
626
|
+
}
|
|
627
|
+
return { kind: "thrown", error };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The error boundary a route renders inside, loaded with the route rather than
|
|
632
|
+
* when it is needed.
|
|
633
|
+
*
|
|
634
|
+
* React decides to show a boundary's fallback synchronously, during the render
|
|
635
|
+
* that threw. A module that still has to be imported is a module that is not
|
|
636
|
+
* there at the only moment it can be used, so this is one more dynamic import
|
|
637
|
+
* per navigation and not a lazy one.
|
|
638
|
+
*
|
|
639
|
+
* `above` is the boundary's own layout count, clamped to the route's. The
|
|
640
|
+
* first attempt compared the two layout arrays for a shared prefix, which is
|
|
641
|
+
* more precise when a `(group)` directory puts a boundary beside a route
|
|
642
|
+
* rather than above it — and it worked by *reference identity* of the loader
|
|
643
|
+
* functions, which holds only because `routesModuleSource` deduplicates them
|
|
644
|
+
* by file. A rule that depends on an invisible property of the generated
|
|
645
|
+
* module is a rule that reads as zero the moment a table is built any other
|
|
646
|
+
* way, and it did: it put the boundary outside the layouts it was written
|
|
647
|
+
* inside. Nesting a boundary per group needs parallel-route trees (#267);
|
|
648
|
+
* until then this is the honest approximation, and it is stated rather than
|
|
649
|
+
* inferred.
|
|
650
|
+
*/
|
|
651
|
+
async function resolveErrorBoundary(
|
|
652
|
+
table: RouteTable,
|
|
653
|
+
pathname: string,
|
|
654
|
+
layoutCount: number,
|
|
655
|
+
): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
|
|
656
|
+
const boundary = nearestBoundary(table.errors, pathname);
|
|
657
|
+
if (boundary == null) {
|
|
658
|
+
return { module: null, above: 0 };
|
|
659
|
+
}
|
|
660
|
+
// Clamped, because a route group can leave a route with fewer layouts than
|
|
661
|
+
// the boundary covering it, and an `above` past the end would compose the
|
|
662
|
+
// layouts out of nothing.
|
|
663
|
+
const above = Math.min(boundary.layouts.length, layoutCount);
|
|
664
|
+
try {
|
|
665
|
+
return { module: await loadOnce(boundary.module), above };
|
|
666
|
+
} catch {
|
|
667
|
+
// A boundary whose module will not load cannot be the answer to a throw,
|
|
668
|
+
// and this is why the field is nullable: containment must not itself
|
|
669
|
+
// depend on an import working.
|
|
670
|
+
return { module: null, above: 0 };
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The error page for `pathname`, inside the layouts above the boundary that
|
|
676
|
+
* answers it.
|
|
677
|
+
*
|
|
678
|
+
* The layouts are the boundary's, for the same reason [`resolveNotFound`]
|
|
679
|
+
* gives: they are what stays mounted around the error, and the layouts below
|
|
680
|
+
* the boundary belong to the subtree that just stopped.
|
|
681
|
+
*/
|
|
682
|
+
async function resolveError(
|
|
683
|
+
table: RouteTable,
|
|
684
|
+
pathname: string,
|
|
685
|
+
search: string,
|
|
686
|
+
searchParams: SearchParams,
|
|
687
|
+
routeError: RouteError,
|
|
688
|
+
): Promise<ResolvedRoute> {
|
|
689
|
+
const boundary = nearestBoundary(table.errors, pathname);
|
|
690
|
+
let module: ?ErrorModule = null;
|
|
691
|
+
let layouts: $ReadOnlyArray<LayoutModule> = [];
|
|
692
|
+
if (boundary != null) {
|
|
693
|
+
try {
|
|
694
|
+
[module, layouts] = await Promise.all([
|
|
695
|
+
loadOnce(boundary.module),
|
|
696
|
+
Promise.all(boundary.layouts.map((layout) => loadOnce(layout))),
|
|
697
|
+
]);
|
|
698
|
+
} catch {
|
|
699
|
+
// See `resolveErrorBoundary`: the framework's own page answers instead.
|
|
700
|
+
module = null;
|
|
701
|
+
layouts = [];
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const declared = await resolveMetadata(
|
|
706
|
+
module?.metadata != null ? { metadata: module.metadata } : {},
|
|
707
|
+
layouts,
|
|
708
|
+
{ params: {}, searchParams, data: undefined },
|
|
709
|
+
);
|
|
710
|
+
return {
|
|
711
|
+
pathname,
|
|
712
|
+
search,
|
|
713
|
+
path: "*",
|
|
714
|
+
params: {},
|
|
715
|
+
searchParams,
|
|
716
|
+
page: { default: ResolvedErrorPage },
|
|
717
|
+
layouts,
|
|
718
|
+
data: undefined,
|
|
719
|
+
metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
|
|
720
|
+
status: routeErrorStatus(routeError),
|
|
721
|
+
error: routeError,
|
|
722
|
+
// All of the boundary's layouts are above it, and no inner boundary is
|
|
723
|
+
// inserted around a page that already is one; see `RouteView`.
|
|
724
|
+
errorBoundary: { module, above: layouts.length },
|
|
392
725
|
};
|
|
393
726
|
}
|
|
394
727
|
|
|
728
|
+
/**
|
|
729
|
+
* The not-found page for `pathname`, inside the layouts above the boundary
|
|
730
|
+
* that answers it.
|
|
731
|
+
*
|
|
732
|
+
* The layouts are the *boundary's*, not the ones the URL had already matched.
|
|
733
|
+
* Taking the matched route's layouts was the other candidate and it is wrong
|
|
734
|
+
* in both directions: for an unmatched URL there is no matched route to take
|
|
735
|
+
* them from, and for `notFound()` thrown from a page they would keep the
|
|
736
|
+
* layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
|
|
737
|
+
* wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
|
|
738
|
+
* of the page that just said it does not exist.
|
|
739
|
+
*/
|
|
395
740
|
async function resolveNotFound(
|
|
396
741
|
table: RouteTable,
|
|
397
742
|
pathname: string,
|
|
398
743
|
search: string,
|
|
399
744
|
searchParams: SearchParams,
|
|
400
745
|
): Promise<ResolvedRoute> {
|
|
401
|
-
const record = table.notFound;
|
|
746
|
+
const record = nearestBoundary(table.notFound, pathname);
|
|
402
747
|
if (record == null) {
|
|
403
748
|
return {
|
|
404
749
|
pathname,
|
|
@@ -411,6 +756,8 @@ async function resolveNotFound(
|
|
|
411
756
|
data: undefined,
|
|
412
757
|
metadata: { title: "Not found" },
|
|
413
758
|
status: 404,
|
|
759
|
+
error: null,
|
|
760
|
+
errorBoundary: await resolveErrorBoundary(table, pathname, 0),
|
|
414
761
|
};
|
|
415
762
|
}
|
|
416
763
|
const [page, ...layouts] = await Promise.all([
|
|
@@ -433,6 +780,9 @@ async function resolveNotFound(
|
|
|
433
780
|
data: undefined,
|
|
434
781
|
metadata,
|
|
435
782
|
status: 404,
|
|
783
|
+
error: null,
|
|
784
|
+
// A not-found page is a page: one that throws is contained like any other.
|
|
785
|
+
errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
|
|
436
786
|
};
|
|
437
787
|
}
|
|
438
788
|
|
|
@@ -474,6 +824,157 @@ component DefaultNotFound() {
|
|
|
474
824
|
);
|
|
475
825
|
}
|
|
476
826
|
|
|
827
|
+
/** The document title an error page gets when nothing declared one. */
|
|
828
|
+
function errorTitle(error: RouteError): string {
|
|
829
|
+
return match (error) {
|
|
830
|
+
{kind: "unauthorized"} => "Sign in required",
|
|
831
|
+
{kind: "forbidden"} => "Not allowed",
|
|
832
|
+
{kind: "thrown"} => "Something went wrong",
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The framework's error page, for a project that declares no `_uf.error.js`.
|
|
838
|
+
*
|
|
839
|
+
* It says which of the three happened and offers the reset, and it does *not*
|
|
840
|
+
* print the thrown error: on the server that message is written for whoever
|
|
841
|
+
* deployed the application — a query, a path, a token in a stack — and this
|
|
842
|
+
* markup is sent to whoever asked for the page. `uf dev` reports the throw in
|
|
843
|
+
* the terminal and `uf build` fails the route, which are the places the person
|
|
844
|
+
* who can act on it is looking.
|
|
845
|
+
*/
|
|
846
|
+
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
847
|
+
const title = errorTitle(error);
|
|
848
|
+
const detail = match (error) {
|
|
849
|
+
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
850
|
+
{kind: "forbidden"} => "You do not have access to this page.",
|
|
851
|
+
{kind: "thrown"} => "This page could not be rendered.",
|
|
852
|
+
};
|
|
853
|
+
return (
|
|
854
|
+
<main>
|
|
855
|
+
<title>{title}</title>
|
|
856
|
+
<h1>{title}</h1>
|
|
857
|
+
<p>{detail}</p>
|
|
858
|
+
<button type="button" onClick={reset}>
|
|
859
|
+
Try again
|
|
860
|
+
</button>
|
|
861
|
+
</main>
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** The component an error module renders: `default`, or the named `Error`. */
|
|
866
|
+
function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
|
|
867
|
+
const component = module.default ?? module.Error;
|
|
868
|
+
if (component == null) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return renderable(component);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The props an error boundary's component receives. */
|
|
877
|
+
type ErrorRenderProps = {|
|
|
878
|
+
readonly error: RouteError,
|
|
879
|
+
readonly reset: () => void,
|
|
880
|
+
|};
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* The error UI, from whichever module is in scope.
|
|
884
|
+
*
|
|
885
|
+
* One component for both ways in — the class boundary below, which catches a
|
|
886
|
+
* throw while the browser renders, and `ResolvedErrorPage`, which is what the
|
|
887
|
+
* server renders because React's boundaries do not run in `renderToString`.
|
|
888
|
+
* Two paths to the same screen is exactly the pair that drifts.
|
|
889
|
+
*/
|
|
890
|
+
component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
|
|
891
|
+
if (module == null) {
|
|
892
|
+
return <DefaultRouteError error={error} reset={reset} />;
|
|
893
|
+
}
|
|
894
|
+
const Boundary = errorComponent(module);
|
|
895
|
+
return <Boundary error={error} reset={reset} />;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* The page of a route that resolved to an error.
|
|
900
|
+
*
|
|
901
|
+
* A resolved error route carries the error and the module on the route itself,
|
|
902
|
+
* so this is a static component rather than a closure the resolver builds:
|
|
903
|
+
* `RouteView` composes it in its layouts exactly like a page, which is what
|
|
904
|
+
* makes "inside the layouts above the boundary" one code path and not two.
|
|
905
|
+
*
|
|
906
|
+
* `reset()` here is `router.refresh()` — this route resolved to an error
|
|
907
|
+
* because a loader or an import threw, so re-running the resolution is what
|
|
908
|
+
* trying again means. On the server `refresh` does nothing, which is correct:
|
|
909
|
+
* a static render has nothing to re-run.
|
|
910
|
+
*/
|
|
911
|
+
component ResolvedErrorPage() {
|
|
912
|
+
const { resolved, router } = useRouterState();
|
|
913
|
+
const reset = useCallback(() => {
|
|
914
|
+
router.refresh().catch(() => {});
|
|
915
|
+
}, [router]);
|
|
916
|
+
|
|
917
|
+
if (resolved.error == null) {
|
|
918
|
+
// Unreachable: this module is only ever the page of a resolved error route.
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
return (
|
|
922
|
+
<RouteErrorView module={resolved.errorBoundary.module} error={resolved.error} reset={reset} />
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
type RouteErrorBoundaryProps = {|
|
|
927
|
+
readonly module: ?ErrorModule,
|
|
928
|
+
readonly resetKey: string,
|
|
929
|
+
readonly children: React.Node,
|
|
930
|
+
|};
|
|
931
|
+
|
|
932
|
+
type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The boundary that catches a throw while the browser renders the subtree.
|
|
936
|
+
*
|
|
937
|
+
* A class, because `getDerivedStateFromError` is React's contract for this and
|
|
938
|
+
* there is no hook that does it — this is the one place in the router where
|
|
939
|
+
* following React's public contract means not using a function component.
|
|
940
|
+
*
|
|
941
|
+
* Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
|
|
942
|
+
* `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
|
|
943
|
+
* navigation, error or not, and everything below the boundary goes with it —
|
|
944
|
+
* which is the layouts, whose whole purpose is to survive navigation with
|
|
945
|
+
* their scroll position and their open sections intact.
|
|
946
|
+
*/
|
|
947
|
+
class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
|
|
948
|
+
constructor(props: RouteErrorBoundaryProps) {
|
|
949
|
+
super(props);
|
|
950
|
+
this.state = { error: null };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
|
|
954
|
+
return { error: routeErrorFor(error) };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
componentDidUpdate(previous: RouteErrorBoundaryProps) {
|
|
958
|
+
if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
|
|
959
|
+
this.setState({ error: null });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
render(): React.Node {
|
|
964
|
+
const { error } = this.state;
|
|
965
|
+
if (error == null) {
|
|
966
|
+
return this.props.children;
|
|
967
|
+
}
|
|
968
|
+
return (
|
|
969
|
+
<RouteErrorView
|
|
970
|
+
module={this.props.module}
|
|
971
|
+
error={error}
|
|
972
|
+
reset={() => this.setState({ error: null })}
|
|
973
|
+
/>
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
477
978
|
// ---------------------------------------------------------------------------
|
|
478
979
|
// The React binding
|
|
479
980
|
// ---------------------------------------------------------------------------
|
|
@@ -705,21 +1206,49 @@ export hook useLoaderData(): mixed {
|
|
|
705
1206
|
/**
|
|
706
1207
|
* Renders the matched page inside its layouts, innermost last, with the
|
|
707
1208
|
* document metadata as hoistable head elements.
|
|
1209
|
+
*
|
|
1210
|
+
* # Where the error boundaries go
|
|
1211
|
+
*
|
|
1212
|
+
* Two, and they are not the same thing twice. The inner one is the project's
|
|
1213
|
+
* `_uf.error.js`, placed at the depth the file sits at, so the layouts above
|
|
1214
|
+
* it stay mounted and interactive while the subtree below is replaced — that
|
|
1215
|
+
* placement *is* the feature. The outer one has no module and so renders the
|
|
1216
|
+
* framework's page; it is what stands between a throw in a root layout, or in
|
|
1217
|
+
* the error component itself, and an unmounted document. A single boundary
|
|
1218
|
+
* cannot be both: put it outside and a page's throw takes the navigation down
|
|
1219
|
+
* with it; put it inside and nothing catches the layout above.
|
|
708
1220
|
*/
|
|
709
1221
|
export component RouteView() {
|
|
710
1222
|
const { resolved } = useRouterState();
|
|
1223
|
+
const { module, above } = resolved.errorBoundary;
|
|
711
1224
|
const Page = pageComponent(resolved.page);
|
|
712
1225
|
let element: React.Node = (
|
|
713
1226
|
<Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
|
|
714
1227
|
);
|
|
715
|
-
for (let index = resolved.layouts.length - 1; index >=
|
|
1228
|
+
for (let index = resolved.layouts.length - 1; index >= above; index -= 1) {
|
|
1229
|
+
const Layout = layoutComponent(resolved.layouts[index]);
|
|
1230
|
+
element = <Layout params={resolved.params}>{element}</Layout>;
|
|
1231
|
+
}
|
|
1232
|
+
// Not around a route that already resolved to its error page: that page is
|
|
1233
|
+
// the boundary's own component, and wrapping it in the same boundary would
|
|
1234
|
+
// answer a throw inside it with itself.
|
|
1235
|
+
if (module != null && resolved.error == null) {
|
|
1236
|
+
element = (
|
|
1237
|
+
<RouteErrorBoundary module={module} resetKey={resolved.pathname}>
|
|
1238
|
+
{element}
|
|
1239
|
+
</RouteErrorBoundary>
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
for (let index = above - 1; index >= 0; index -= 1) {
|
|
716
1243
|
const Layout = layoutComponent(resolved.layouts[index]);
|
|
717
1244
|
element = <Layout params={resolved.params}>{element}</Layout>;
|
|
718
1245
|
}
|
|
719
1246
|
return (
|
|
720
1247
|
<>
|
|
721
1248
|
<Head metadata={resolved.metadata} />
|
|
722
|
-
{
|
|
1249
|
+
<RouteErrorBoundary module={null} resetKey={resolved.pathname}>
|
|
1250
|
+
{element}
|
|
1251
|
+
</RouteErrorBoundary>
|
|
723
1252
|
</>
|
|
724
1253
|
);
|
|
725
1254
|
}
|
|
@@ -889,6 +1418,16 @@ export function notFound(): empty {
|
|
|
889
1418
|
throw new NotFoundError();
|
|
890
1419
|
}
|
|
891
1420
|
|
|
1421
|
+
/** Stop rendering the current page and show the error boundary, as a 401. */
|
|
1422
|
+
export function unauthorized(): empty {
|
|
1423
|
+
throw new UnauthorizedError();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Stop rendering the current page and show the error boundary, as a 403. */
|
|
1427
|
+
export function forbidden(): empty {
|
|
1428
|
+
throw new ForbiddenError();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
892
1431
|
/** Stop rendering the current page and send the visitor elsewhere. */
|
|
893
1432
|
export function redirect(to: string): empty {
|
|
894
1433
|
throw new RedirectError(to, false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.7",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
"react-dom": ">=19"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
32
|
+
"@uniflowed/server": "0.0.0-alpha.7"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type RouteTable,
|
|
18
18
|
RedirectError,
|
|
19
19
|
installRoutes,
|
|
20
|
+
resolveFailure,
|
|
20
21
|
resolveMatch,
|
|
21
22
|
} from "./internal/runtime.js";
|
|
22
23
|
|
|
@@ -32,6 +33,19 @@ export type RenderResult = {|
|
|
|
32
33
|
readonly status: number,
|
|
33
34
|
readonly html: string,
|
|
34
35
|
readonly headers?: { readonly [string]: string },
|
|
36
|
+
/**
|
|
37
|
+
* The exception this render fell back to its error boundary for.
|
|
38
|
+
*
|
|
39
|
+
* The document is still a document — the boundary rendered — and this is how
|
|
40
|
+
* the caller learns that it is an error page rather than the page it asked
|
|
41
|
+
* for. `uf build` fails the route it names; `uf dev` reports it in the
|
|
42
|
+
* terminal. Without it, containment would mean a build that quietly wrote a
|
|
43
|
+
* directory of error pages and exited 0.
|
|
44
|
+
*
|
|
45
|
+
* `forbidden()` and `unauthorized()` do not set it: those are answers an
|
|
46
|
+
* application chose, and a build that prerendered one has not failed.
|
|
47
|
+
*/
|
|
48
|
+
readonly error?: mixed,
|
|
35
49
|
|};
|
|
36
50
|
|
|
37
51
|
/**
|
|
@@ -46,8 +60,13 @@ export function createRenderer(options: {|
|
|
|
46
60
|
readonly App: React.ComponentType<AppProps>,
|
|
47
61
|
readonly routes: RouteTable["routes"],
|
|
48
62
|
readonly notFound: RouteTable["notFound"],
|
|
63
|
+
readonly errors: RouteTable["errors"],
|
|
49
64
|
|}): (url: string, assets: RenderAssets) => Promise<RenderResult> {
|
|
50
|
-
const table: RouteTable = {
|
|
65
|
+
const table: RouteTable = {
|
|
66
|
+
routes: options.routes,
|
|
67
|
+
notFound: options.notFound,
|
|
68
|
+
errors: options.errors,
|
|
69
|
+
};
|
|
51
70
|
installRoutes(table);
|
|
52
71
|
const { App } = options;
|
|
53
72
|
|
|
@@ -56,15 +75,49 @@ export function createRenderer(options: {|
|
|
|
56
75
|
try {
|
|
57
76
|
resolved = await resolveMatch(table, url);
|
|
58
77
|
} catch (error) {
|
|
78
|
+
// A redirect is the only thing `resolveMatch` lets out, because a
|
|
79
|
+
// redirect is a response rather than a page.
|
|
59
80
|
if (error instanceof RedirectError) {
|
|
60
81
|
return redirectDocument(error);
|
|
61
82
|
}
|
|
62
83
|
throw error;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
|
|
86
|
+
let markup: string;
|
|
87
|
+
try {
|
|
88
|
+
markup = renderToString(<App url={url} initial={resolved} />);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// The server's half of the error boundary. React does not run class
|
|
91
|
+
// boundaries in `renderToString` — Fizz has no `getDerivedStateFromError`
|
|
92
|
+
// step outside a Suspense boundary — so `RouteView`'s boundary is the
|
|
93
|
+
// browser's containment and this is the server's. Without it one
|
|
94
|
+
// component that throws is the whole response, and during `uf build` the
|
|
95
|
+
// whole build. See ubugeeei-prod/uf#257.
|
|
96
|
+
if (error instanceof RedirectError) {
|
|
97
|
+
return redirectDocument(error);
|
|
98
|
+
}
|
|
99
|
+
resolved = await resolveFailure(table, url, error);
|
|
100
|
+
// Deliberately not caught again: this render is the boundary's own
|
|
101
|
+
// component, and a boundary that throws has nothing left to answer with.
|
|
102
|
+
// It reaches `uf dev`'s overlay and fails `uf build`'s route, which is
|
|
103
|
+
// where somebody can fix it.
|
|
104
|
+
markup = renderToString(<App url={url} initial={resolved} />);
|
|
105
|
+
}
|
|
106
|
+
|
|
66
107
|
const html = assemble(markup, resolved, assets);
|
|
67
|
-
return { status: resolved.status, html };
|
|
108
|
+
return { status: resolved.status, html, error: renderFailure(resolved) };
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The exception a resolved route fell back to its error boundary for. */
|
|
113
|
+
function renderFailure(resolved: ResolvedRoute): mixed {
|
|
114
|
+
if (resolved.error == null) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return match (resolved.error) {
|
|
118
|
+
{kind: "thrown", error: const error} => error,
|
|
119
|
+
{kind: "unauthorized"} => undefined,
|
|
120
|
+
{kind: "forbidden"} => undefined,
|
|
68
121
|
};
|
|
69
122
|
}
|
|
70
123
|
|