@uniflowed/router 0.0.0-alpha.28 → 0.0.0-alpha.30
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/internal/payload-rows.js +3 -2
- package/internal/payload.js +17 -1
- package/internal/routing.js +426 -0
- package/internal/runtime.js +94 -578
- package/package.json +5 -3
- package/routing.js +42 -0
package/internal/payload-rows.js
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
PAYLOAD_ROW_ATTRIBUTE,
|
|
58
58
|
PayloadRowError,
|
|
59
59
|
PayloadValueError,
|
|
60
|
+
payloadRowId,
|
|
60
61
|
parseRowMessage,
|
|
61
62
|
} from "./payload.js";
|
|
62
63
|
|
|
@@ -149,8 +150,8 @@ export function createPayloadReader(
|
|
|
149
150
|
if (attribute == null) {
|
|
150
151
|
return;
|
|
151
152
|
}
|
|
152
|
-
const id =
|
|
153
|
-
if (
|
|
153
|
+
const id = payloadRowId(attribute);
|
|
154
|
+
if (id == null) {
|
|
154
155
|
return;
|
|
155
156
|
}
|
|
156
157
|
const slot = slots.get(id);
|
package/internal/payload.js
CHANGED
|
@@ -580,6 +580,19 @@ function decodeReference(value: string, path: string, resolve: RowResolver): mix
|
|
|
580
580
|
return resolve(id);
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
+
/**
|
|
584
|
+
* The row id carried by a streamed row element, or `null` when the attribute
|
|
585
|
+
* is not one.
|
|
586
|
+
*
|
|
587
|
+
* This is the same grammar as the `$P<n>` reference without the `$P` tag:
|
|
588
|
+
* digits only, in range. The browser reads row elements from a live document,
|
|
589
|
+
* so accepting `Number.parseInt`'s looser spellings would let `1x` satisfy the
|
|
590
|
+
* row the model named as `$P1`.
|
|
591
|
+
*/
|
|
592
|
+
export function payloadRowId(value: string): number | null {
|
|
593
|
+
return parsePayloadRowId(value);
|
|
594
|
+
}
|
|
595
|
+
|
|
583
596
|
/**
|
|
584
597
|
* The row a reference names, or `null` when the string is not one.
|
|
585
598
|
*
|
|
@@ -591,7 +604,10 @@ function rowId(value: string): number | null {
|
|
|
591
604
|
if (!value.startsWith(REFERENCE_PREFIX + ROW_TAG)) {
|
|
592
605
|
return null;
|
|
593
606
|
}
|
|
594
|
-
|
|
607
|
+
return parsePayloadRowId(value.slice(2));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function parsePayloadRowId(digits: string): number | null {
|
|
595
611
|
if (digits.length === 0 || digits.length > 3) {
|
|
596
612
|
return null;
|
|
597
613
|
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the React-free route table primitives.
|
|
4
|
+
//
|
|
5
|
+
// Server Components need to be able to name routes, throw router control
|
|
6
|
+
// errors, and match generated tables without importing the client router,
|
|
7
|
+
// React components, or hooks. Keep this file to data, errors, and pure
|
|
8
|
+
// functions; rendering belongs in `runtime.js`.
|
|
9
|
+
|
|
10
|
+
/** One parameter a route path captures. */
|
|
11
|
+
export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
|
|
12
|
+
|
|
13
|
+
/** The parameters captured from a URL. A catch-all captures the rest as a list. */
|
|
14
|
+
export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
|
|
15
|
+
|
|
16
|
+
/** The query string, as a read-only map. */
|
|
17
|
+
export type SearchParams = { readonly [string]: string };
|
|
18
|
+
|
|
19
|
+
/** A lazy route module entry from the generated route table. */
|
|
20
|
+
export type RouteModule<TModule = mixed> = () => Promise<TModule>;
|
|
21
|
+
|
|
22
|
+
/** One `$template.js`, as the route table carries it. */
|
|
23
|
+
export type TemplateRecord<TTemplate = mixed> = {|
|
|
24
|
+
readonly above: number,
|
|
25
|
+
readonly module: RouteModule<TTemplate>,
|
|
26
|
+
|};
|
|
27
|
+
|
|
28
|
+
/** One `$loading.js`, as the route table carries it. */
|
|
29
|
+
export type LoadingRecord<TLoading = mixed> = {|
|
|
30
|
+
readonly above: number,
|
|
31
|
+
readonly module: RouteModule<TLoading>,
|
|
32
|
+
|};
|
|
33
|
+
|
|
34
|
+
/** One parallel-route slot, as the route table carries it. */
|
|
35
|
+
export type SlotRecord<TPage = mixed, TLayout = mixed> = {|
|
|
36
|
+
readonly name: string,
|
|
37
|
+
readonly above: number,
|
|
38
|
+
readonly defaultPage: ?RouteModule<TPage>,
|
|
39
|
+
readonly defaultFile?: string,
|
|
40
|
+
readonly defaultMdx?: boolean,
|
|
41
|
+
readonly routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout>>,
|
|
42
|
+
|};
|
|
43
|
+
|
|
44
|
+
/** One page inside a slot. */
|
|
45
|
+
export type SlotRouteRecord<TPage = mixed, TLayout = mixed> = {|
|
|
46
|
+
readonly path: string,
|
|
47
|
+
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
48
|
+
readonly mdx: boolean,
|
|
49
|
+
readonly file: string,
|
|
50
|
+
readonly page: RouteModule<TPage>,
|
|
51
|
+
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|
|
52
|
+
readonly slots: $ReadOnlyArray<SlotRecord<TPage, TLayout>>,
|
|
53
|
+
|};
|
|
54
|
+
|
|
55
|
+
/** One entry of the generated route table. */
|
|
56
|
+
export type RouteRecord<TPage = mixed, TLayout = mixed, TTemplate = mixed, TLoading = mixed> = {|
|
|
57
|
+
readonly path: string,
|
|
58
|
+
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
59
|
+
readonly mdx: boolean,
|
|
60
|
+
readonly file: string,
|
|
61
|
+
readonly page?: RouteModule<TPage>,
|
|
62
|
+
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|
|
63
|
+
readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
|
|
64
|
+
readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
|
|
65
|
+
readonly slots?: $ReadOnlyArray<SlotRecord<TPage, TLayout>>,
|
|
66
|
+
|};
|
|
67
|
+
|
|
68
|
+
/** One not-found boundary: the page for a path under `path` that matched nothing. */
|
|
69
|
+
export type NotFoundBoundary<TPage = mixed, TLayout = mixed> = {|
|
|
70
|
+
readonly path: string,
|
|
71
|
+
readonly mdx: boolean,
|
|
72
|
+
readonly file: string,
|
|
73
|
+
readonly page: ?RouteModule<TPage>,
|
|
74
|
+
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|
|
75
|
+
|};
|
|
76
|
+
|
|
77
|
+
/** One error boundary: what renders in place of a subtree that threw. */
|
|
78
|
+
export type ErrorBoundary<TError = mixed, TLayout = mixed> = {|
|
|
79
|
+
readonly path: string,
|
|
80
|
+
readonly file: string,
|
|
81
|
+
readonly module: ?RouteModule<TError>,
|
|
82
|
+
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|
|
83
|
+
|};
|
|
84
|
+
|
|
85
|
+
/** A route table plus the boundaries declared under it. */
|
|
86
|
+
export type RouteTable<
|
|
87
|
+
TPage = mixed,
|
|
88
|
+
TLayout = mixed,
|
|
89
|
+
TTemplate = mixed,
|
|
90
|
+
TLoading = mixed,
|
|
91
|
+
TError = mixed,
|
|
92
|
+
> = {|
|
|
93
|
+
readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading>>,
|
|
94
|
+
readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
|
|
95
|
+
readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
|
|
96
|
+
|};
|
|
97
|
+
|
|
98
|
+
type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed>;
|
|
99
|
+
|
|
100
|
+
/** A URL matched against a table. */
|
|
101
|
+
export type RouteMatch<TRoute: { +path: string, ... } = UnknownRouteRecord> = {|
|
|
102
|
+
readonly route: TRoute,
|
|
103
|
+
readonly params: RouteParams,
|
|
104
|
+
|};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why the router is rendering an error boundary instead of a page.
|
|
108
|
+
*
|
|
109
|
+
* One union rather than one file convention per status. `forbidden()` and
|
|
110
|
+
* `unauthorized()` are not different *kinds* of file to write; they are
|
|
111
|
+
* different sentences an error page says.
|
|
112
|
+
*/
|
|
113
|
+
export type RouteError =
|
|
114
|
+
| {| readonly kind: "thrown", readonly error: mixed |}
|
|
115
|
+
| {| readonly kind: "unauthorized" |}
|
|
116
|
+
| {| readonly kind: "forbidden" |};
|
|
117
|
+
|
|
118
|
+
/** The status a `RouteError` answers with. */
|
|
119
|
+
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
120
|
+
return match (error) {
|
|
121
|
+
{kind: "unauthorized"} => 401,
|
|
122
|
+
{kind: "forbidden"} => 403,
|
|
123
|
+
{kind: "thrown"} => 500,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
128
|
+
export class NotFoundError extends Error {
|
|
129
|
+
constructor() {
|
|
130
|
+
super("not found");
|
|
131
|
+
this.name = "NotFoundError";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
|
|
136
|
+
export class UnauthorizedError extends Error {
|
|
137
|
+
constructor() {
|
|
138
|
+
super("unauthorized");
|
|
139
|
+
this.name = "UnauthorizedError";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Thrown by `forbidden()`; the renderer answers with the error boundary. */
|
|
144
|
+
export class ForbiddenError extends Error {
|
|
145
|
+
constructor() {
|
|
146
|
+
super("forbidden");
|
|
147
|
+
this.name = "ForbiddenError";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Thrown by `redirect()`; the renderer answers with a redirect. */
|
|
152
|
+
export class RedirectError extends Error {
|
|
153
|
+
to: string;
|
|
154
|
+
permanent: boolean;
|
|
155
|
+
|
|
156
|
+
constructor(to: string, permanent: boolean) {
|
|
157
|
+
super(`redirect to ${to}`);
|
|
158
|
+
this.name = "RedirectError";
|
|
159
|
+
this.to = to;
|
|
160
|
+
this.permanent = permanent;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type Segment =
|
|
165
|
+
| {| readonly kind: "static", readonly value: string |}
|
|
166
|
+
| {| readonly kind: "param", readonly name: string |}
|
|
167
|
+
| {| readonly kind: "catchAll", readonly name: string |};
|
|
168
|
+
|
|
169
|
+
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
170
|
+
return routePath
|
|
171
|
+
.split("/")
|
|
172
|
+
.filter((segment) => segment !== "")
|
|
173
|
+
.map((segment): Segment => {
|
|
174
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
175
|
+
return { kind: "catchAll", name: segment.slice(1, -1) };
|
|
176
|
+
}
|
|
177
|
+
if (segment.startsWith(":")) {
|
|
178
|
+
return { kind: "param", name: segment.slice(1) };
|
|
179
|
+
}
|
|
180
|
+
return { kind: "static", value: segment };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* How specific a route is, for ranking: a static segment outranks a parameter,
|
|
186
|
+
* which outranks a catch-all, and a longer path outranks a shorter one.
|
|
187
|
+
*/
|
|
188
|
+
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
189
|
+
let score = 0;
|
|
190
|
+
for (const segment of segments) {
|
|
191
|
+
score += match (segment) {
|
|
192
|
+
{kind: "static"} => 3,
|
|
193
|
+
{kind: "param"} => 2,
|
|
194
|
+
{kind: "catchAll"} => 1,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return score;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function matchSegments(
|
|
201
|
+
segments: $ReadOnlyArray<Segment>,
|
|
202
|
+
parts: $ReadOnlyArray<string>,
|
|
203
|
+
): ?RouteParams {
|
|
204
|
+
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
205
|
+
let index = 0;
|
|
206
|
+
for (const segment of segments) {
|
|
207
|
+
match (segment) {
|
|
208
|
+
{kind: "static", value: const value} => {
|
|
209
|
+
if (parts[index] !== value) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
index += 1;
|
|
213
|
+
}
|
|
214
|
+
{kind: "param", name: const name} => {
|
|
215
|
+
if (index >= parts.length) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
params[name] = decodeSegment(parts[index]);
|
|
219
|
+
index += 1;
|
|
220
|
+
}
|
|
221
|
+
{kind: "catchAll", name: const name} => {
|
|
222
|
+
params[name] = parts.slice(index).map(decodeSegment);
|
|
223
|
+
index = parts.length;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return index === parts.length ? params : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The URL for a route pattern and the parameters it takes.
|
|
232
|
+
*
|
|
233
|
+
* The inverse of [`matchSegments`], and deliberately built out of the same
|
|
234
|
+
* [`compile`]: a builder that parsed patterns its own way would drift from the
|
|
235
|
+
* matcher, and the drift would show up as a link that 404s rather than as a
|
|
236
|
+
* failure anybody could see.
|
|
237
|
+
*/
|
|
238
|
+
export function buildRoute(routePath: string, params?: RouteParams): string {
|
|
239
|
+
const values: RouteParams = params ?? {};
|
|
240
|
+
const parts: Array<string> = [];
|
|
241
|
+
for (const segment of compile(routePath)) {
|
|
242
|
+
match (segment) {
|
|
243
|
+
{kind: "static", value: const value} => {
|
|
244
|
+
parts.push(value);
|
|
245
|
+
}
|
|
246
|
+
{kind: "param", name: const name} => {
|
|
247
|
+
const value = values[name];
|
|
248
|
+
if (typeof value !== "string") {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
parts.push(encodeURIComponent(value));
|
|
254
|
+
}
|
|
255
|
+
{kind: "catchAll", name: const name} => {
|
|
256
|
+
const value = values[name];
|
|
257
|
+
if (value == null || typeof value === "string") {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`route ${routePath} takes an array of segments for :${name}*, and got ` +
|
|
260
|
+
describeParam(value),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
for (const part of value) {
|
|
264
|
+
parts.push(encodeURIComponent(part));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return parts.length === 0 ? "/" : `/${parts.join("/")}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** What a parameter was, for the message that says it was the wrong thing. */
|
|
273
|
+
function describeParam(value: string | $ReadOnlyArray<string> | void): string {
|
|
274
|
+
if (value === undefined) {
|
|
275
|
+
return "nothing";
|
|
276
|
+
}
|
|
277
|
+
return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function decodeSegment(segment: string): string {
|
|
281
|
+
try {
|
|
282
|
+
return decodeURIComponent(segment);
|
|
283
|
+
} catch {
|
|
284
|
+
return segment;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Whether this table can render the route in the browser. */
|
|
289
|
+
export function hasClientPage(route: { +page?: mixed, ... }): boolean {
|
|
290
|
+
return route.page != null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Match a pathname against the table, preferring the most specific route. */
|
|
294
|
+
export function matchRoute<TRoute: { +path: string, ... }>(
|
|
295
|
+
routes: $ReadOnlyArray<TRoute>,
|
|
296
|
+
pathname: string,
|
|
297
|
+
): ?RouteMatch<TRoute> {
|
|
298
|
+
return matchIn(routes, pathname);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The same match, over anything that has a route path.
|
|
303
|
+
*
|
|
304
|
+
* A slot is a second table matched against the same URL, and it has to be
|
|
305
|
+
* matched by this function rather than by one of its own.
|
|
306
|
+
*/
|
|
307
|
+
export function matchIn<TRoute: { +path: string, ... }>(
|
|
308
|
+
routes: $ReadOnlyArray<TRoute>,
|
|
309
|
+
pathname: string,
|
|
310
|
+
): ?RouteMatch<TRoute> {
|
|
311
|
+
const parts = pathname.split("/").filter((part) => part !== "");
|
|
312
|
+
let best: ?RouteMatch<TRoute> = null;
|
|
313
|
+
let bestScore = -1;
|
|
314
|
+
for (const route of routes) {
|
|
315
|
+
const segments = compile(route.path);
|
|
316
|
+
const params = matchSegments(segments, parts);
|
|
317
|
+
if (params == null) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const score = specificity(segments);
|
|
321
|
+
if (score > bestScore) {
|
|
322
|
+
best = { route, params };
|
|
323
|
+
bestScore = score;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return best;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Whether a boundary declared at `segments` is at or above `parts`.
|
|
331
|
+
*
|
|
332
|
+
* The same segment kinds as [`matchSegments`], stopping when the boundary's
|
|
333
|
+
* own segments run out instead of requiring the path to.
|
|
334
|
+
*/
|
|
335
|
+
function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
|
|
336
|
+
let index = 0;
|
|
337
|
+
for (const segment of segments) {
|
|
338
|
+
const next = match (segment) {
|
|
339
|
+
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
340
|
+
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
341
|
+
{kind: "catchAll"} => parts.length,
|
|
342
|
+
};
|
|
343
|
+
if (next === -1) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
index = next;
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The nearest boundary above `pathname`, or `null` when none covers it. */
|
|
352
|
+
export function nearestBoundary<TBoundary: { readonly path: string, ... }>(
|
|
353
|
+
boundaries: $ReadOnlyArray<TBoundary>,
|
|
354
|
+
pathname: string,
|
|
355
|
+
): ?TBoundary {
|
|
356
|
+
const parts = pathname.split("/").filter((part) => part !== "");
|
|
357
|
+
let best: ?TBoundary = null;
|
|
358
|
+
let bestDepth = -1;
|
|
359
|
+
for (const boundary of boundaries) {
|
|
360
|
+
const segments = compile(boundary.path);
|
|
361
|
+
if (!covers(segments, parts)) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (segments.length > bestDepth) {
|
|
365
|
+
best = boundary;
|
|
366
|
+
bestDepth = segments.length;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return best;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Split a URL into its pathname and search string. */
|
|
373
|
+
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
374
|
+
const hash = url.indexOf("#");
|
|
375
|
+
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
376
|
+
const question = withoutHash.indexOf("?");
|
|
377
|
+
if (question === -1) {
|
|
378
|
+
return { pathname: normalizePathname(withoutHash), search: "" };
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
pathname: normalizePathname(withoutHash.slice(0, question)),
|
|
382
|
+
search: withoutHash.slice(question),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normalizePathname(pathname: string): string {
|
|
387
|
+
if (pathname === "" || pathname === "/") {
|
|
388
|
+
return "/";
|
|
389
|
+
}
|
|
390
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
391
|
+
return trimmed === "" ? "/" : trimmed;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Parse a search string into a flat map; a repeated key keeps its last value. */
|
|
395
|
+
export function parseSearch(search: string): SearchParams {
|
|
396
|
+
const params: { [string]: string } = {};
|
|
397
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
398
|
+
params[key] = value;
|
|
399
|
+
}
|
|
400
|
+
return params;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Stop rendering the current page and show the not-found page instead. */
|
|
404
|
+
export function notFound(): empty {
|
|
405
|
+
throw new NotFoundError();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Stop rendering the current page and show the error boundary, as a 401. */
|
|
409
|
+
export function unauthorized(): empty {
|
|
410
|
+
throw new UnauthorizedError();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Stop rendering the current page and show the error boundary, as a 403. */
|
|
414
|
+
export function forbidden(): empty {
|
|
415
|
+
throw new ForbiddenError();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Stop rendering the current page and send the visitor elsewhere. */
|
|
419
|
+
export function redirect(to: string): empty {
|
|
420
|
+
throw new RedirectError(to, false);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** `redirect`, with a permanent status. */
|
|
424
|
+
export function permanentRedirect(to: string): empty {
|
|
425
|
+
throw new RedirectError(to, true);
|
|
426
|
+
}
|
package/internal/runtime.js
CHANGED
|
@@ -68,15 +68,53 @@ import {
|
|
|
68
68
|
routeBoundaries,
|
|
69
69
|
suspenseId,
|
|
70
70
|
} from "./boundaries.js";
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
import {
|
|
72
|
+
ForbiddenError,
|
|
73
|
+
NotFoundError,
|
|
74
|
+
RedirectError,
|
|
75
|
+
UnauthorizedError,
|
|
76
|
+
hasClientPage,
|
|
77
|
+
matchIn,
|
|
78
|
+
matchRoute,
|
|
79
|
+
nearestBoundary,
|
|
80
|
+
parseSearch,
|
|
81
|
+
routeErrorStatus,
|
|
82
|
+
splitUrl,
|
|
83
|
+
} from "./routing.js";
|
|
84
|
+
import type {
|
|
85
|
+
ErrorBoundary as RoutingErrorBoundary,
|
|
86
|
+
LoadingRecord as RoutingLoadingRecord,
|
|
87
|
+
NotFoundBoundary as RoutingNotFoundBoundary,
|
|
88
|
+
RouteError,
|
|
89
|
+
RouteMatch as RoutingRouteMatch,
|
|
90
|
+
RouteParams,
|
|
91
|
+
RouteRecord as RoutingRouteRecord,
|
|
92
|
+
RouteTable as RoutingRouteTable,
|
|
93
|
+
SearchParams,
|
|
94
|
+
SlotRecord as RoutingSlotRecord,
|
|
95
|
+
SlotRouteRecord as RoutingSlotRouteRecord,
|
|
96
|
+
TemplateRecord as RoutingTemplateRecord,
|
|
97
|
+
} from "./routing.js";
|
|
98
|
+
|
|
99
|
+
export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
|
|
100
|
+
|
|
101
|
+
export {
|
|
102
|
+
ForbiddenError,
|
|
103
|
+
NotFoundError,
|
|
104
|
+
RedirectError,
|
|
105
|
+
UnauthorizedError,
|
|
106
|
+
buildRoute,
|
|
107
|
+
forbidden,
|
|
108
|
+
hasClientPage,
|
|
109
|
+
matchRoute,
|
|
110
|
+
notFound,
|
|
111
|
+
parseSearch,
|
|
112
|
+
permanentRedirect,
|
|
113
|
+
redirect,
|
|
114
|
+
routeErrorStatus,
|
|
115
|
+
splitUrl,
|
|
116
|
+
unauthorized,
|
|
117
|
+
} from "./routing.js";
|
|
80
118
|
|
|
81
119
|
/**
|
|
82
120
|
* A component found in a route module.
|
|
@@ -420,241 +458,34 @@ export type MetadataArgs = {|
|
|
|
420
458
|
readonly data: mixed,
|
|
421
459
|
|};
|
|
422
460
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* The page module — absent when this table cannot render the route.
|
|
431
|
-
*
|
|
432
|
-
* The server's table always has one: the server renders every route. The
|
|
433
|
-
* browser's may not. `@uniflowed/vite` leaves the page out of the client
|
|
434
|
-
* route table when uf's server-component analysis finds no `"use client"`
|
|
435
|
-
* boundary reachable from the page, its layouts or its fallbacks, and with
|
|
436
|
-
* the `import()` gone so is the whole subtree it reached — which is the
|
|
437
|
-
* point of leaving it out.
|
|
438
|
-
*
|
|
439
|
-
* The route stays in the table because the router still has to *match* the
|
|
440
|
-
* URL. Matching is what tells a `Link` that the destination is a document
|
|
441
|
-
* the browser must fetch rather than a page this bundle can render; a route
|
|
442
|
-
* missing from the table entirely would be a 404 instead. See
|
|
443
|
-
* [`hasClientPage`], which is the question every caller asks.
|
|
444
|
-
*/
|
|
445
|
-
readonly page?: () => Promise<PageModule>,
|
|
446
|
-
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
447
|
-
/**
|
|
448
|
-
* The `<Suspense>` boundaries this route renders inside, root first.
|
|
449
|
-
*
|
|
450
|
-
* Optional because a table written before `$loading.js` existed — a
|
|
451
|
-
* hand-written one in a test, a server bundle built by an older `uf` —
|
|
452
|
-
* is still a table this router can render, and a route with no boundary is
|
|
453
|
-
* exactly what it had before.
|
|
454
|
-
*/
|
|
455
|
-
readonly loading?: $ReadOnlyArray<LoadingRecord>,
|
|
456
|
-
/**
|
|
457
|
-
* The `$template.js` wrappers this route renders inside, root first.
|
|
458
|
-
*
|
|
459
|
-
* Optional for the reason `loading` is: a table written before templates
|
|
460
|
-
* existed is still a table this router can render, and a route with no
|
|
461
|
-
* template renders exactly the tree it did before.
|
|
462
|
-
*/
|
|
463
|
-
readonly templates?: $ReadOnlyArray<TemplateRecord>,
|
|
464
|
-
/**
|
|
465
|
-
* The parallel-route slots in scope on this route, outermost first.
|
|
466
|
-
*
|
|
467
|
-
* Optional for the reason `templates` is. A route with no slot renders
|
|
468
|
-
* exactly the tree it did before slots existed, which is most routes.
|
|
469
|
-
*/
|
|
470
|
-
readonly slots?: $ReadOnlyArray<SlotRecord>,
|
|
471
|
-
|};
|
|
461
|
+
export type RouteRecord = RoutingRouteRecord<
|
|
462
|
+
PageModule,
|
|
463
|
+
LayoutModule,
|
|
464
|
+
TemplateModule,
|
|
465
|
+
LoadingModule,
|
|
466
|
+
>;
|
|
472
467
|
|
|
473
|
-
|
|
474
|
-
* One parallel-route slot, as the route table carries it.
|
|
475
|
-
*
|
|
476
|
-
* A slot is a second thing a layout renders. `app/dashboard/@team/` gives
|
|
477
|
-
* `app/dashboard/$layout.js` a `team` prop beside `children`, and the slot's
|
|
478
|
-
* pages are matched against the same URL the page is: `/dashboard/members`
|
|
479
|
-
* renders `app/dashboard/members/$page.js` as `children` and
|
|
480
|
-
* `app/dashboard/@team/members/$page.js` as `team`, at once, each inside its
|
|
481
|
-
* own layouts.
|
|
482
|
-
*
|
|
483
|
-
* A slot never adds a URL — the directory contributes no path segment — so
|
|
484
|
-
* `routes` here is a second table matched against paths the main table already
|
|
485
|
-
* defines. That is uf's answer to the question Next.js answers with a
|
|
486
|
-
* `default.js` for `children`: there is no such thing, because `children` is
|
|
487
|
-
* the page the URL matched and a URL that matches no page is a 404.
|
|
488
|
-
*
|
|
489
|
-
* `above` is how many of the route's `layouts` are outside the slot, so
|
|
490
|
-
* `layouts[above - 1]` is the one that receives it — the same number, spelled
|
|
491
|
-
* the same way, as [`TemplateRecord`]'s and [`LoadingRecord`]'s.
|
|
492
|
-
*/
|
|
493
|
-
export type SlotRecord = {|
|
|
494
|
-
readonly name: string,
|
|
495
|
-
readonly above: number,
|
|
496
|
-
/**
|
|
497
|
-
* `$default.js`: what this slot renders when the URL matches none of its
|
|
498
|
-
* routes.
|
|
499
|
-
*
|
|
500
|
-
* `null` for a slot that declares none, and then the slot renders nothing at
|
|
501
|
-
* all. That is what an unaddressed slot does on a soft navigation in Next.js
|
|
502
|
-
* too, and it is the honest answer for a slot that only some URLs have
|
|
503
|
-
* something to put in — a modal, a detail pane.
|
|
504
|
-
*/
|
|
505
|
-
readonly defaultPage: ?() => Promise<PageModule>,
|
|
506
|
-
/** The default's source path, for diagnostics; absent when there is none. */
|
|
507
|
-
readonly defaultFile?: string,
|
|
508
|
-
/** Whether that default is MDX content. */
|
|
509
|
-
readonly defaultMdx?: boolean,
|
|
510
|
-
readonly routes: $ReadOnlyArray<SlotRouteRecord>,
|
|
511
|
-
|};
|
|
468
|
+
export type SlotRecord = RoutingSlotRecord<PageModule, LayoutModule>;
|
|
512
469
|
|
|
513
|
-
|
|
514
|
-
* One page inside a slot.
|
|
515
|
-
*
|
|
516
|
-
* A [`RouteRecord`] without the parts a slot does not have. No `loading` and no
|
|
517
|
-
* `templates`: those belong to the segment, and they already wrap the layout
|
|
518
|
-
* the slot renders into. Per-slot boundaries are the part of parallel routes uf
|
|
519
|
-
* has not built, and `@uniflowed/vite`'s scan refuses the files rather than
|
|
520
|
-
* leaving them unopened — see ubugeeei-prod/uf#267.
|
|
521
|
-
*
|
|
522
|
-
* `page` is required, unlike a `RouteRecord`'s: a slot route that ships no
|
|
523
|
-
* client page has no URL of its own to hand the browser, so there would be
|
|
524
|
-
* nothing to do with the entry. The client table drops the whole route when its
|
|
525
|
-
* page is dropped, slots and all.
|
|
526
|
-
*
|
|
527
|
-
* `layouts` are the layouts *inside* the slot, and `slots` are the slots a
|
|
528
|
-
* layout inside this one declares — the recursion is the feature rather than a
|
|
529
|
-
* special case.
|
|
530
|
-
*/
|
|
531
|
-
export type SlotRouteRecord = {|
|
|
532
|
-
readonly path: string,
|
|
533
|
-
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
534
|
-
readonly mdx: boolean,
|
|
535
|
-
readonly file: string,
|
|
536
|
-
readonly page: () => Promise<PageModule>,
|
|
537
|
-
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
538
|
-
readonly slots: $ReadOnlyArray<SlotRecord>,
|
|
539
|
-
|};
|
|
470
|
+
export type SlotRouteRecord = RoutingSlotRouteRecord<PageModule, LayoutModule>;
|
|
540
471
|
|
|
541
|
-
|
|
542
|
-
* One `$template.js`, as the route table carries it.
|
|
543
|
-
*
|
|
544
|
-
* The same shape as [`LoadingRecord`] and the same `above`, because it answers
|
|
545
|
-
* the same question — where in the stack of layouts this thing sits — and
|
|
546
|
-
* there is no second vocabulary for it.
|
|
547
|
-
*/
|
|
548
|
-
export type TemplateRecord = {|
|
|
549
|
-
readonly above: number,
|
|
550
|
-
readonly module: () => Promise<TemplateModule>,
|
|
551
|
-
|};
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* One `$loading.js`, as the route table carries it.
|
|
555
|
-
*
|
|
556
|
-
* `above` is how many of the route's `layouts` are outside the boundary, which
|
|
557
|
-
* is the same number `ResolvedRoute["errorBoundary"].above` means and is
|
|
558
|
-
* spelled the same way on purpose: both answer "where in the stack of layouts
|
|
559
|
-
* does this thing sit", and there is no second vocabulary for it.
|
|
560
|
-
*/
|
|
561
|
-
export type LoadingRecord = {|
|
|
562
|
-
readonly above: number,
|
|
563
|
-
readonly module: () => Promise<LoadingModule>,
|
|
564
|
-
|};
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* One not-found boundary: the page for a path under `path` that matched
|
|
568
|
-
* nothing.
|
|
569
|
-
*
|
|
570
|
-
* `$not-found.js` is a segment file, so `path` is the route path of the
|
|
571
|
-
* directory that declares it and `layouts` are the layouts in scope *there* —
|
|
572
|
-
* which is what the boundary renders inside. A project with one at the router
|
|
573
|
-
* root has one of these; a project whose manual answers its own 404 has two.
|
|
574
|
-
*/
|
|
575
|
-
export type NotFoundBoundary = {|
|
|
576
|
-
readonly path: string,
|
|
577
|
-
readonly mdx: boolean,
|
|
578
|
-
readonly file: string,
|
|
579
|
-
/**
|
|
580
|
-
* The page this boundary renders — `null` for the one the build synthesises
|
|
581
|
-
* at the router root when a project declares no `$not-found.js` there.
|
|
582
|
-
*
|
|
583
|
-
* A project that had declared none used to get `layouts: []` along with the
|
|
584
|
-
* framework's page: not the nearest-ancestor rule failing, but the fallback
|
|
585
|
-
* having no record to take layouts from. So the root's layouts are a record
|
|
586
|
-
* like any other, with the framework's component in place of a module to
|
|
587
|
-
* import — which is a nullable field rather than a second kind of answer, and
|
|
588
|
-
* is why an unmatched URL still arrives inside the site's own masthead. See
|
|
589
|
-
* ubugeeei-prod/uf#351.
|
|
590
|
-
*/
|
|
591
|
-
readonly page: ?() => Promise<PageModule>,
|
|
592
|
-
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
593
|
-
|};
|
|
472
|
+
export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
|
|
594
473
|
|
|
595
|
-
|
|
596
|
-
* One error boundary: what renders in place of the subtree under `path` when
|
|
597
|
-
* something in it throws.
|
|
598
|
-
*
|
|
599
|
-
* The same nearest-ancestor shape as [`NotFoundBoundary`], and `layouts` means
|
|
600
|
-
* the same thing — the layouts in scope where the file is, which stay mounted
|
|
601
|
-
* around the error and are why the rest of the document is still there.
|
|
602
|
-
*/
|
|
603
|
-
export type ErrorBoundary = {|
|
|
604
|
-
readonly path: string,
|
|
605
|
-
readonly file: string,
|
|
606
|
-
/** `null` for the synthesised root record; see [`NotFoundBoundary`]`.page`. */
|
|
607
|
-
readonly module: ?() => Promise<ErrorModule>,
|
|
608
|
-
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
609
|
-
|};
|
|
474
|
+
export type LoadingRecord = RoutingLoadingRecord<LoadingModule>;
|
|
610
475
|
|
|
611
|
-
|
|
612
|
-
* A route table plus the boundaries declared under it.
|
|
613
|
-
*
|
|
614
|
-
* `errors` is the error boundaries a project declared, not failures that
|
|
615
|
-
* happened.
|
|
616
|
-
*/
|
|
617
|
-
export type RouteTable = {|
|
|
618
|
-
readonly routes: $ReadOnlyArray<RouteRecord>,
|
|
619
|
-
readonly notFound: $ReadOnlyArray<NotFoundBoundary>,
|
|
620
|
-
readonly errors: $ReadOnlyArray<ErrorBoundary>,
|
|
621
|
-
|};
|
|
476
|
+
export type NotFoundBoundary = RoutingNotFoundBoundary<PageModule, LayoutModule>;
|
|
622
477
|
|
|
623
|
-
|
|
624
|
-
export type RouteMatch = {|
|
|
625
|
-
readonly route: RouteRecord,
|
|
626
|
-
readonly params: RouteParams,
|
|
627
|
-
|};
|
|
478
|
+
export type ErrorBoundary = RoutingErrorBoundary<ErrorModule, LayoutModule>;
|
|
628
479
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
* the other way — `$forbidden.js` and `$unauthorized.js` beside
|
|
637
|
-
* `$error.js`, which is what Next.js does — is three files per segment to
|
|
638
|
-
* express one thing, and nothing would check that any of them handled the
|
|
639
|
-
* case it was named for.
|
|
640
|
-
*
|
|
641
|
-
* The thrown value is carried but deliberately not rendered by the default
|
|
642
|
-
* boundary: a server exception's message is written for the person who
|
|
643
|
-
* deployed the application, not for whoever asks for the page.
|
|
644
|
-
*/
|
|
645
|
-
export type RouteError =
|
|
646
|
-
| {| readonly kind: "thrown", readonly error: mixed |}
|
|
647
|
-
| {| readonly kind: "unauthorized" |}
|
|
648
|
-
| {| readonly kind: "forbidden" |};
|
|
480
|
+
export type RouteTable = RoutingRouteTable<
|
|
481
|
+
PageModule,
|
|
482
|
+
LayoutModule,
|
|
483
|
+
TemplateModule,
|
|
484
|
+
LoadingModule,
|
|
485
|
+
ErrorModule,
|
|
486
|
+
>;
|
|
649
487
|
|
|
650
|
-
|
|
651
|
-
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
652
|
-
return match (error) {
|
|
653
|
-
{kind: "unauthorized"} => 401,
|
|
654
|
-
{kind: "forbidden"} => 403,
|
|
655
|
-
{kind: "thrown"} => 500,
|
|
656
|
-
};
|
|
657
|
-
}
|
|
488
|
+
export type RouteMatch = RoutingRouteMatch<RouteRecord>;
|
|
658
489
|
|
|
659
490
|
/**
|
|
660
491
|
* A match whose modules are loaded and whose loader has run or is running — or,
|
|
@@ -772,319 +603,6 @@ export type ResolvedSlot = {|
|
|
|
772
603
|
readonly slots: $ReadOnlyArray<ResolvedSlot>,
|
|
773
604
|
|};
|
|
774
605
|
|
|
775
|
-
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
776
|
-
export class NotFoundError extends Error {
|
|
777
|
-
constructor() {
|
|
778
|
-
super("not found");
|
|
779
|
-
this.name = "NotFoundError";
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
|
|
784
|
-
export class UnauthorizedError extends Error {
|
|
785
|
-
constructor() {
|
|
786
|
-
super("unauthorized");
|
|
787
|
-
this.name = "UnauthorizedError";
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
/** Thrown by `forbidden()`; the renderer answers with the error boundary. */
|
|
792
|
-
export class ForbiddenError extends Error {
|
|
793
|
-
constructor() {
|
|
794
|
-
super("forbidden");
|
|
795
|
-
this.name = "ForbiddenError";
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/** Thrown by `redirect()`; the renderer answers with a redirect. */
|
|
800
|
-
export class RedirectError extends Error {
|
|
801
|
-
to: string;
|
|
802
|
-
permanent: boolean;
|
|
803
|
-
|
|
804
|
-
constructor(to: string, permanent: boolean) {
|
|
805
|
-
super(`redirect to ${to}`);
|
|
806
|
-
this.name = "RedirectError";
|
|
807
|
-
this.to = to;
|
|
808
|
-
this.permanent = permanent;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// ---------------------------------------------------------------------------
|
|
813
|
-
// Matching
|
|
814
|
-
// ---------------------------------------------------------------------------
|
|
815
|
-
|
|
816
|
-
type Segment =
|
|
817
|
-
| {| readonly kind: "static", readonly value: string |}
|
|
818
|
-
| {| readonly kind: "param", readonly name: string |}
|
|
819
|
-
| {| readonly kind: "catchAll", readonly name: string |};
|
|
820
|
-
|
|
821
|
-
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
822
|
-
return routePath
|
|
823
|
-
.split("/")
|
|
824
|
-
.filter((segment) => segment !== "")
|
|
825
|
-
.map((segment): Segment => {
|
|
826
|
-
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
827
|
-
return { kind: "catchAll", name: segment.slice(1, -1) };
|
|
828
|
-
}
|
|
829
|
-
if (segment.startsWith(":")) {
|
|
830
|
-
return { kind: "param", name: segment.slice(1) };
|
|
831
|
-
}
|
|
832
|
-
return { kind: "static", value: segment };
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
/**
|
|
837
|
-
* How specific a route is, for ranking: a static segment outranks a parameter,
|
|
838
|
-
* which outranks a catch-all, and a longer path outranks a shorter one.
|
|
839
|
-
*/
|
|
840
|
-
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
841
|
-
let score = 0;
|
|
842
|
-
for (const segment of segments) {
|
|
843
|
-
score += match (segment) {
|
|
844
|
-
{kind: "static"} => 3,
|
|
845
|
-
{kind: "param"} => 2,
|
|
846
|
-
{kind: "catchAll"} => 1,
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
return score;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function matchSegments(
|
|
853
|
-
segments: $ReadOnlyArray<Segment>,
|
|
854
|
-
parts: $ReadOnlyArray<string>,
|
|
855
|
-
): ?RouteParams {
|
|
856
|
-
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
857
|
-
let index = 0;
|
|
858
|
-
for (const segment of segments) {
|
|
859
|
-
match (segment) {
|
|
860
|
-
{kind: "static", value: const value} => {
|
|
861
|
-
if (parts[index] !== value) {
|
|
862
|
-
return null;
|
|
863
|
-
}
|
|
864
|
-
index += 1;
|
|
865
|
-
}
|
|
866
|
-
{kind: "param", name: const name} => {
|
|
867
|
-
if (index >= parts.length) {
|
|
868
|
-
return null;
|
|
869
|
-
}
|
|
870
|
-
params[name] = decodeSegment(parts[index]);
|
|
871
|
-
index += 1;
|
|
872
|
-
}
|
|
873
|
-
{kind: "catchAll", name: const name} => {
|
|
874
|
-
params[name] = parts.slice(index).map(decodeSegment);
|
|
875
|
-
index = parts.length;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
return index === parts.length ? params : null;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
* The URL for a route pattern and the parameters it takes.
|
|
884
|
-
*
|
|
885
|
-
* The inverse of [`matchSegments`], and deliberately built out of the same
|
|
886
|
-
* [`compile`]: a builder that parsed patterns its own way would drift from the
|
|
887
|
-
* matcher, and the drift would show up as a link that 404s rather than as a
|
|
888
|
-
* failure anybody could see.
|
|
889
|
-
*
|
|
890
|
-
* The generated `router.js` is what a project calls — `route("/posts/:slug",
|
|
891
|
-
* { slug })` — and it is typed there, so the parameters are checked before this
|
|
892
|
-
* runs. This still refuses a bad call rather than building a wrong URL,
|
|
893
|
-
* because the types are only in front of the callers that have them: a value
|
|
894
|
-
* that arrived from JSON, or from a module that opted out of Flow, reaches
|
|
895
|
-
* here unchecked. A link to `/posts/undefined` is the failure this exists to
|
|
896
|
-
* turn into an error with a name on it.
|
|
897
|
-
*
|
|
898
|
-
* Each segment is `encodeURIComponent`d, which is what [`decodeSegment`]
|
|
899
|
-
* undoes on the way back — so a slug with a slash in it round-trips as one
|
|
900
|
-
* segment rather than becoming two.
|
|
901
|
-
*/
|
|
902
|
-
export function buildRoute(routePath: string, params?: RouteParams): string {
|
|
903
|
-
const values: RouteParams = params ?? {};
|
|
904
|
-
const parts: Array<string> = [];
|
|
905
|
-
for (const segment of compile(routePath)) {
|
|
906
|
-
match (segment) {
|
|
907
|
-
{kind: "static", value: const value} => {
|
|
908
|
-
parts.push(value);
|
|
909
|
-
}
|
|
910
|
-
{kind: "param", name: const name} => {
|
|
911
|
-
const value = values[name];
|
|
912
|
-
if (typeof value !== "string") {
|
|
913
|
-
throw new Error(
|
|
914
|
-
`route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
|
-
parts.push(encodeURIComponent(value));
|
|
918
|
-
}
|
|
919
|
-
{kind: "catchAll", name: const name} => {
|
|
920
|
-
const value = values[name];
|
|
921
|
-
if (value == null || typeof value === "string") {
|
|
922
|
-
throw new Error(
|
|
923
|
-
`route ${routePath} takes an array of segments for :${name}*, and got ` +
|
|
924
|
-
describeParam(value),
|
|
925
|
-
);
|
|
926
|
-
}
|
|
927
|
-
for (const part of value) {
|
|
928
|
-
parts.push(encodeURIComponent(part));
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
return parts.length === 0 ? "/" : `/${parts.join("/")}`;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/** What a parameter was, for the message that says it was the wrong thing. */
|
|
937
|
-
function describeParam(value: string | $ReadOnlyArray<string> | void): string {
|
|
938
|
-
if (value === undefined) {
|
|
939
|
-
return "nothing";
|
|
940
|
-
}
|
|
941
|
-
return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function decodeSegment(segment: string): string {
|
|
945
|
-
try {
|
|
946
|
-
return decodeURIComponent(segment);
|
|
947
|
-
} catch {
|
|
948
|
-
return segment;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/**
|
|
953
|
-
* Whether this table can render the route in the browser.
|
|
954
|
-
*
|
|
955
|
-
* False only in the client bundle, and only for a route uf decided ships no
|
|
956
|
-
* JavaScript. Every caller that would load a page asks this first, and the two
|
|
957
|
-
* answers are different actions rather than a success and a failure: render
|
|
958
|
-
* it, or let the browser fetch the document.
|
|
959
|
-
*/
|
|
960
|
-
export function hasClientPage(route: RouteRecord): boolean {
|
|
961
|
-
return route.page != null;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/**
|
|
965
|
-
* Match a pathname against the table, preferring the most specific route.
|
|
966
|
-
*/
|
|
967
|
-
export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string): ?RouteMatch {
|
|
968
|
-
return matchIn(routes, pathname);
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* The same match, over anything that has a route path.
|
|
973
|
-
*
|
|
974
|
-
* A slot is a second table matched against the same URL — see [`SlotRecord`] —
|
|
975
|
-
* and it has to be matched by *this* function rather than by one of its own:
|
|
976
|
-
* two matchers would be two answers to "which of these paths does this URL
|
|
977
|
-
* name", and the one that disagreed would show up as a slot holding somebody
|
|
978
|
-
* else's page. The generic is only about the record type; the ranking, the
|
|
979
|
-
* parameters and the tie-break are the route table's.
|
|
980
|
-
*/
|
|
981
|
-
function matchIn<TRecord: { +path: string, ... }>(
|
|
982
|
-
routes: $ReadOnlyArray<TRecord>,
|
|
983
|
-
pathname: string,
|
|
984
|
-
): ?{| readonly route: TRecord, readonly params: RouteParams |} {
|
|
985
|
-
const parts = pathname.split("/").filter((part) => part !== "");
|
|
986
|
-
let best: ?{| readonly route: TRecord, readonly params: RouteParams |} = null;
|
|
987
|
-
let bestScore = -1;
|
|
988
|
-
for (const route of routes) {
|
|
989
|
-
const segments = compile(route.path);
|
|
990
|
-
const params = matchSegments(segments, parts);
|
|
991
|
-
if (params == null) {
|
|
992
|
-
continue;
|
|
993
|
-
}
|
|
994
|
-
const score = specificity(segments);
|
|
995
|
-
if (score > bestScore) {
|
|
996
|
-
best = { route, params };
|
|
997
|
-
bestScore = score;
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
return best;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
/**
|
|
1004
|
-
* Whether a boundary declared at `segments` is at or above `parts`.
|
|
1005
|
-
*
|
|
1006
|
-
* The same segment kinds as [`matchSegments`], stopping when the boundary's
|
|
1007
|
-
* own segments run out instead of requiring the path to: `/guide` covers
|
|
1008
|
-
* `/guide/nope`, and `/guide` covers `/guide` itself.
|
|
1009
|
-
*/
|
|
1010
|
-
function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
|
|
1011
|
-
let index = 0;
|
|
1012
|
-
for (const segment of segments) {
|
|
1013
|
-
const next = match (segment) {
|
|
1014
|
-
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
1015
|
-
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
1016
|
-
{kind: "catchAll"} => parts.length,
|
|
1017
|
-
};
|
|
1018
|
-
if (next === -1) {
|
|
1019
|
-
return false;
|
|
1020
|
-
}
|
|
1021
|
-
index = next;
|
|
1022
|
-
}
|
|
1023
|
-
return true;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* The nearest boundary above `pathname`, or `null` when none covers it.
|
|
1028
|
-
*
|
|
1029
|
-
* The one rule both `$not-found.js` and `$error.js` are resolved by, and
|
|
1030
|
-
* the same one layouts already follow: nearest means the longest path that
|
|
1031
|
-
* covers the URL. It is decided here rather than by the table's order — the
|
|
1032
|
-
* table is sorted by path so the generated module is stable, and a resolver
|
|
1033
|
-
* that read "nearest" as "first" would silently depend on that sort. Two
|
|
1034
|
-
* boundaries can share a path (a route group's directory does not appear in
|
|
1035
|
-
* the URL), and then the first in the table wins.
|
|
1036
|
-
*/
|
|
1037
|
-
function nearestBoundary<TBoundary: { readonly path: string, ... }>(
|
|
1038
|
-
boundaries: $ReadOnlyArray<TBoundary>,
|
|
1039
|
-
pathname: string,
|
|
1040
|
-
): ?TBoundary {
|
|
1041
|
-
const parts = pathname.split("/").filter((part) => part !== "");
|
|
1042
|
-
let best: ?TBoundary = null;
|
|
1043
|
-
let bestDepth = -1;
|
|
1044
|
-
for (const boundary of boundaries) {
|
|
1045
|
-
const segments = compile(boundary.path);
|
|
1046
|
-
if (!covers(segments, parts)) {
|
|
1047
|
-
continue;
|
|
1048
|
-
}
|
|
1049
|
-
if (segments.length > bestDepth) {
|
|
1050
|
-
best = boundary;
|
|
1051
|
-
bestDepth = segments.length;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
return best;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
/** Split a URL into its pathname and search string. */
|
|
1058
|
-
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
1059
|
-
const hash = url.indexOf("#");
|
|
1060
|
-
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
1061
|
-
const question = withoutHash.indexOf("?");
|
|
1062
|
-
if (question === -1) {
|
|
1063
|
-
return { pathname: normalizePathname(withoutHash), search: "" };
|
|
1064
|
-
}
|
|
1065
|
-
return {
|
|
1066
|
-
pathname: normalizePathname(withoutHash.slice(0, question)),
|
|
1067
|
-
search: withoutHash.slice(question),
|
|
1068
|
-
};
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
function normalizePathname(pathname: string): string {
|
|
1072
|
-
if (pathname === "" || pathname === "/") {
|
|
1073
|
-
return "/";
|
|
1074
|
-
}
|
|
1075
|
-
const trimmed = pathname.replace(/\/+$/, "");
|
|
1076
|
-
return trimmed === "" ? "/" : trimmed;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/** Parse a search string into a flat map; a repeated key keeps its last value. */
|
|
1080
|
-
export function parseSearch(search: string): SearchParams {
|
|
1081
|
-
const params: { [string]: string } = {};
|
|
1082
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
1083
|
-
params[key] = value;
|
|
1084
|
-
}
|
|
1085
|
-
return params;
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
606
|
// ---------------------------------------------------------------------------
|
|
1089
607
|
// Loading
|
|
1090
608
|
// ---------------------------------------------------------------------------
|
|
@@ -2691,6 +2209,9 @@ export component RouteView() {
|
|
|
2691
2209
|
);
|
|
2692
2210
|
}
|
|
2693
2211
|
}
|
|
2212
|
+
if (needsRootStreamFrame(resolved)) {
|
|
2213
|
+
element = <RootStreamFrame>{element}</RootStreamFrame>;
|
|
2214
|
+
}
|
|
2694
2215
|
return (
|
|
2695
2216
|
<>
|
|
2696
2217
|
<Head metadata={resolved.metadata} />
|
|
@@ -2706,6 +2227,28 @@ export component RouteView() {
|
|
|
2706
2227
|
);
|
|
2707
2228
|
}
|
|
2708
2229
|
|
|
2230
|
+
/**
|
|
2231
|
+
* Whether the outermost route fallback needs one host element above it.
|
|
2232
|
+
*
|
|
2233
|
+
* React can flush a shell whose suspended boundary is inside any host element,
|
|
2234
|
+
* but not one whose boundary is a direct child of the render root. A route with
|
|
2235
|
+
* no layout and a root `$loading.js` is exactly that second tree: every
|
|
2236
|
+
* framework component above it renders no element, so the fallback waits for
|
|
2237
|
+
* the page it was meant to stand in for. A root layout is already the element
|
|
2238
|
+
* that can carry it, and deeper fallbacks sit inside a layout by construction.
|
|
2239
|
+
*/
|
|
2240
|
+
function needsRootStreamFrame(resolved: ResolvedRoute): boolean {
|
|
2241
|
+
return resolved.layouts.length === 0 && resolved.loading.some((boundary) => boundary.above === 0);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
component RootStreamFrame(children: React.Node) {
|
|
2245
|
+
return (
|
|
2246
|
+
<div data-uf-stream-root="" style={{ display: "contents" }}>
|
|
2247
|
+
{children}
|
|
2248
|
+
</div>
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2709
2252
|
/**
|
|
2710
2253
|
* The page, with the loader's answer and the copy of it the browser hydrates
|
|
2711
2254
|
* from.
|
|
@@ -2814,10 +2357,8 @@ component AwaitedPage(loader: Promise<mixed>) {
|
|
|
2814
2357
|
* rows rather than one each — the boundaries inside it still resolve
|
|
2815
2358
|
* independently, since each is its own.
|
|
2816
2359
|
*
|
|
2817
|
-
* The same rule catches a route whose `$loading.js` sits above no layout
|
|
2818
|
-
* `RouteView`
|
|
2819
|
-
* either. That is a bug this file did not introduce and does not fix; it is
|
|
2820
|
-
* written down in ubugeeei-prod/uf#519 rather than left to be rediscovered.
|
|
2360
|
+
* The same rule catches a route whose `$loading.js` sits above no layout, so
|
|
2361
|
+
* `RouteView` wraps that specific root shape in `RootStreamFrame`.
|
|
2821
2362
|
*/
|
|
2822
2363
|
function payloadElements(data: mixed): React.Node {
|
|
2823
2364
|
if (data === undefined) {
|
|
@@ -3506,31 +3047,6 @@ export function routerView(root: string): React.ComponentType<AppProps> {
|
|
|
3506
3047
|
return App;
|
|
3507
3048
|
}
|
|
3508
3049
|
|
|
3509
|
-
/** Stop rendering the current page and show the not-found page instead. */
|
|
3510
|
-
export function notFound(): empty {
|
|
3511
|
-
throw new NotFoundError();
|
|
3512
|
-
}
|
|
3513
|
-
|
|
3514
|
-
/** Stop rendering the current page and show the error boundary, as a 401. */
|
|
3515
|
-
export function unauthorized(): empty {
|
|
3516
|
-
throw new UnauthorizedError();
|
|
3517
|
-
}
|
|
3518
|
-
|
|
3519
|
-
/** Stop rendering the current page and show the error boundary, as a 403. */
|
|
3520
|
-
export function forbidden(): empty {
|
|
3521
|
-
throw new ForbiddenError();
|
|
3522
|
-
}
|
|
3523
|
-
|
|
3524
|
-
/** Stop rendering the current page and send the visitor elsewhere. */
|
|
3525
|
-
export function redirect(to: string): empty {
|
|
3526
|
-
throw new RedirectError(to, false);
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
|
-
/** `redirect`, with a permanent status. */
|
|
3530
|
-
export function permanentRedirect(to: string): empty {
|
|
3531
|
-
throw new RedirectError(to, true);
|
|
3532
|
-
}
|
|
3533
|
-
|
|
3534
3050
|
/**
|
|
3535
3051
|
* Whether the app is being rendered on the server.
|
|
3536
3052
|
*
|
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.30",
|
|
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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./action": "./action.js",
|
|
16
16
|
"./client": "./client.js",
|
|
17
17
|
"./server": "./server.js",
|
|
18
|
+
"./routing": "./routing.js",
|
|
18
19
|
"./package.json": "./package.json",
|
|
19
20
|
"./handler": "./handler.js",
|
|
20
21
|
"./middleware": "./middleware.js"
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
"index.js",
|
|
27
28
|
"internal",
|
|
28
29
|
"middleware.js",
|
|
30
|
+
"routing.js",
|
|
29
31
|
"server.js",
|
|
30
32
|
"!*.test.js"
|
|
31
33
|
],
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
"react-dom": ">=19"
|
|
35
37
|
},
|
|
36
38
|
"dependencies": {
|
|
37
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
38
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
39
|
+
"@uniflowed/hooks": "0.0.0-alpha.30",
|
|
40
|
+
"@uniflowed/server": "0.0.0-alpha.30"
|
|
39
41
|
}
|
|
40
42
|
}
|
package/routing.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/routing`: React-free route table helpers.
|
|
4
|
+
//
|
|
5
|
+
// Import this from server-oriented modules that need matching, URL building,
|
|
6
|
+
// or router control errors without importing the client router, React
|
|
7
|
+
// components, or hooks.
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
ErrorBoundary,
|
|
11
|
+
LoadingRecord,
|
|
12
|
+
NotFoundBoundary,
|
|
13
|
+
RouteError,
|
|
14
|
+
RouteMatch,
|
|
15
|
+
RouteModule,
|
|
16
|
+
RouteParamSpec,
|
|
17
|
+
RouteParams,
|
|
18
|
+
RouteRecord,
|
|
19
|
+
RouteTable,
|
|
20
|
+
SearchParams,
|
|
21
|
+
SlotRecord,
|
|
22
|
+
SlotRouteRecord,
|
|
23
|
+
TemplateRecord,
|
|
24
|
+
} from "./internal/routing.js";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
ForbiddenError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RedirectError,
|
|
30
|
+
UnauthorizedError,
|
|
31
|
+
buildRoute,
|
|
32
|
+
forbidden,
|
|
33
|
+
hasClientPage,
|
|
34
|
+
matchRoute,
|
|
35
|
+
notFound,
|
|
36
|
+
parseSearch,
|
|
37
|
+
permanentRedirect,
|
|
38
|
+
redirect,
|
|
39
|
+
routeErrorStatus,
|
|
40
|
+
splitUrl,
|
|
41
|
+
unauthorized,
|
|
42
|
+
} from "./internal/routing.js";
|