@uniflowed/router 0.0.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1597 -1329
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -106
@@ -0,0 +1,199 @@
1
+ // @flow
2
+ //
3
+ // Module-free summaries of resolved routes.
4
+
5
+ import { routeBoundaries, suspenseId } from "./boundary-data.js";
6
+ import type { RouteBoundary } from "./boundary-data.js";
7
+ import type { RouteParams, SearchParams } from "./routing.js";
8
+
9
+ type RouteStatus = 200 | 401 | 403 | 404 | 500;
10
+
11
+ type RouteMetadata = { readonly [string]: mixed, ... };
12
+
13
+ type RouteErrorLike = {
14
+ readonly kind: "thrown" | "unauthorized" | "forbidden",
15
+ ...
16
+ };
17
+
18
+ type BoundaryLike = {
19
+ readonly above: number,
20
+ ...
21
+ };
22
+
23
+ type ModuleBoundaryLike = {
24
+ readonly above: number,
25
+ readonly module?: mixed,
26
+ ...
27
+ };
28
+
29
+ type SlotLike = {
30
+ readonly name: string,
31
+ readonly above: number,
32
+ readonly page: mixed,
33
+ readonly params: RouteParams,
34
+ readonly layouts: $ReadOnlyArray<mixed>,
35
+ readonly loading: $ReadOnlyArray<BoundaryLike>,
36
+ readonly templates: $ReadOnlyArray<BoundaryLike>,
37
+ readonly errorBoundary: ?ModuleBoundaryLike,
38
+ readonly slots: $ReadOnlyArray<SlotLike>,
39
+ ...
40
+ };
41
+
42
+ type RouteLike = {
43
+ readonly pathname: string,
44
+ readonly search: string,
45
+ readonly path: string,
46
+ readonly params: RouteParams,
47
+ readonly searchParams: SearchParams,
48
+ readonly layouts: $ReadOnlyArray<mixed>,
49
+ readonly data: mixed,
50
+ readonly deferred: mixed,
51
+ readonly metadata: RouteMetadata,
52
+ readonly viewTransition: ?string,
53
+ readonly status: RouteStatus,
54
+ readonly error: ?RouteErrorLike,
55
+ readonly errorBoundary: ModuleBoundaryLike,
56
+ readonly loading: $ReadOnlyArray<BoundaryLike>,
57
+ readonly templates: $ReadOnlyArray<BoundaryLike>,
58
+ readonly slots: $ReadOnlyArray<SlotLike>,
59
+ ...
60
+ };
61
+
62
+ export type ResolvedBoundarySummary = {|
63
+ readonly id: string,
64
+ readonly above: number,
65
+ |};
66
+
67
+ export type ResolvedTemplateSummary = {|
68
+ readonly above: number,
69
+ |};
70
+
71
+ export type ResolvedRouteErrorSummary =
72
+ | {| readonly kind: "thrown" |}
73
+ | {| readonly kind: "unauthorized" |}
74
+ | {| readonly kind: "forbidden" |};
75
+
76
+ export type ResolvedErrorBoundarySummary = {|
77
+ readonly above: number,
78
+ readonly custom: boolean,
79
+ readonly rendered: boolean,
80
+ |};
81
+
82
+ export type ResolvedSlotSummary = {|
83
+ readonly name: string,
84
+ readonly above: number,
85
+ readonly active: boolean,
86
+ readonly params: RouteParams,
87
+ readonly layoutCount: number,
88
+ readonly loading: $ReadOnlyArray<ResolvedBoundarySummary>,
89
+ readonly templates: $ReadOnlyArray<ResolvedTemplateSummary>,
90
+ readonly errorBoundary: ?ResolvedErrorBoundarySummary,
91
+ readonly slots: $ReadOnlyArray<ResolvedSlotSummary>,
92
+ |};
93
+
94
+ export type ResolvedRouteSummary = {|
95
+ readonly pathname: string,
96
+ readonly search: string,
97
+ readonly path: string,
98
+ readonly params: RouteParams,
99
+ readonly searchParams: SearchParams,
100
+ readonly layoutCount: number,
101
+ readonly data: mixed,
102
+ readonly deferred: boolean,
103
+ readonly metadata: RouteMetadata,
104
+ readonly viewTransition: ?string,
105
+ readonly status: RouteStatus,
106
+ readonly error: ?ResolvedRouteErrorSummary,
107
+ readonly errorBoundary: ResolvedErrorBoundarySummary,
108
+ readonly loading: $ReadOnlyArray<ResolvedBoundarySummary>,
109
+ readonly templates: $ReadOnlyArray<ResolvedTemplateSummary>,
110
+ readonly slots: $ReadOnlyArray<ResolvedSlotSummary>,
111
+ readonly boundaries: $ReadOnlyArray<RouteBoundary>,
112
+ |};
113
+
114
+ /**
115
+ * The part of a `ResolvedRoute` that can cross to a payload or diagnostic.
116
+ *
117
+ * A resolved route carries loaded modules so `RouteView` can render today.
118
+ * The element payload in ubugeeei-prod/uf#519 needs the other half: route
119
+ * identity, data, metadata, status, and boundary names, with page/layout/
120
+ * fallback modules left behind in the renderer graph.
121
+ */
122
+ export function summarizeResolvedRoute(
123
+ route: RouteLike,
124
+ errorSource?: ?string,
125
+ ): ResolvedRouteSummary {
126
+ return {
127
+ pathname: route.pathname,
128
+ search: route.search,
129
+ path: route.path,
130
+ params: route.params,
131
+ searchParams: route.searchParams,
132
+ layoutCount: route.layouts.length,
133
+ data: route.data,
134
+ deferred: route.deferred != null,
135
+ metadata: route.metadata,
136
+ viewTransition: route.viewTransition,
137
+ status: route.status,
138
+ error: summarizeError(route.error),
139
+ errorBoundary: summarizeErrorBoundary(route.errorBoundary, route.error == null),
140
+ loading: summarizeLoading(route.loading),
141
+ templates: summarizeTemplates(route.templates),
142
+ slots: route.slots.map(summarizeSlot),
143
+ boundaries: [...routeBoundaries(route, errorSource).values()],
144
+ };
145
+ }
146
+
147
+ function summarizeSlot(slot: SlotLike): ResolvedSlotSummary {
148
+ return {
149
+ name: slot.name,
150
+ above: slot.above,
151
+ active: slot.page != null,
152
+ params: slot.params,
153
+ layoutCount: slot.layouts.length,
154
+ loading: summarizeLoading(slot.loading),
155
+ templates: summarizeTemplates(slot.templates),
156
+ errorBoundary:
157
+ slot.errorBoundary == null ? null : summarizeErrorBoundary(slot.errorBoundary, true),
158
+ slots: slot.slots.map(summarizeSlot),
159
+ };
160
+ }
161
+
162
+ function summarizeLoading(
163
+ boundaries: $ReadOnlyArray<BoundaryLike>,
164
+ ): $ReadOnlyArray<ResolvedBoundarySummary> {
165
+ return boundaries.map((boundary, index) => ({
166
+ id: suspenseId(index),
167
+ above: boundary.above,
168
+ }));
169
+ }
170
+
171
+ function summarizeTemplates(
172
+ templates: $ReadOnlyArray<BoundaryLike>,
173
+ ): $ReadOnlyArray<ResolvedTemplateSummary> {
174
+ return templates.map((template) => ({ above: template.above }));
175
+ }
176
+
177
+ function summarizeErrorBoundary(
178
+ boundary: ModuleBoundaryLike,
179
+ rendered: boolean,
180
+ ): ResolvedErrorBoundarySummary {
181
+ return {
182
+ above: boundary.above,
183
+ custom: boundary.module != null,
184
+ rendered,
185
+ };
186
+ }
187
+
188
+ function summarizeError(error: ?RouteErrorLike): ?ResolvedRouteErrorSummary {
189
+ if (error == null) {
190
+ return null;
191
+ }
192
+ if (error.kind === "unauthorized") {
193
+ return { kind: "unauthorized" };
194
+ }
195
+ if (error.kind === "forbidden") {
196
+ return { kind: "forbidden" };
197
+ }
198
+ return { kind: "thrown" };
199
+ }
@@ -0,0 +1,478 @@
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 `$error.js` inside a slot, as the route table carries it. */
35
+ export type SlotErrorBoundaryRecord<TError = mixed> = {|
36
+ readonly above: number,
37
+ readonly module: RouteModule<TError>,
38
+ |};
39
+
40
+ /** One parallel-route slot, as the route table carries it. */
41
+ export type SlotRecord<
42
+ TPage = mixed,
43
+ TLayout = mixed,
44
+ TTemplate = mixed,
45
+ TLoading = mixed,
46
+ TError = mixed,
47
+ > = {|
48
+ readonly name: string,
49
+ readonly above: number,
50
+ readonly defaultPage: ?RouteModule<TPage>,
51
+ readonly defaultFile?: string,
52
+ readonly defaultMdx?: boolean,
53
+ readonly defaultErrorBoundary?: ?SlotErrorBoundaryRecord<TError>,
54
+ readonly routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
55
+ /**
56
+ * The routes this slot renders when a client navigation *intercepts* the URL
57
+ * each one names — `app/feed/@modal/(.)photo/[id]/$page.js` is the one for
58
+ * `/feed/photo/:id` — keyed by that URL.
59
+ *
60
+ * A second list rather than a flag on `routes`, because the two are read by
61
+ * different callers at different times and neither may see the other's.
62
+ * `routes` is matched against every URL the segment renders, by the server and
63
+ * by the browser alike. These are matched only by a navigation that starts on
64
+ * a page this slot is already rendered on, and nothing on a server reads them
65
+ * — which is the whole of why a document request for an intercepted URL
66
+ * renders the ordinary page. `resolveInterception`, beside the rest of the
67
+ * rendering, is the one reader.
68
+ *
69
+ * Absent on a slot that intercepts nothing, which is every slot a table
70
+ * written before interception existed.
71
+ */
72
+ readonly intercepts?: $ReadOnlyArray<
73
+ SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>,
74
+ >,
75
+ |};
76
+
77
+ /** One page inside a slot. */
78
+ export type SlotRouteRecord<
79
+ TPage = mixed,
80
+ TLayout = mixed,
81
+ TTemplate = mixed,
82
+ TLoading = mixed,
83
+ TError = mixed,
84
+ > = {|
85
+ readonly path: string,
86
+ readonly params: $ReadOnlyArray<RouteParamSpec>,
87
+ readonly mdx: boolean,
88
+ readonly file: string,
89
+ readonly page: RouteModule<TPage>,
90
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
91
+ readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
92
+ readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
93
+ readonly errorBoundary?: ?SlotErrorBoundaryRecord<TError>,
94
+ readonly slots: $ReadOnlyArray<SlotRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
95
+ |};
96
+
97
+ /** One entry of the generated route table. */
98
+ export type RouteRecord<
99
+ TPage = mixed,
100
+ TLayout = mixed,
101
+ TTemplate = mixed,
102
+ TLoading = mixed,
103
+ TError = mixed,
104
+ > = {|
105
+ readonly path: string,
106
+ readonly params: $ReadOnlyArray<RouteParamSpec>,
107
+ readonly mdx: boolean,
108
+ readonly file: string,
109
+ readonly page?: RouteModule<TPage>,
110
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
111
+ readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
112
+ readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
113
+ readonly slots?: $ReadOnlyArray<SlotRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
114
+ |};
115
+
116
+ /** One not-found boundary: the page for a path under `path` that matched nothing. */
117
+ export type NotFoundBoundary<TPage = mixed, TLayout = mixed> = {|
118
+ readonly path: string,
119
+ readonly mdx: boolean,
120
+ readonly file: string,
121
+ readonly page: ?RouteModule<TPage>,
122
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
123
+ |};
124
+
125
+ /** One error boundary: what renders in place of a subtree that threw. */
126
+ export type ErrorBoundary<TError = mixed, TLayout = mixed> = {|
127
+ readonly path: string,
128
+ readonly file: string,
129
+ readonly module: ?RouteModule<TError>,
130
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
131
+ |};
132
+
133
+ /** A route table plus the boundaries declared under it. */
134
+ export type RouteTable<
135
+ TPage = mixed,
136
+ TLayout = mixed,
137
+ TTemplate = mixed,
138
+ TLoading = mixed,
139
+ TError = mixed,
140
+ > = {|
141
+ readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
142
+ readonly nativeLinks?: {|
143
+ readonly origins: $ReadOnlyArray<string>,
144
+ readonly routes: $ReadOnlyArray<string>,
145
+ |},
146
+ readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
147
+ readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
148
+ |};
149
+
150
+ type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed, mixed>;
151
+
152
+ /** A URL matched against a table. */
153
+ export type RouteMatch<TRoute: { +path: string, ... } = UnknownRouteRecord> = {|
154
+ readonly route: TRoute,
155
+ readonly params: RouteParams,
156
+ |};
157
+
158
+ /**
159
+ * Why the router is rendering an error boundary instead of a page.
160
+ *
161
+ * One union rather than one file convention per status. `forbidden()` and
162
+ * `unauthorized()` are not different *kinds* of file to write; they are
163
+ * different sentences an error page says.
164
+ */
165
+ export type RouteError =
166
+ | {| readonly kind: "thrown", readonly error: mixed |}
167
+ | {| readonly kind: "unauthorized" |}
168
+ | {| readonly kind: "forbidden" |};
169
+
170
+ /** The status a `RouteError` answers with. */
171
+ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
172
+ return match (error) {
173
+ {kind: "unauthorized"} => 401,
174
+ {kind: "forbidden"} => 403,
175
+ {kind: "thrown"} => 500,
176
+ };
177
+ }
178
+
179
+ /** Thrown by `notFound()`; the renderer answers with the not-found page. */
180
+ export class NotFoundError extends Error {
181
+ constructor() {
182
+ super("not found");
183
+ this.name = "NotFoundError";
184
+ }
185
+ }
186
+
187
+ /** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
188
+ export class UnauthorizedError extends Error {
189
+ constructor() {
190
+ super("unauthorized");
191
+ this.name = "UnauthorizedError";
192
+ }
193
+ }
194
+
195
+ /** Thrown by `forbidden()`; the renderer answers with the error boundary. */
196
+ export class ForbiddenError extends Error {
197
+ constructor() {
198
+ super("forbidden");
199
+ this.name = "ForbiddenError";
200
+ }
201
+ }
202
+
203
+ /** Thrown by `redirect()`; the renderer answers with a redirect. */
204
+ export class RedirectError extends Error {
205
+ to: string;
206
+ permanent: boolean;
207
+
208
+ constructor(to: string, permanent: boolean) {
209
+ super(`redirect to ${to}`);
210
+ this.name = "RedirectError";
211
+ this.to = to;
212
+ this.permanent = permanent;
213
+ }
214
+ }
215
+
216
+ type Segment =
217
+ | {| readonly kind: "static", readonly value: string |}
218
+ | {| readonly kind: "param", readonly name: string |}
219
+ | {| readonly kind: "catchAll", readonly name: string |};
220
+
221
+ function compile(routePath: string): $ReadOnlyArray<Segment> {
222
+ return routePath
223
+ .split("/")
224
+ .filter((segment) => segment !== "")
225
+ .map((segment): Segment => {
226
+ if (segment.startsWith(":") && segment.endsWith("*")) {
227
+ return { kind: "catchAll", name: segment.slice(1, -1) };
228
+ }
229
+ if (segment.startsWith(":")) {
230
+ return { kind: "param", name: segment.slice(1) };
231
+ }
232
+ return { kind: "static", value: segment };
233
+ });
234
+ }
235
+
236
+ /**
237
+ * How specific a route is, for ranking: a static segment outranks a parameter,
238
+ * which outranks a catch-all, and a longer path outranks a shorter one.
239
+ */
240
+ function specificity(segments: $ReadOnlyArray<Segment>): number {
241
+ let score = 0;
242
+ for (const segment of segments) {
243
+ score += match (segment) {
244
+ {kind: "static"} => 3,
245
+ {kind: "param"} => 2,
246
+ {kind: "catchAll"} => 1,
247
+ };
248
+ }
249
+ return score;
250
+ }
251
+
252
+ function matchSegments(
253
+ segments: $ReadOnlyArray<Segment>,
254
+ parts: $ReadOnlyArray<string>,
255
+ ): ?RouteParams {
256
+ const params: { [string]: string | $ReadOnlyArray<string> } = {};
257
+ let index = 0;
258
+ for (const segment of segments) {
259
+ match (segment) {
260
+ {kind: "static", value: const value} => {
261
+ if (parts[index] !== value) {
262
+ return null;
263
+ }
264
+ index += 1;
265
+ }
266
+ {kind: "param", name: const name} => {
267
+ if (index >= parts.length) {
268
+ return null;
269
+ }
270
+ params[name] = decodeSegment(parts[index]);
271
+ index += 1;
272
+ }
273
+ {kind: "catchAll", name: const name} => {
274
+ params[name] = parts.slice(index).map(decodeSegment);
275
+ index = parts.length;
276
+ }
277
+ }
278
+ }
279
+ return index === parts.length ? params : null;
280
+ }
281
+
282
+ /**
283
+ * The URL for a route pattern and the parameters it takes.
284
+ *
285
+ * The inverse of [`matchSegments`], and deliberately built out of the same
286
+ * [`compile`]: a builder that parsed patterns its own way would drift from the
287
+ * matcher, and the drift would show up as a link that 404s rather than as a
288
+ * failure anybody could see.
289
+ */
290
+ export function buildRoute(routePath: string, params?: RouteParams): string {
291
+ const values: RouteParams = params ?? {};
292
+ const parts: Array<string> = [];
293
+ for (const segment of compile(routePath)) {
294
+ match (segment) {
295
+ {kind: "static", value: const value} => {
296
+ parts.push(value);
297
+ }
298
+ {kind: "param", name: const name} => {
299
+ const value = values[name];
300
+ if (typeof value !== "string") {
301
+ throw new Error(
302
+ `route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
303
+ );
304
+ }
305
+ parts.push(encodeURIComponent(value));
306
+ }
307
+ {kind: "catchAll", name: const name} => {
308
+ const value = values[name];
309
+ if (value == null || typeof value === "string") {
310
+ throw new Error(
311
+ `route ${routePath} takes an array of segments for :${name}*, and got ` +
312
+ describeParam(value),
313
+ );
314
+ }
315
+ for (const part of value) {
316
+ parts.push(encodeURIComponent(part));
317
+ }
318
+ }
319
+ }
320
+ }
321
+ return parts.length === 0 ? "/" : `/${parts.join("/")}`;
322
+ }
323
+
324
+ /** What a parameter was, for the message that says it was the wrong thing. */
325
+ function describeParam(value: string | $ReadOnlyArray<string> | void): string {
326
+ if (value === undefined) {
327
+ return "nothing";
328
+ }
329
+ return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
330
+ }
331
+
332
+ function decodeSegment(segment: string): string {
333
+ try {
334
+ return decodeURIComponent(segment);
335
+ } catch {
336
+ return segment;
337
+ }
338
+ }
339
+
340
+ /** Whether this table can render the route in the browser. */
341
+ export function hasClientPage(route: { +page?: mixed, ... }): boolean {
342
+ return route.page != null;
343
+ }
344
+
345
+ /** Match a pathname against the table, preferring the most specific route. */
346
+ export function matchRoute<TRoute: { +path: string, ... }>(
347
+ routes: $ReadOnlyArray<TRoute>,
348
+ pathname: string,
349
+ ): ?RouteMatch<TRoute> {
350
+ return matchIn(routes, pathname);
351
+ }
352
+
353
+ /**
354
+ * The same match, over anything that has a route path.
355
+ *
356
+ * A slot is a second table matched against the same URL, and it has to be
357
+ * matched by this function rather than by one of its own.
358
+ */
359
+ export function matchIn<TRoute: { +path: string, ... }>(
360
+ routes: $ReadOnlyArray<TRoute>,
361
+ pathname: string,
362
+ ): ?RouteMatch<TRoute> {
363
+ const parts = pathname.split("/").filter((part) => part !== "");
364
+ let best: ?RouteMatch<TRoute> = null;
365
+ let bestScore = -1;
366
+ for (const route of routes) {
367
+ const segments = compile(route.path);
368
+ const params = matchSegments(segments, parts);
369
+ if (params == null) {
370
+ continue;
371
+ }
372
+ const score = specificity(segments);
373
+ if (score > bestScore) {
374
+ best = { route, params };
375
+ bestScore = score;
376
+ }
377
+ }
378
+ return best;
379
+ }
380
+
381
+ /**
382
+ * Whether a boundary declared at `segments` is at or above `parts`.
383
+ *
384
+ * The same segment kinds as [`matchSegments`], stopping when the boundary's
385
+ * own segments run out instead of requiring the path to.
386
+ */
387
+ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
388
+ let index = 0;
389
+ for (const segment of segments) {
390
+ const next = match (segment) {
391
+ {kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
392
+ {kind: "param"} => index < parts.length ? index + 1 : -1,
393
+ {kind: "catchAll"} => parts.length,
394
+ };
395
+ if (next === -1) {
396
+ return false;
397
+ }
398
+ index = next;
399
+ }
400
+ return true;
401
+ }
402
+
403
+ /** The nearest boundary above `pathname`, or `null` when none covers it. */
404
+ export function nearestBoundary<TBoundary: { readonly path: string, ... }>(
405
+ boundaries: $ReadOnlyArray<TBoundary>,
406
+ pathname: string,
407
+ ): ?TBoundary {
408
+ const parts = pathname.split("/").filter((part) => part !== "");
409
+ let best: ?TBoundary = null;
410
+ let bestDepth = -1;
411
+ for (const boundary of boundaries) {
412
+ const segments = compile(boundary.path);
413
+ if (!covers(segments, parts)) {
414
+ continue;
415
+ }
416
+ if (segments.length > bestDepth) {
417
+ best = boundary;
418
+ bestDepth = segments.length;
419
+ }
420
+ }
421
+ return best;
422
+ }
423
+
424
+ /** Split a URL into its pathname and search string. */
425
+ export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
426
+ const hash = url.indexOf("#");
427
+ const withoutHash = hash === -1 ? url : url.slice(0, hash);
428
+ const question = withoutHash.indexOf("?");
429
+ if (question === -1) {
430
+ return { pathname: normalizePathname(withoutHash), search: "" };
431
+ }
432
+ return {
433
+ pathname: normalizePathname(withoutHash.slice(0, question)),
434
+ search: withoutHash.slice(question),
435
+ };
436
+ }
437
+
438
+ function normalizePathname(pathname: string): string {
439
+ if (pathname === "" || pathname === "/") {
440
+ return "/";
441
+ }
442
+ const trimmed = pathname.replace(/\/+$/, "");
443
+ return trimmed === "" ? "/" : trimmed;
444
+ }
445
+
446
+ /** Parse a search string into a flat map; a repeated key keeps its last value. */
447
+ export function parseSearch(search: string): SearchParams {
448
+ const params: { [string]: string } = {};
449
+ for (const [key, value] of new URLSearchParams(search)) {
450
+ params[key] = value;
451
+ }
452
+ return params;
453
+ }
454
+
455
+ /** Stop rendering the current page and show the not-found page instead. */
456
+ export function notFound(): empty {
457
+ throw new NotFoundError();
458
+ }
459
+
460
+ /** Stop rendering the current page and show the error boundary, as a 401. */
461
+ export function unauthorized(): empty {
462
+ throw new UnauthorizedError();
463
+ }
464
+
465
+ /** Stop rendering the current page and show the error boundary, as a 403. */
466
+ export function forbidden(): empty {
467
+ throw new ForbiddenError();
468
+ }
469
+
470
+ /** Stop rendering the current page and send the visitor elsewhere. */
471
+ export function redirect(to: string): empty {
472
+ throw new RedirectError(to, false);
473
+ }
474
+
475
+ /** `redirect`, with a permanent status. */
476
+ export function permanentRedirect(to: string): empty {
477
+ throw new RedirectError(to, true);
478
+ }