@uniflowed/router 0.0.0-alpha.1 → 0.0.0-alpha.4
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 +133 -96
- 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,103 @@ 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
32
|
|
|
33
33
|
/** What a page module may export. The component is `default` or `Page`. */
|
|
34
34
|
export type PageModule = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
readonly default?: React.ComponentType<any>,
|
|
36
|
+
readonly Page?: React.ComponentType<any>,
|
|
37
|
+
readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
|
|
38
|
+
readonly metadata?: Metadata,
|
|
39
|
+
readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
|
|
40
|
+
readonly generateStaticParams?: () =>
|
|
41
|
+
| $ReadOnlyArray<RouteParams>
|
|
42
|
+
| Promise<$ReadOnlyArray<RouteParams>>,
|
|
43
|
+
readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
|
|
42
44
|
...
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
/** What a layout module may export. The component is `default` or `Layout`. */
|
|
46
48
|
export type LayoutModule = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
readonly default?: React.ComponentType<any>,
|
|
50
|
+
readonly Layout?: React.ComponentType<any>,
|
|
51
|
+
readonly metadata?: Metadata,
|
|
50
52
|
...
|
|
51
53
|
};
|
|
52
54
|
|
|
53
55
|
/** Document metadata a page or layout declares. */
|
|
54
56
|
export type Metadata = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
readonly title?: string,
|
|
58
|
+
readonly description?: string,
|
|
59
|
+
readonly openGraph?: {
|
|
60
|
+
readonly title?: string,
|
|
61
|
+
readonly description?: string,
|
|
62
|
+
readonly images?: $ReadOnlyArray<string>,
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
/** Arguments a loader receives. */
|
|
65
67
|
export type LoaderArgs = {|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
readonly params: RouteParams,
|
|
69
|
+
readonly searchParams: SearchParams,
|
|
70
|
+
readonly pathname: string,
|
|
69
71
|
|};
|
|
70
72
|
|
|
71
73
|
/** Arguments `generateMetadata` receives. */
|
|
72
74
|
export type MetadataArgs = {|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
readonly params: RouteParams,
|
|
76
|
+
readonly searchParams: SearchParams,
|
|
77
|
+
readonly data: mixed,
|
|
76
78
|
|};
|
|
77
79
|
|
|
78
80
|
/** One entry of the generated route table. */
|
|
79
81
|
export type RouteRecord = {|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
readonly path: string,
|
|
83
|
+
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
84
|
+
readonly mdx: boolean,
|
|
85
|
+
readonly file: string,
|
|
86
|
+
readonly page: () => Promise<PageModule>,
|
|
87
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
86
88
|
|};
|
|
87
89
|
|
|
88
90
|
/** The not-found page, when the app declares one. */
|
|
89
91
|
export type NotFoundRecord = {|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
readonly mdx: boolean,
|
|
93
|
+
readonly file: string,
|
|
94
|
+
readonly page: () => Promise<PageModule>,
|
|
95
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
94
96
|
|};
|
|
95
97
|
|
|
96
98
|
/** A route table plus the not-found page. */
|
|
97
99
|
export type RouteTable = {|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
readonly routes: $ReadOnlyArray<RouteRecord>,
|
|
101
|
+
readonly notFound: ?NotFoundRecord,
|
|
100
102
|
|};
|
|
101
103
|
|
|
102
104
|
/** A URL matched against the table. */
|
|
103
105
|
export type RouteMatch = {|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
readonly route: RouteRecord,
|
|
107
|
+
readonly params: RouteParams,
|
|
106
108
|
|};
|
|
107
109
|
|
|
108
110
|
/** A match whose modules are loaded and whose loader has run. */
|
|
109
111
|
export type ResolvedRoute = {|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
readonly pathname: string,
|
|
113
|
+
readonly search: string,
|
|
114
|
+
readonly path: string,
|
|
115
|
+
readonly params: RouteParams,
|
|
116
|
+
readonly searchParams: SearchParams,
|
|
117
|
+
readonly page: PageModule,
|
|
118
|
+
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
119
|
+
readonly data: mixed,
|
|
120
|
+
readonly metadata: Metadata,
|
|
121
|
+
readonly status: 200 | 404,
|
|
120
122
|
|};
|
|
121
123
|
|
|
122
124
|
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
@@ -145,9 +147,9 @@ export class RedirectError extends Error {
|
|
|
145
147
|
// ---------------------------------------------------------------------------
|
|
146
148
|
|
|
147
149
|
type Segment =
|
|
148
|
-
| {|
|
|
149
|
-
| {|
|
|
150
|
-
| {|
|
|
150
|
+
| {| readonly kind: "static", readonly value: string |}
|
|
151
|
+
| {| readonly kind: "param", readonly name: string |}
|
|
152
|
+
| {| readonly kind: "catchAll", readonly name: string |};
|
|
151
153
|
|
|
152
154
|
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
153
155
|
return routePath
|
|
@@ -171,35 +173,37 @@ function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
|
171
173
|
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
172
174
|
let score = 0;
|
|
173
175
|
for (const segment of segments) {
|
|
174
|
-
score +=
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
};
|
|
176
|
+
score += match (segment) {
|
|
177
|
+
{kind: "static"} => 3,
|
|
178
|
+
{kind: "param"} => 2,
|
|
179
|
+
{kind: "catchAll"} => 1,
|
|
180
|
+
};
|
|
180
181
|
}
|
|
181
182
|
return score;
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
function matchSegments(
|
|
185
|
+
function matchSegments(
|
|
186
|
+
segments: $ReadOnlyArray<Segment>,
|
|
187
|
+
parts: $ReadOnlyArray<string>,
|
|
188
|
+
): ?RouteParams {
|
|
185
189
|
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
186
190
|
let index = 0;
|
|
187
191
|
for (const segment of segments) {
|
|
188
192
|
match (segment) {
|
|
189
|
-
{
|
|
193
|
+
{kind: "static", value: const value} => {
|
|
190
194
|
if (parts[index] !== value) {
|
|
191
195
|
return null;
|
|
192
196
|
}
|
|
193
197
|
index += 1;
|
|
194
198
|
}
|
|
195
|
-
{
|
|
199
|
+
{kind: "param", name: const name} => {
|
|
196
200
|
if (index >= parts.length) {
|
|
197
201
|
return null;
|
|
198
202
|
}
|
|
199
203
|
params[name] = decodeSegment(parts[index]);
|
|
200
204
|
index += 1;
|
|
201
205
|
}
|
|
202
|
-
{
|
|
206
|
+
{kind: "catchAll", name: const name} => {
|
|
203
207
|
params[name] = parts.slice(index).map(decodeSegment);
|
|
204
208
|
index = parts.length;
|
|
205
209
|
}
|
|
@@ -239,7 +243,7 @@ export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string
|
|
|
239
243
|
}
|
|
240
244
|
|
|
241
245
|
/** Split a URL into its pathname and search string. */
|
|
242
|
-
export function splitUrl(url: string): {|
|
|
246
|
+
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
243
247
|
const hash = url.indexOf("#");
|
|
244
248
|
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
245
249
|
const question = withoutHash.indexOf("?");
|
|
@@ -295,7 +299,7 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
295
299
|
export async function resolveMatch(
|
|
296
300
|
table: RouteTable,
|
|
297
301
|
url: string,
|
|
298
|
-
options?: {|
|
|
302
|
+
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
299
303
|
): Promise<ResolvedRoute> {
|
|
300
304
|
const { pathname, search } = splitUrl(url);
|
|
301
305
|
const searchParams = parseSearch(search);
|
|
@@ -322,7 +326,11 @@ export async function resolveMatch(
|
|
|
322
326
|
}
|
|
323
327
|
}
|
|
324
328
|
|
|
325
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
329
|
+
const metadata = await resolveMetadata(page, layouts, {
|
|
330
|
+
params: matched.params,
|
|
331
|
+
searchParams,
|
|
332
|
+
data,
|
|
333
|
+
});
|
|
326
334
|
return {
|
|
327
335
|
pathname,
|
|
328
336
|
search,
|
|
@@ -362,7 +370,11 @@ async function resolveNotFound(
|
|
|
362
370
|
loadOnce(record.page),
|
|
363
371
|
...record.layouts.map((layout) => loadOnce(layout)),
|
|
364
372
|
]);
|
|
365
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
373
|
+
const metadata = await resolveMetadata(page, layouts, {
|
|
374
|
+
params: {},
|
|
375
|
+
searchParams,
|
|
376
|
+
data: undefined,
|
|
377
|
+
});
|
|
366
378
|
return {
|
|
367
379
|
pathname,
|
|
368
380
|
search,
|
|
@@ -390,7 +402,11 @@ async function resolveMetadata(
|
|
|
390
402
|
}
|
|
391
403
|
if (page.frontmatter != null) {
|
|
392
404
|
const { title, description } = page.frontmatter;
|
|
393
|
-
merged = {
|
|
405
|
+
merged = {
|
|
406
|
+
...merged,
|
|
407
|
+
...(title != null ? { title } : {}),
|
|
408
|
+
...(description != null ? { description } : {}),
|
|
409
|
+
};
|
|
394
410
|
}
|
|
395
411
|
if (page.metadata != null) {
|
|
396
412
|
merged = { ...merged, ...page.metadata };
|
|
@@ -416,32 +432,32 @@ component DefaultNotFound() {
|
|
|
416
432
|
// ---------------------------------------------------------------------------
|
|
417
433
|
|
|
418
434
|
/** How a navigation is performed. */
|
|
419
|
-
export type NavigateOptions = {|
|
|
435
|
+
export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
|
|
420
436
|
|
|
421
437
|
/** What `useRouter()` returns. */
|
|
422
438
|
export type Router = {|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
439
|
+
readonly push: (to: string, options?: NavigateOptions) => Promise<void>,
|
|
440
|
+
readonly replace: (to: string) => Promise<void>,
|
|
441
|
+
readonly prefetch: (to: string) => Promise<void>,
|
|
442
|
+
readonly refresh: () => Promise<void>,
|
|
443
|
+
readonly back: () => void,
|
|
444
|
+
readonly forward: () => void,
|
|
429
445
|
|};
|
|
430
446
|
|
|
431
447
|
/** What `useRoute()` returns. */
|
|
432
448
|
export type RouteInfo = {|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
449
|
+
readonly path: string,
|
|
450
|
+
readonly pathname: string,
|
|
451
|
+
readonly params: RouteParams,
|
|
452
|
+
readonly searchParams: SearchParams,
|
|
453
|
+
readonly data: mixed,
|
|
454
|
+
readonly pending: boolean,
|
|
439
455
|
|};
|
|
440
456
|
|
|
441
457
|
type RouterState = {|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
458
|
+
readonly resolved: ResolvedRoute,
|
|
459
|
+
readonly router: Router,
|
|
460
|
+
readonly pending: boolean,
|
|
445
461
|
|};
|
|
446
462
|
|
|
447
463
|
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
@@ -457,15 +473,17 @@ export function installRoutes(table: RouteTable): void {
|
|
|
457
473
|
/** The registered table, or a clear error when the entry forgot to install it. */
|
|
458
474
|
export function routeTable(): RouteTable {
|
|
459
475
|
if (installedTable == null) {
|
|
460
|
-
throw new Error(
|
|
476
|
+
throw new Error(
|
|
477
|
+
"@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`",
|
|
478
|
+
);
|
|
461
479
|
}
|
|
462
480
|
return installedTable;
|
|
463
481
|
}
|
|
464
482
|
|
|
465
483
|
/** Props the app root receives from the client and server entries. */
|
|
466
484
|
export type AppProps = {|
|
|
467
|
-
|
|
468
|
-
|
|
485
|
+
readonly url: string,
|
|
486
|
+
readonly initial: ResolvedRoute,
|
|
469
487
|
|};
|
|
470
488
|
|
|
471
489
|
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
@@ -547,13 +565,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
547
565
|
if (matched == null) {
|
|
548
566
|
return;
|
|
549
567
|
}
|
|
550
|
-
await Promise.all([
|
|
568
|
+
await Promise.all([
|
|
569
|
+
loadOnce(matched.route.page),
|
|
570
|
+
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
571
|
+
]);
|
|
551
572
|
},
|
|
552
573
|
refresh: async () => {
|
|
553
574
|
if (!isBrowser) {
|
|
554
575
|
return;
|
|
555
576
|
}
|
|
556
|
-
const nextResolved = await resolveMatch(
|
|
577
|
+
const nextResolved = await resolveMatch(
|
|
578
|
+
routeTable(),
|
|
579
|
+
window.location.pathname + window.location.search,
|
|
580
|
+
);
|
|
557
581
|
startTransition(() => {
|
|
558
582
|
setResolved(nextResolved);
|
|
559
583
|
});
|
|
@@ -572,14 +596,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
572
596
|
[navigate],
|
|
573
597
|
);
|
|
574
598
|
|
|
575
|
-
const value = useMemo<RouterState>(
|
|
599
|
+
const value = useMemo<RouterState>(
|
|
600
|
+
() => ({ resolved, router, pending }),
|
|
601
|
+
[resolved, router, pending],
|
|
602
|
+
);
|
|
576
603
|
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
577
604
|
}
|
|
578
605
|
|
|
579
606
|
hook useRouterState(): RouterState {
|
|
580
607
|
const state = useContext(RouterContext);
|
|
581
608
|
if (state == null) {
|
|
582
|
-
throw new Error(
|
|
609
|
+
throw new Error(
|
|
610
|
+
"@uniflowed/router: this hook must be used inside the app started by `routerView`",
|
|
611
|
+
);
|
|
583
612
|
}
|
|
584
613
|
return state;
|
|
585
614
|
}
|
|
@@ -605,7 +634,7 @@ export hook useRouter(): Router {
|
|
|
605
634
|
/** The current page's loader data. */
|
|
606
635
|
export hook useLoaderData<T>(): T {
|
|
607
636
|
// $FlowFixMe[unclear-type] loader data is typed by the page that declares the loader.
|
|
608
|
-
return
|
|
637
|
+
return useRouterState().resolved.data as any;
|
|
609
638
|
}
|
|
610
639
|
|
|
611
640
|
/**
|
|
@@ -637,7 +666,9 @@ export component RouteView() {
|
|
|
637
666
|
function pageComponent(module: PageModule): React.ComponentType<any> {
|
|
638
667
|
const component = module.default ?? module.Page;
|
|
639
668
|
if (component == null) {
|
|
640
|
-
throw new Error(
|
|
669
|
+
throw new Error(
|
|
670
|
+
"@uniflowed/router: a page module must export a component as `default` or `Page`",
|
|
671
|
+
);
|
|
641
672
|
}
|
|
642
673
|
return component;
|
|
643
674
|
}
|
|
@@ -646,7 +677,9 @@ function pageComponent(module: PageModule): React.ComponentType<any> {
|
|
|
646
677
|
function layoutComponent(module: LayoutModule): React.ComponentType<any> {
|
|
647
678
|
const component = module.default ?? module.Layout;
|
|
648
679
|
if (component == null) {
|
|
649
|
-
throw new Error(
|
|
680
|
+
throw new Error(
|
|
681
|
+
"@uniflowed/router: a layout module must export a component as `default` or `Layout`",
|
|
682
|
+
);
|
|
650
683
|
}
|
|
651
684
|
return component;
|
|
652
685
|
}
|
|
@@ -658,9 +691,13 @@ component Head(metadata: Metadata) {
|
|
|
658
691
|
{title != null ? <title>{title}</title> : null}
|
|
659
692
|
{description != null ? <meta name="description" content={description} /> : null}
|
|
660
693
|
{openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
|
|
661
|
-
{openGraph?.description != null ?
|
|
694
|
+
{openGraph?.description != null ? (
|
|
695
|
+
<meta property="og:description" content={openGraph.description} />
|
|
696
|
+
) : null}
|
|
662
697
|
{openGraph?.images != null
|
|
663
|
-
? openGraph.images.map((image) =>
|
|
698
|
+
? openGraph.images.map((image) => (
|
|
699
|
+
<meta key={image} property="og:image" content={image} />
|
|
700
|
+
))
|
|
664
701
|
: null}
|
|
665
702
|
</>
|
|
666
703
|
);
|
|
@@ -683,7 +720,7 @@ export component Link(
|
|
|
683
720
|
children?: React.Node,
|
|
684
721
|
className?: string,
|
|
685
722
|
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
686
|
-
...rest: {
|
|
723
|
+
...rest: { readonly [string]: mixed }
|
|
687
724
|
) {
|
|
688
725
|
const router = useRouter();
|
|
689
726
|
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.4",
|
|
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.4"
|
|
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
|
|