@uniflowed/router 0.0.0-alpha.1 → 0.0.0-alpha.5
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 +4 -4
- package/handler.js +210 -0
- package/index.js +9 -7
- package/internal/document.js +24 -0
- package/internal/runtime.js +224 -102
- package/package.json +10 -5
- package/server.js +17 -16
package/client.js
CHANGED
|
@@ -12,15 +12,15 @@ import { startTransition } from "react";
|
|
|
12
12
|
import { hydrateRoot } from "react-dom/client";
|
|
13
13
|
|
|
14
14
|
import { type AppProps, type RouteTable, installRoutes, resolveMatch } from "./internal/runtime.js";
|
|
15
|
-
import { DATA_ID, ROOT_ID } from "./
|
|
15
|
+
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Hydrate the current document.
|
|
19
19
|
*/
|
|
20
20
|
export async function hydrate(options: {|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
readonly App: React.ComponentType<AppProps>,
|
|
22
|
+
readonly routes: RouteTable["routes"],
|
|
23
|
+
readonly notFound: RouteTable["notFound"],
|
|
24
24
|
|}): Promise<void> {
|
|
25
25
|
const table: RouteTable = { routes: options.routes, notFound: options.notFound };
|
|
26
26
|
installRoutes(table);
|
package/handler.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Route handlers: a path that answers a request instead of rendering a page.
|
|
4
|
+
//
|
|
5
|
+
// `app/api/users/_uf.route.js` exporting `GET` and `POST` serves
|
|
6
|
+
// `/api/users`. A handler takes a `Request` and returns a `Response` — the
|
|
7
|
+
// platform's own types, not a framework's wrapper — because that is what runs
|
|
8
|
+
// unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
|
|
9
|
+
// position is that the host is a capability rather than a target.
|
|
10
|
+
//
|
|
11
|
+
// // app/api/users/[id]/_uf.route.js
|
|
12
|
+
// // @flow
|
|
13
|
+
// export async function GET(request: Request, context: HandlerContext) {
|
|
14
|
+
// const user = await find(context.params.id);
|
|
15
|
+
// return user == null
|
|
16
|
+
// ? new Response("not found", { status: 404 })
|
|
17
|
+
// : Response.json(user);
|
|
18
|
+
// }
|
|
19
|
+
//
|
|
20
|
+
// # What the dispatcher decides, and what it does not
|
|
21
|
+
//
|
|
22
|
+
// It matches a path and a method and calls a function. It does not catch the
|
|
23
|
+
// handler's errors, because a handler that throws is a bug the host's own
|
|
24
|
+
// error reporting should see, and swallowing it into a 500 here would hide it.
|
|
25
|
+
// It does answer `405` itself when the path matches and the method does not,
|
|
26
|
+
// with the `Allow` header the specification requires — that is not the
|
|
27
|
+
// handler's business, and every handler would otherwise write it.
|
|
28
|
+
|
|
29
|
+
import { contextFor, drainDeferred, runWithContext } from "@uniflowed/server/host";
|
|
30
|
+
import type { RouteParams } from "./internal/runtime.js";
|
|
31
|
+
|
|
32
|
+
/** What a handler is given besides the request. */
|
|
33
|
+
export type HandlerContext = {|
|
|
34
|
+
/** The `[param]` and `[...rest]` segments of the matched path. */
|
|
35
|
+
readonly params: RouteParams,
|
|
36
|
+
/** The parsed query string, for the common case of reading one value. */
|
|
37
|
+
readonly searchParams: URLSearchParams,
|
|
38
|
+
|};
|
|
39
|
+
|
|
40
|
+
/** One exported method of a handler module. */
|
|
41
|
+
export type Handler = (request: Request, context: HandlerContext) => Response | Promise<Response>;
|
|
42
|
+
|
|
43
|
+
/** A handler module, as the generated table loads it. */
|
|
44
|
+
export type HandlerModule = { readonly [method: string]: mixed };
|
|
45
|
+
|
|
46
|
+
/** One entry of the generated handler table. */
|
|
47
|
+
export type HandlerRecord = {|
|
|
48
|
+
readonly path: string,
|
|
49
|
+
readonly params: $ReadOnlyArray<{| readonly name: string, readonly catchAll: boolean |}>,
|
|
50
|
+
readonly file: string,
|
|
51
|
+
readonly load: () => Promise<HandlerModule>,
|
|
52
|
+
|};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The methods a handler may export.
|
|
56
|
+
*
|
|
57
|
+
* A closed list, because the alternative is treating every export as a method
|
|
58
|
+
* — and a module that exports a helper would then answer requests with it.
|
|
59
|
+
* `HEAD` falls back to `GET` with the body dropped, which is what a client
|
|
60
|
+
* asking for headers expects and what nobody remembers to write.
|
|
61
|
+
*/
|
|
62
|
+
const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Match a request against the handler table and run it.
|
|
66
|
+
*
|
|
67
|
+
* Returns `null` when no path matches, which is the caller's signal to carry
|
|
68
|
+
* on — a request for `/about` is a page, and the dispatcher declining is how
|
|
69
|
+
* it says so.
|
|
70
|
+
*/
|
|
71
|
+
export function createDispatcher(options: {|
|
|
72
|
+
readonly handlers: $ReadOnlyArray<HandlerRecord>,
|
|
73
|
+
|}): (request: Request) => Promise<Response | null> {
|
|
74
|
+
// Longest path first, so `/api/users/new` wins over `/api/users/[id]` and a
|
|
75
|
+
// catch-all is the last thing tried.
|
|
76
|
+
const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
|
|
77
|
+
|
|
78
|
+
return async function dispatch(request: Request): Promise<Response | null> {
|
|
79
|
+
const url = new URL(request.url);
|
|
80
|
+
for (const record of table) {
|
|
81
|
+
const params = matchPath(record.path, url.pathname);
|
|
82
|
+
if (params == null) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const module = await record.load();
|
|
87
|
+
const method = request.method.toUpperCase();
|
|
88
|
+
const handler = pick(module, method);
|
|
89
|
+
if (handler == null) {
|
|
90
|
+
return methodNotAllowed(module);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Inside the request, so a handler that calls `headers()`, `cookies()`
|
|
94
|
+
// or `after()` has something to answer about. `drainDeferred` runs after
|
|
95
|
+
// the response is in hand, which is what `after()` means.
|
|
96
|
+
const context = contextFor(request);
|
|
97
|
+
const response = await runWithContext(context, () =>
|
|
98
|
+
handler(request, {
|
|
99
|
+
params,
|
|
100
|
+
searchParams: url.searchParams,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
await drainDeferred(context);
|
|
104
|
+
|
|
105
|
+
// A `HEAD` answered by `GET` must not carry the body. The test is
|
|
106
|
+
// against the module's own `HEAD`, not `pick`'s — `pick` falls back to
|
|
107
|
+
// `GET`, so asking it whether a `HEAD` exists always said yes and the
|
|
108
|
+
// body went out anyway.
|
|
109
|
+
if (method === "HEAD" && typeof module.HEAD !== "function") {
|
|
110
|
+
return new Response(null, {
|
|
111
|
+
status: response.status,
|
|
112
|
+
statusText: response.statusText,
|
|
113
|
+
headers: response.headers,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The function for a method, falling back to `GET` for `HEAD`. */
|
|
123
|
+
function pick(module: HandlerModule, method: string): Handler | null {
|
|
124
|
+
const own = module[method];
|
|
125
|
+
if (typeof own === "function") {
|
|
126
|
+
return own as $FlowFixMe;
|
|
127
|
+
}
|
|
128
|
+
if (method === "HEAD" && typeof module.GET === "function") {
|
|
129
|
+
return module.GET as $FlowFixMe;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `405`, with the `Allow` header naming what the path does accept.
|
|
136
|
+
*
|
|
137
|
+
* Required by the specification, and the reason a client can tell "you may not
|
|
138
|
+
* do that here" from "there is nothing here".
|
|
139
|
+
*/
|
|
140
|
+
function methodNotAllowed(module: HandlerModule): Response {
|
|
141
|
+
const own = new Set(METHODS.filter((method) => typeof module[method] === "function"));
|
|
142
|
+
// A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
|
|
143
|
+
if (own.has("GET")) {
|
|
144
|
+
own.add("HEAD");
|
|
145
|
+
}
|
|
146
|
+
// Filtered through `METHODS` rather than listed in insertion order, so the
|
|
147
|
+
// header reads in the conventional order however the module was written.
|
|
148
|
+
return new Response(null, {
|
|
149
|
+
status: 405,
|
|
150
|
+
headers: { allow: METHODS.filter((method) => own.has(method)).join(", ") },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Match one route path against a pathname, returning its parameters.
|
|
156
|
+
*
|
|
157
|
+
* `null` rather than an empty object when it does not match, so a route with
|
|
158
|
+
* no parameters is still distinguishable from a miss.
|
|
159
|
+
*/
|
|
160
|
+
function matchPath(routePath: string, pathname: string): RouteParams | null {
|
|
161
|
+
const wanted = segmentsOf(routePath);
|
|
162
|
+
const given = segmentsOf(pathname);
|
|
163
|
+
const params: { [string]: string | Array<string> } = {};
|
|
164
|
+
|
|
165
|
+
for (let index = 0; index < wanted.length; index += 1) {
|
|
166
|
+
const segment = wanted[index];
|
|
167
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
168
|
+
// A catch-all takes the rest, and matches zero segments as well as many.
|
|
169
|
+
params[segment.slice(1, -1)] = given.slice(index);
|
|
170
|
+
return params as $FlowFixMe;
|
|
171
|
+
}
|
|
172
|
+
if (index >= given.length) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (segment.startsWith(":")) {
|
|
176
|
+
params[segment.slice(1)] = given[index];
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (segment !== given[index]) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return wanted.length === given.length ? (params as $FlowFixMe) : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function segmentsOf(value: string): Array<string> {
|
|
188
|
+
return value.split("/").filter((segment) => segment !== "");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* How specific a path is, so the table can be tried in the right order.
|
|
193
|
+
*
|
|
194
|
+
* A literal segment is worth more than a parameter and a parameter more than a
|
|
195
|
+
* catch-all, and a longer path outranks a shorter one — which is what makes
|
|
196
|
+
* `/api/users/new` win over `/api/users/[id]`.
|
|
197
|
+
*/
|
|
198
|
+
function specificity(routePath: string): number {
|
|
199
|
+
let score = 0;
|
|
200
|
+
for (const segment of segmentsOf(routePath)) {
|
|
201
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
202
|
+
score += 1;
|
|
203
|
+
} else if (segment.startsWith(":")) {
|
|
204
|
+
score += 10;
|
|
205
|
+
} else {
|
|
206
|
+
score += 100;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return score;
|
|
210
|
+
}
|
package/index.js
CHANGED
|
@@ -49,16 +49,18 @@ export {
|
|
|
49
49
|
|
|
50
50
|
/** Props a page receives. */
|
|
51
51
|
export type PageProps<
|
|
52
|
-
TParams
|
|
52
|
+
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
53
53
|
TData = void,
|
|
54
54
|
> = {|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
readonly params: TParams,
|
|
56
|
+
readonly searchParams: { readonly [string]: string },
|
|
57
|
+
readonly data: TData,
|
|
58
58
|
|};
|
|
59
59
|
|
|
60
60
|
/** Props a layout receives. */
|
|
61
|
-
export type LayoutProps<
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
export type LayoutProps<
|
|
62
|
+
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
63
|
+
> = {|
|
|
64
|
+
readonly params: TParams,
|
|
65
|
+
readonly children: React$Node,
|
|
64
66
|
|};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the two ids the server writes and the
|
|
4
|
+
// client reads.
|
|
5
|
+
//
|
|
6
|
+
// Their own module because both halves need them and neither should import the
|
|
7
|
+
// other. The client used to take them from `./server.js`, which meant a client
|
|
8
|
+
// bundle reached the request dispatcher — and through it `node:async_hooks`,
|
|
9
|
+
// by way of `@uniflowed/server`. Nothing was *called*, so a bundler dropped the
|
|
10
|
+
// code, but the import of a Node builtin survived into a browser bundle and
|
|
11
|
+
// Vite said so on every build.
|
|
12
|
+
//
|
|
13
|
+
// Two string constants are not worth a boundary violation.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The element the client hydrates when the app does not render `<html>`.
|
|
17
|
+
*
|
|
18
|
+
* An app whose root layout renders the whole document owns it and hydrates
|
|
19
|
+
* `document` instead; this is the wrapper for the ones that render content.
|
|
20
|
+
*/
|
|
21
|
+
export const ROOT_ID = "uf-root";
|
|
22
|
+
|
|
23
|
+
/** The script element the server's resolved route data is written into. */
|
|
24
|
+
export const DATA_ID = "__uf_data";
|
package/internal/runtime.js
CHANGED
|
@@ -22,101 +22,150 @@ import {
|
|
|
22
22
|
} from "react";
|
|
23
23
|
|
|
24
24
|
/** One parameter a route path captures. */
|
|
25
|
-
export type RouteParamSpec = {|
|
|
25
|
+
export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
|
|
26
26
|
|
|
27
27
|
/** The parameters captured from a URL. A catch-all captures the rest as a list. */
|
|
28
|
-
export type RouteParams = {
|
|
28
|
+
export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
|
|
29
29
|
|
|
30
30
|
/** The query string, as a read-only map. */
|
|
31
|
-
export type SearchParams = {
|
|
31
|
+
export type SearchParams = { readonly [string]: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A component found in a route module.
|
|
35
|
+
*
|
|
36
|
+
* `React.ComponentType<empty>` is "some React component", and it is a claim
|
|
37
|
+
* rather than a shrug. `ComponentType` is contravariant in its props — Flow's
|
|
38
|
+
* library definition writes it `component(...P)` with `in P` — so `empty` is
|
|
39
|
+
* the *top* of the component types: every component is one, and nothing may be
|
|
40
|
+
* passed to one until a caller has said which props it is passing. That is
|
|
41
|
+
* exactly what is known here. The router finds these by dynamic import, and
|
|
42
|
+
* nobody has told it what a page's props are.
|
|
43
|
+
*
|
|
44
|
+
* It cannot be `React.ComponentType<PageRenderProps>`, the props the router
|
|
45
|
+
* actually passes, because Flow's `component` syntax gives a component *exact*
|
|
46
|
+
* props and a page is free to want none of them. This repository's own pages
|
|
47
|
+
* and layouts are `component NotFound()` and
|
|
48
|
+
* `component Layout(children: React.Node)`, and against the props the router
|
|
49
|
+
* hands them that reads:
|
|
50
|
+
*
|
|
51
|
+
* error[incompatible-type]: property `data`, property `params`, and
|
|
52
|
+
* property `searchParams` are extra in `PageRenderProps` but missing in
|
|
53
|
+
* `props of component NotFound`. Exact objects do not accept extra props.
|
|
54
|
+
*
|
|
55
|
+
* React passing a component a prop it did not declare is allowed and always
|
|
56
|
+
* has been. `renderable` is the one line that says so.
|
|
57
|
+
*/
|
|
58
|
+
type RouteComponent = React.ComponentType<empty>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The props `RouteView` gives the page it renders.
|
|
62
|
+
*
|
|
63
|
+
* The same three as the public `PageProps` in `../index.js`, at the arguments
|
|
64
|
+
* the runtime instantiates it with: the runtime knows the parameters as
|
|
65
|
+
* strings and the loader's data as `mixed`, and a page narrows both by
|
|
66
|
+
* annotating its own props.
|
|
67
|
+
*/
|
|
68
|
+
type PageRenderProps = {|
|
|
69
|
+
readonly params: RouteParams,
|
|
70
|
+
readonly searchParams: SearchParams,
|
|
71
|
+
readonly data: mixed,
|
|
72
|
+
|};
|
|
73
|
+
|
|
74
|
+
/** The props `RouteView` gives each layout, outermost first. */
|
|
75
|
+
type LayoutRenderProps = {|
|
|
76
|
+
readonly params: RouteParams,
|
|
77
|
+
readonly children: React.Node,
|
|
78
|
+
|};
|
|
32
79
|
|
|
33
80
|
/** What a page module may export. The component is `default` or `Page`. */
|
|
34
81
|
export type PageModule = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
82
|
+
readonly default?: RouteComponent,
|
|
83
|
+
readonly Page?: RouteComponent,
|
|
84
|
+
readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
|
|
85
|
+
readonly metadata?: Metadata,
|
|
86
|
+
readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
|
|
87
|
+
readonly generateStaticParams?: () =>
|
|
88
|
+
| $ReadOnlyArray<RouteParams>
|
|
89
|
+
| Promise<$ReadOnlyArray<RouteParams>>,
|
|
90
|
+
readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
|
|
42
91
|
...
|
|
43
92
|
};
|
|
44
93
|
|
|
45
94
|
/** What a layout module may export. The component is `default` or `Layout`. */
|
|
46
95
|
export type LayoutModule = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
readonly default?: RouteComponent,
|
|
97
|
+
readonly Layout?: RouteComponent,
|
|
98
|
+
readonly metadata?: Metadata,
|
|
50
99
|
...
|
|
51
100
|
};
|
|
52
101
|
|
|
53
102
|
/** Document metadata a page or layout declares. */
|
|
54
103
|
export type Metadata = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
readonly title?: string,
|
|
105
|
+
readonly description?: string,
|
|
106
|
+
readonly openGraph?: {
|
|
107
|
+
readonly title?: string,
|
|
108
|
+
readonly description?: string,
|
|
109
|
+
readonly images?: $ReadOnlyArray<string>,
|
|
61
110
|
},
|
|
62
111
|
};
|
|
63
112
|
|
|
64
113
|
/** Arguments a loader receives. */
|
|
65
114
|
export type LoaderArgs = {|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
115
|
+
readonly params: RouteParams,
|
|
116
|
+
readonly searchParams: SearchParams,
|
|
117
|
+
readonly pathname: string,
|
|
69
118
|
|};
|
|
70
119
|
|
|
71
120
|
/** Arguments `generateMetadata` receives. */
|
|
72
121
|
export type MetadataArgs = {|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
122
|
+
readonly params: RouteParams,
|
|
123
|
+
readonly searchParams: SearchParams,
|
|
124
|
+
readonly data: mixed,
|
|
76
125
|
|};
|
|
77
126
|
|
|
78
127
|
/** One entry of the generated route table. */
|
|
79
128
|
export type RouteRecord = {|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
129
|
+
readonly path: string,
|
|
130
|
+
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
131
|
+
readonly mdx: boolean,
|
|
132
|
+
readonly file: string,
|
|
133
|
+
readonly page: () => Promise<PageModule>,
|
|
134
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
86
135
|
|};
|
|
87
136
|
|
|
88
137
|
/** The not-found page, when the app declares one. */
|
|
89
138
|
export type NotFoundRecord = {|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
139
|
+
readonly mdx: boolean,
|
|
140
|
+
readonly file: string,
|
|
141
|
+
readonly page: () => Promise<PageModule>,
|
|
142
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
94
143
|
|};
|
|
95
144
|
|
|
96
145
|
/** A route table plus the not-found page. */
|
|
97
146
|
export type RouteTable = {|
|
|
98
|
-
|
|
99
|
-
|
|
147
|
+
readonly routes: $ReadOnlyArray<RouteRecord>,
|
|
148
|
+
readonly notFound: ?NotFoundRecord,
|
|
100
149
|
|};
|
|
101
150
|
|
|
102
151
|
/** A URL matched against the table. */
|
|
103
152
|
export type RouteMatch = {|
|
|
104
|
-
|
|
105
|
-
|
|
153
|
+
readonly route: RouteRecord,
|
|
154
|
+
readonly params: RouteParams,
|
|
106
155
|
|};
|
|
107
156
|
|
|
108
157
|
/** A match whose modules are loaded and whose loader has run. */
|
|
109
158
|
export type ResolvedRoute = {|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
159
|
+
readonly pathname: string,
|
|
160
|
+
readonly search: string,
|
|
161
|
+
readonly path: string,
|
|
162
|
+
readonly params: RouteParams,
|
|
163
|
+
readonly searchParams: SearchParams,
|
|
164
|
+
readonly page: PageModule,
|
|
165
|
+
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
166
|
+
readonly data: mixed,
|
|
167
|
+
readonly metadata: Metadata,
|
|
168
|
+
readonly status: 200 | 404,
|
|
120
169
|
|};
|
|
121
170
|
|
|
122
171
|
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
@@ -145,9 +194,9 @@ export class RedirectError extends Error {
|
|
|
145
194
|
// ---------------------------------------------------------------------------
|
|
146
195
|
|
|
147
196
|
type Segment =
|
|
148
|
-
| {|
|
|
149
|
-
| {|
|
|
150
|
-
| {|
|
|
197
|
+
| {| readonly kind: "static", readonly value: string |}
|
|
198
|
+
| {| readonly kind: "param", readonly name: string |}
|
|
199
|
+
| {| readonly kind: "catchAll", readonly name: string |};
|
|
151
200
|
|
|
152
201
|
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
153
202
|
return routePath
|
|
@@ -171,35 +220,37 @@ function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
|
171
220
|
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
172
221
|
let score = 0;
|
|
173
222
|
for (const segment of segments) {
|
|
174
|
-
score +=
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
};
|
|
223
|
+
score += match (segment) {
|
|
224
|
+
{kind: "static"} => 3,
|
|
225
|
+
{kind: "param"} => 2,
|
|
226
|
+
{kind: "catchAll"} => 1,
|
|
227
|
+
};
|
|
180
228
|
}
|
|
181
229
|
return score;
|
|
182
230
|
}
|
|
183
231
|
|
|
184
|
-
function matchSegments(
|
|
232
|
+
function matchSegments(
|
|
233
|
+
segments: $ReadOnlyArray<Segment>,
|
|
234
|
+
parts: $ReadOnlyArray<string>,
|
|
235
|
+
): ?RouteParams {
|
|
185
236
|
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
186
237
|
let index = 0;
|
|
187
238
|
for (const segment of segments) {
|
|
188
239
|
match (segment) {
|
|
189
|
-
{
|
|
240
|
+
{kind: "static", value: const value} => {
|
|
190
241
|
if (parts[index] !== value) {
|
|
191
242
|
return null;
|
|
192
243
|
}
|
|
193
244
|
index += 1;
|
|
194
245
|
}
|
|
195
|
-
{
|
|
246
|
+
{kind: "param", name: const name} => {
|
|
196
247
|
if (index >= parts.length) {
|
|
197
248
|
return null;
|
|
198
249
|
}
|
|
199
250
|
params[name] = decodeSegment(parts[index]);
|
|
200
251
|
index += 1;
|
|
201
252
|
}
|
|
202
|
-
{
|
|
253
|
+
{kind: "catchAll", name: const name} => {
|
|
203
254
|
params[name] = parts.slice(index).map(decodeSegment);
|
|
204
255
|
index = parts.length;
|
|
205
256
|
}
|
|
@@ -239,7 +290,7 @@ export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string
|
|
|
239
290
|
}
|
|
240
291
|
|
|
241
292
|
/** Split a URL into its pathname and search string. */
|
|
242
|
-
export function splitUrl(url: string): {|
|
|
293
|
+
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
243
294
|
const hash = url.indexOf("#");
|
|
244
295
|
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
245
296
|
const question = withoutHash.indexOf("?");
|
|
@@ -295,7 +346,7 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
295
346
|
export async function resolveMatch(
|
|
296
347
|
table: RouteTable,
|
|
297
348
|
url: string,
|
|
298
|
-
options?: {|
|
|
349
|
+
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
299
350
|
): Promise<ResolvedRoute> {
|
|
300
351
|
const { pathname, search } = splitUrl(url);
|
|
301
352
|
const searchParams = parseSearch(search);
|
|
@@ -322,7 +373,11 @@ export async function resolveMatch(
|
|
|
322
373
|
}
|
|
323
374
|
}
|
|
324
375
|
|
|
325
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
376
|
+
const metadata = await resolveMetadata(page, layouts, {
|
|
377
|
+
params: matched.params,
|
|
378
|
+
searchParams,
|
|
379
|
+
data,
|
|
380
|
+
});
|
|
326
381
|
return {
|
|
327
382
|
pathname,
|
|
328
383
|
search,
|
|
@@ -362,7 +417,11 @@ async function resolveNotFound(
|
|
|
362
417
|
loadOnce(record.page),
|
|
363
418
|
...record.layouts.map((layout) => loadOnce(layout)),
|
|
364
419
|
]);
|
|
365
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
420
|
+
const metadata = await resolveMetadata(page, layouts, {
|
|
421
|
+
params: {},
|
|
422
|
+
searchParams,
|
|
423
|
+
data: undefined,
|
|
424
|
+
});
|
|
366
425
|
return {
|
|
367
426
|
pathname,
|
|
368
427
|
search,
|
|
@@ -390,7 +449,11 @@ async function resolveMetadata(
|
|
|
390
449
|
}
|
|
391
450
|
if (page.frontmatter != null) {
|
|
392
451
|
const { title, description } = page.frontmatter;
|
|
393
|
-
merged = {
|
|
452
|
+
merged = {
|
|
453
|
+
...merged,
|
|
454
|
+
...(title != null ? { title } : {}),
|
|
455
|
+
...(description != null ? { description } : {}),
|
|
456
|
+
};
|
|
394
457
|
}
|
|
395
458
|
if (page.metadata != null) {
|
|
396
459
|
merged = { ...merged, ...page.metadata };
|
|
@@ -416,32 +479,32 @@ component DefaultNotFound() {
|
|
|
416
479
|
// ---------------------------------------------------------------------------
|
|
417
480
|
|
|
418
481
|
/** How a navigation is performed. */
|
|
419
|
-
export type NavigateOptions = {|
|
|
482
|
+
export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
|
|
420
483
|
|
|
421
484
|
/** What `useRouter()` returns. */
|
|
422
485
|
export type Router = {|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
486
|
+
readonly push: (to: string, options?: NavigateOptions) => Promise<void>,
|
|
487
|
+
readonly replace: (to: string) => Promise<void>,
|
|
488
|
+
readonly prefetch: (to: string) => Promise<void>,
|
|
489
|
+
readonly refresh: () => Promise<void>,
|
|
490
|
+
readonly back: () => void,
|
|
491
|
+
readonly forward: () => void,
|
|
429
492
|
|};
|
|
430
493
|
|
|
431
494
|
/** What `useRoute()` returns. */
|
|
432
495
|
export type RouteInfo = {|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
496
|
+
readonly path: string,
|
|
497
|
+
readonly pathname: string,
|
|
498
|
+
readonly params: RouteParams,
|
|
499
|
+
readonly searchParams: SearchParams,
|
|
500
|
+
readonly data: mixed,
|
|
501
|
+
readonly pending: boolean,
|
|
439
502
|
|};
|
|
440
503
|
|
|
441
504
|
type RouterState = {|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
505
|
+
readonly resolved: ResolvedRoute,
|
|
506
|
+
readonly router: Router,
|
|
507
|
+
readonly pending: boolean,
|
|
445
508
|
|};
|
|
446
509
|
|
|
447
510
|
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
@@ -457,15 +520,17 @@ export function installRoutes(table: RouteTable): void {
|
|
|
457
520
|
/** The registered table, or a clear error when the entry forgot to install it. */
|
|
458
521
|
export function routeTable(): RouteTable {
|
|
459
522
|
if (installedTable == null) {
|
|
460
|
-
throw new Error(
|
|
523
|
+
throw new Error(
|
|
524
|
+
"@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`",
|
|
525
|
+
);
|
|
461
526
|
}
|
|
462
527
|
return installedTable;
|
|
463
528
|
}
|
|
464
529
|
|
|
465
530
|
/** Props the app root receives from the client and server entries. */
|
|
466
531
|
export type AppProps = {|
|
|
467
|
-
|
|
468
|
-
|
|
532
|
+
readonly url: string,
|
|
533
|
+
readonly initial: ResolvedRoute,
|
|
469
534
|
|};
|
|
470
535
|
|
|
471
536
|
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
@@ -547,13 +612,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
547
612
|
if (matched == null) {
|
|
548
613
|
return;
|
|
549
614
|
}
|
|
550
|
-
await Promise.all([
|
|
615
|
+
await Promise.all([
|
|
616
|
+
loadOnce(matched.route.page),
|
|
617
|
+
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
618
|
+
]);
|
|
551
619
|
},
|
|
552
620
|
refresh: async () => {
|
|
553
621
|
if (!isBrowser) {
|
|
554
622
|
return;
|
|
555
623
|
}
|
|
556
|
-
const nextResolved = await resolveMatch(
|
|
624
|
+
const nextResolved = await resolveMatch(
|
|
625
|
+
routeTable(),
|
|
626
|
+
window.location.pathname + window.location.search,
|
|
627
|
+
);
|
|
557
628
|
startTransition(() => {
|
|
558
629
|
setResolved(nextResolved);
|
|
559
630
|
});
|
|
@@ -572,14 +643,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
572
643
|
[navigate],
|
|
573
644
|
);
|
|
574
645
|
|
|
575
|
-
const value = useMemo<RouterState>(
|
|
646
|
+
const value = useMemo<RouterState>(
|
|
647
|
+
() => ({ resolved, router, pending }),
|
|
648
|
+
[resolved, router, pending],
|
|
649
|
+
);
|
|
576
650
|
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
577
651
|
}
|
|
578
652
|
|
|
579
653
|
hook useRouterState(): RouterState {
|
|
580
654
|
const state = useContext(RouterContext);
|
|
581
655
|
if (state == null) {
|
|
582
|
-
throw new Error(
|
|
656
|
+
throw new Error(
|
|
657
|
+
"@uniflowed/router: this hook must be used inside the app started by `routerView`",
|
|
658
|
+
);
|
|
583
659
|
}
|
|
584
660
|
return state;
|
|
585
661
|
}
|
|
@@ -602,10 +678,28 @@ export hook useRouter(): Router {
|
|
|
602
678
|
return useRouterState().router;
|
|
603
679
|
}
|
|
604
680
|
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
681
|
+
/**
|
|
682
|
+
* The current page's loader data.
|
|
683
|
+
*
|
|
684
|
+
* `mixed`, so the page that reads it says what it is and the checker watches
|
|
685
|
+
* it do so. This was `useLoaderData<T>(): T`, which looks like inference and
|
|
686
|
+
* is a cast a caller writes at a distance: `useLoaderData<Post>()` asserted
|
|
687
|
+
* that a loader three files away returned a `Post` and nothing anywhere
|
|
688
|
+
* checked it, so a loader that changed shape produced a `Post`-shaped
|
|
689
|
+
* `undefined` at the first property read rather than an error where the shape
|
|
690
|
+
* was decided.
|
|
691
|
+
*
|
|
692
|
+
* Narrowing is a line at the top of the page — `if (typeof data !== "object"
|
|
693
|
+
* || data == null) { … }`, or the page's own validator schema, which is what
|
|
694
|
+
* `@uniflowed/validator` is for at exactly this boundary.
|
|
695
|
+
*
|
|
696
|
+
* The type that would need no narrowing is a *generated* one: the route table
|
|
697
|
+
* already produces `RoutePath` and `RouteParams` from the `app/` directory
|
|
698
|
+
* (`crates/uf_router/src/lib.rs`), and a loader's return type belongs in the
|
|
699
|
+
* same file, keyed by route. Until it is there, this says what is true.
|
|
700
|
+
*/
|
|
701
|
+
export hook useLoaderData(): mixed {
|
|
702
|
+
return useRouterState().resolved.data;
|
|
609
703
|
}
|
|
610
704
|
|
|
611
705
|
/**
|
|
@@ -634,21 +728,47 @@ export component RouteView() {
|
|
|
634
728
|
* The component a page module renders: its default export, or the named
|
|
635
729
|
* `Page` that `uf create` scaffolds. An MDX page always has a default export.
|
|
636
730
|
*/
|
|
637
|
-
function pageComponent(module: PageModule): React.ComponentType<
|
|
731
|
+
function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
|
|
638
732
|
const component = module.default ?? module.Page;
|
|
639
733
|
if (component == null) {
|
|
640
|
-
throw new Error(
|
|
734
|
+
throw new Error(
|
|
735
|
+
"@uniflowed/router: a page module must export a component as `default` or `Page`",
|
|
736
|
+
);
|
|
641
737
|
}
|
|
642
|
-
return component;
|
|
738
|
+
return renderable(component);
|
|
643
739
|
}
|
|
644
740
|
|
|
645
741
|
/** The component a layout module renders: `default`, or the named `Layout`. */
|
|
646
|
-
function layoutComponent(module: LayoutModule): React.ComponentType<
|
|
742
|
+
function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
|
|
647
743
|
const component = module.default ?? module.Layout;
|
|
648
744
|
if (component == null) {
|
|
649
|
-
throw new Error(
|
|
745
|
+
throw new Error(
|
|
746
|
+
"@uniflowed/router: a layout module must export a component as `default` or `Layout`",
|
|
747
|
+
);
|
|
650
748
|
}
|
|
651
|
-
return component;
|
|
749
|
+
return renderable(component);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* A route module's component, as the router is about to render it.
|
|
754
|
+
*
|
|
755
|
+
* # The one cast in this file, and why it is here rather than in six places
|
|
756
|
+
*
|
|
757
|
+
* A `RouteComponent` is a component about whose props nothing was claimed, and
|
|
758
|
+
* `RouteView` is about to pass it three. React allows that — a component
|
|
759
|
+
* receives the props its parent wrote and ignores the ones it did not declare
|
|
760
|
+
* — but Flow cannot be told it: a page's props are exact, so no props type but
|
|
761
|
+
* that page's own is assignable, and the router does not know which page it
|
|
762
|
+
* has. `React.ComponentType<any>` on the module types was this same
|
|
763
|
+
* unsoundness spread over six declarations, where it also stopped anyone from
|
|
764
|
+
* checking that `RouteView` passes the props a page is documented to receive.
|
|
765
|
+
* Here it is one line, and everything on either side of it is checked: what a
|
|
766
|
+
* module may export, and what a page is handed.
|
|
767
|
+
*/
|
|
768
|
+
function renderable<TProps extends { ... }>(
|
|
769
|
+
component: RouteComponent,
|
|
770
|
+
): React.ComponentType<TProps> {
|
|
771
|
+
return component as any;
|
|
652
772
|
}
|
|
653
773
|
|
|
654
774
|
component Head(metadata: Metadata) {
|
|
@@ -658,7 +778,9 @@ component Head(metadata: Metadata) {
|
|
|
658
778
|
{title != null ? <title>{title}</title> : null}
|
|
659
779
|
{description != null ? <meta name="description" content={description} /> : null}
|
|
660
780
|
{openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
|
|
661
|
-
{openGraph?.description != null ?
|
|
781
|
+
{openGraph?.description != null ? (
|
|
782
|
+
<meta property="og:description" content={openGraph.description} />
|
|
783
|
+
) : null}
|
|
662
784
|
{openGraph?.images != null
|
|
663
785
|
? openGraph.images.map((image) => <meta key={image} property="og:image" content={image} />)
|
|
664
786
|
: null}
|
|
@@ -683,7 +805,7 @@ export component Link(
|
|
|
683
805
|
children?: React.Node,
|
|
684
806
|
className?: string,
|
|
685
807
|
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
686
|
-
...rest: {
|
|
808
|
+
...rest: { readonly [string]: mixed }
|
|
687
809
|
) {
|
|
688
810
|
const router = useRouter();
|
|
689
811
|
const prefetched = React.useRef(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.5",
|
|
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",
|
|
@@ -14,16 +14,21 @@
|
|
|
14
14
|
".": "./index.js",
|
|
15
15
|
"./client": "./client.js",
|
|
16
16
|
"./server": "./server.js",
|
|
17
|
-
"./package.json": "./package.json"
|
|
17
|
+
"./package.json": "./package.json",
|
|
18
|
+
"./handler": "./handler.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
|
-
"index.js",
|
|
21
21
|
"client.js",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
22
|
+
"handler.js",
|
|
23
|
+
"index.js",
|
|
24
|
+
"internal",
|
|
25
|
+
"server.js"
|
|
24
26
|
],
|
|
25
27
|
"peerDependencies": {
|
|
26
28
|
"react": ">=19",
|
|
27
29
|
"react-dom": ">=19"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@uniflowed/server": "0.0.0-alpha.5"
|
|
28
33
|
}
|
|
29
34
|
}
|
package/server.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// `uf build` renders every static route through it once. Both produce the
|
|
8
8
|
// same markup from the same code, which is the point.
|
|
9
9
|
|
|
10
|
+
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
10
11
|
import * as React from "react";
|
|
11
12
|
import { renderToString } from "react-dom/server";
|
|
12
13
|
|
|
@@ -21,31 +22,30 @@ import {
|
|
|
21
22
|
|
|
22
23
|
/** Asset URLs to reference from the document. */
|
|
23
24
|
export type RenderAssets = {|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
readonly scripts: $ReadOnlyArray<string>,
|
|
26
|
+
readonly styles: $ReadOnlyArray<string>,
|
|
27
|
+
readonly preloads: $ReadOnlyArray<string>,
|
|
27
28
|
|};
|
|
28
29
|
|
|
29
30
|
/** A rendered document. */
|
|
30
31
|
export type RenderResult = {|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
readonly status: number,
|
|
33
|
+
readonly html: string,
|
|
34
|
+
readonly headers?: { readonly [string]: string },
|
|
34
35
|
|};
|
|
35
36
|
|
|
36
|
-
/** The id of the element the client hydrates when the app does not render `<html>`. */
|
|
37
|
-
export const ROOT_ID = "uf-root";
|
|
38
|
-
|
|
39
|
-
/** The id of the script carrying the loader data to the client. */
|
|
40
|
-
export const DATA_ID = "__uf_data";
|
|
41
|
-
|
|
42
37
|
/**
|
|
43
38
|
* Build a `render(url, assets)` for one app.
|
|
44
39
|
*/
|
|
40
|
+
export { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
41
|
+
|
|
42
|
+
export type { Handler, HandlerContext, HandlerModule, HandlerRecord } from "./handler.js";
|
|
43
|
+
export { createDispatcher } from "./handler.js";
|
|
44
|
+
|
|
45
45
|
export function createRenderer(options: {|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
readonly App: React.ComponentType<AppProps>,
|
|
47
|
+
readonly routes: RouteTable["routes"],
|
|
48
|
+
readonly notFound: RouteTable["notFound"],
|
|
49
49
|
|}): (url: string, assets: RenderAssets) => Promise<RenderResult> {
|
|
50
50
|
const table: RouteTable = { routes: options.routes, notFound: options.notFound };
|
|
51
51
|
installRoutes(table);
|
|
@@ -93,7 +93,8 @@ function assemble(markup: string, resolved: ResolvedRoute, assets: RenderAssets)
|
|
|
93
93
|
: markup.replace(/<html([^>]*)>/i, `<html$1><head>${head}</head>`);
|
|
94
94
|
return `<!doctype html>\n${document}\n`;
|
|
95
95
|
}
|
|
96
|
-
const title =
|
|
96
|
+
const title =
|
|
97
|
+
resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
|
|
97
98
|
return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}</head><body><div id="${ROOT_ID}">${markup}</div></body></html>\n`;
|
|
98
99
|
}
|
|
99
100
|
|