@uniflowed/router 0.0.0-alpha.1 → 0.0.0-alpha.11
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 +39 -6
- package/handler.js +217 -0
- package/index.js +35 -7
- package/internal/document.js +24 -0
- package/internal/request.js +43 -0
- package/internal/runtime.js +1121 -130
- package/internal/stream.js +627 -0
- package/middleware.js +211 -0
- package/package.json +12 -5
- package/server.js +324 -40
package/client.js
CHANGED
|
@@ -6,25 +6,58 @@
|
|
|
6
6
|
// The current route's chunks are loaded and its embedded loader data read
|
|
7
7
|
// *before* `hydrateRoot`, so the first client render is synchronous and
|
|
8
8
|
// matches the server's markup exactly.
|
|
9
|
+
//
|
|
10
|
+
// # A route can decline to be hydrated
|
|
11
|
+
//
|
|
12
|
+
// uf's server-component analysis decides which routes have a `"use client"`
|
|
13
|
+
// boundary anywhere in them, and `@uniflowed/vite` leaves the page out of the
|
|
14
|
+
// client route table for the ones that have none. Such a route has nothing in
|
|
15
|
+
// the browser to attach: the document the server wrote is the whole of it. So
|
|
16
|
+
// this returns without calling `hydrateRoot`, and the `<a>` elements a `Link`
|
|
17
|
+
// rendered stay what the server made them — real links the browser follows.
|
|
18
|
+
// See ubugeeei-prod/uf#350.
|
|
9
19
|
|
|
10
20
|
import * as React from "react";
|
|
11
21
|
import { startTransition } from "react";
|
|
12
22
|
import { hydrateRoot } from "react-dom/client";
|
|
13
23
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
24
|
+
import {
|
|
25
|
+
type AppProps,
|
|
26
|
+
type RouteTable,
|
|
27
|
+
hasClientPage,
|
|
28
|
+
installRoutes,
|
|
29
|
+
matchRoute,
|
|
30
|
+
resolveMatch,
|
|
31
|
+
} from "./internal/runtime.js";
|
|
32
|
+
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
16
33
|
|
|
17
34
|
/**
|
|
18
35
|
* Hydrate the current document.
|
|
36
|
+
*
|
|
37
|
+
* Resolves without mounting anything when the current route ships no client
|
|
38
|
+
* page — see the header. The promise settling is not a claim that React is on
|
|
39
|
+
* the document.
|
|
19
40
|
*/
|
|
20
41
|
export async function hydrate(options: {|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
readonly App: React.ComponentType<AppProps>,
|
|
43
|
+
readonly routes: RouteTable["routes"],
|
|
44
|
+
readonly notFound: RouteTable["notFound"],
|
|
45
|
+
readonly errors: RouteTable["errors"],
|
|
24
46
|
|}): Promise<void> {
|
|
25
|
-
const table: RouteTable = {
|
|
47
|
+
const table: RouteTable = {
|
|
48
|
+
routes: options.routes,
|
|
49
|
+
notFound: options.notFound,
|
|
50
|
+
errors: options.errors,
|
|
51
|
+
};
|
|
26
52
|
installRoutes(table);
|
|
27
53
|
|
|
54
|
+
// Before the loader data is read and before `resolveMatch` is called: both
|
|
55
|
+
// would go looking for a page module that is not in this bundle.
|
|
56
|
+
const matched = matchRoute(table.routes, window.location.pathname);
|
|
57
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
const url = window.location.pathname + window.location.search;
|
|
29
62
|
const embedded = document.getElementById(DATA_ID);
|
|
30
63
|
const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
|
package/handler.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
// It also does not establish the request a handler is inside. The host does,
|
|
30
|
+
// around the whole of it, so a handler and the guard above it share one
|
|
31
|
+
// context; see the same section in `./middleware.js`. This module used to
|
|
32
|
+
// build its own and drain it the moment the handler returned, which the
|
|
33
|
+
// comment there called "the response is in hand" — true, and not what
|
|
34
|
+
// `after()` promises. A handler that streams its body has not sent a byte at
|
|
35
|
+
// that point. See ubugeeei-prod/uf#389.
|
|
36
|
+
|
|
37
|
+
import { requireRequest } from "./internal/request.js";
|
|
38
|
+
import type { RouteParams } from "./internal/runtime.js";
|
|
39
|
+
|
|
40
|
+
/** What a handler is given besides the request. */
|
|
41
|
+
export type HandlerContext = {|
|
|
42
|
+
/** The `[param]` and `[...rest]` segments of the matched path. */
|
|
43
|
+
readonly params: RouteParams,
|
|
44
|
+
/** The parsed query string, for the common case of reading one value. */
|
|
45
|
+
readonly searchParams: URLSearchParams,
|
|
46
|
+
|};
|
|
47
|
+
|
|
48
|
+
/** One exported method of a handler module. */
|
|
49
|
+
export type Handler = (request: Request, context: HandlerContext) => Response | Promise<Response>;
|
|
50
|
+
|
|
51
|
+
/** A handler module, as the generated table loads it. */
|
|
52
|
+
export type HandlerModule = { readonly [method: string]: mixed };
|
|
53
|
+
|
|
54
|
+
/** One entry of the generated handler table. */
|
|
55
|
+
export type HandlerRecord = {|
|
|
56
|
+
readonly path: string,
|
|
57
|
+
readonly params: $ReadOnlyArray<{| readonly name: string, readonly catchAll: boolean |}>,
|
|
58
|
+
readonly file: string,
|
|
59
|
+
readonly load: () => Promise<HandlerModule>,
|
|
60
|
+
|};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The methods a handler may export.
|
|
64
|
+
*
|
|
65
|
+
* A closed list, because the alternative is treating every export as a method
|
|
66
|
+
* — and a module that exports a helper would then answer requests with it.
|
|
67
|
+
* `HEAD` falls back to `GET` with the body dropped, which is what a client
|
|
68
|
+
* asking for headers expects and what nobody remembers to write.
|
|
69
|
+
*/
|
|
70
|
+
const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Match a request against the handler table and run it.
|
|
74
|
+
*
|
|
75
|
+
* Returns `null` when no path matches, which is the caller's signal to carry
|
|
76
|
+
* on — a request for `/about` is a page, and the dispatcher declining is how
|
|
77
|
+
* it says so.
|
|
78
|
+
*/
|
|
79
|
+
export function createDispatcher(options: {|
|
|
80
|
+
readonly handlers: $ReadOnlyArray<HandlerRecord>,
|
|
81
|
+
|}): (request: Request) => Promise<Response | null> {
|
|
82
|
+
// Longest path first, so `/api/users/new` wins over `/api/users/[id]` and a
|
|
83
|
+
// catch-all is the last thing tried.
|
|
84
|
+
const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
|
|
85
|
+
|
|
86
|
+
return async function dispatch(request: Request): Promise<Response | null> {
|
|
87
|
+
// The host's half of the contract, checked rather than assumed; see
|
|
88
|
+
// `./internal/request.js`.
|
|
89
|
+
requireRequest("dispatch");
|
|
90
|
+
const url = new URL(request.url);
|
|
91
|
+
for (const record of table) {
|
|
92
|
+
const params = matchPath(record.path, url.pathname);
|
|
93
|
+
if (params == null) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const module = await record.load();
|
|
98
|
+
const method = request.method.toUpperCase();
|
|
99
|
+
const handler = pick(module, method);
|
|
100
|
+
if (handler == null) {
|
|
101
|
+
return methodNotAllowed(module);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// In the host's request, so a handler that calls `headers()`,
|
|
105
|
+
// `cookies()` or `after()` answers about the same one its guard did, and
|
|
106
|
+
// what it defers is drained once, by the host, after the bytes are out.
|
|
107
|
+
const response = await handler(request, {
|
|
108
|
+
params,
|
|
109
|
+
searchParams: url.searchParams,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// A `HEAD` answered by `GET` must not carry the body. The test is
|
|
113
|
+
// against the module's own `HEAD`, not `pick`'s — `pick` falls back to
|
|
114
|
+
// `GET`, so asking it whether a `HEAD` exists always said yes and the
|
|
115
|
+
// body went out anyway.
|
|
116
|
+
if (method === "HEAD" && typeof module.HEAD !== "function") {
|
|
117
|
+
return new Response(null, {
|
|
118
|
+
status: response.status,
|
|
119
|
+
statusText: response.statusText,
|
|
120
|
+
headers: response.headers,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return response;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The function for a method, falling back to `GET` for `HEAD`. */
|
|
130
|
+
function pick(module: HandlerModule, method: string): Handler | null {
|
|
131
|
+
const own = module[method];
|
|
132
|
+
if (typeof own === "function") {
|
|
133
|
+
return own as $FlowFixMe;
|
|
134
|
+
}
|
|
135
|
+
if (method === "HEAD" && typeof module.GET === "function") {
|
|
136
|
+
return module.GET as $FlowFixMe;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `405`, with the `Allow` header naming what the path does accept.
|
|
143
|
+
*
|
|
144
|
+
* Required by the specification, and the reason a client can tell "you may not
|
|
145
|
+
* do that here" from "there is nothing here".
|
|
146
|
+
*/
|
|
147
|
+
function methodNotAllowed(module: HandlerModule): Response {
|
|
148
|
+
const own = new Set(METHODS.filter((method) => typeof module[method] === "function"));
|
|
149
|
+
// A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
|
|
150
|
+
if (own.has("GET")) {
|
|
151
|
+
own.add("HEAD");
|
|
152
|
+
}
|
|
153
|
+
// Filtered through `METHODS` rather than listed in insertion order, so the
|
|
154
|
+
// header reads in the conventional order however the module was written.
|
|
155
|
+
return new Response(null, {
|
|
156
|
+
status: 405,
|
|
157
|
+
headers: { allow: METHODS.filter((method) => own.has(method)).join(", ") },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Match one route path against a pathname, returning its parameters.
|
|
163
|
+
*
|
|
164
|
+
* `null` rather than an empty object when it does not match, so a route with
|
|
165
|
+
* no parameters is still distinguishable from a miss.
|
|
166
|
+
*/
|
|
167
|
+
function matchPath(routePath: string, pathname: string): RouteParams | null {
|
|
168
|
+
const wanted = segmentsOf(routePath);
|
|
169
|
+
const given = segmentsOf(pathname);
|
|
170
|
+
const params: { [string]: string | Array<string> } = {};
|
|
171
|
+
|
|
172
|
+
for (let index = 0; index < wanted.length; index += 1) {
|
|
173
|
+
const segment = wanted[index];
|
|
174
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
175
|
+
// A catch-all takes the rest, and matches zero segments as well as many.
|
|
176
|
+
params[segment.slice(1, -1)] = given.slice(index);
|
|
177
|
+
return params as $FlowFixMe;
|
|
178
|
+
}
|
|
179
|
+
if (index >= given.length) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (segment.startsWith(":")) {
|
|
183
|
+
params[segment.slice(1)] = given[index];
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (segment !== given[index]) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return wanted.length === given.length ? (params as $FlowFixMe) : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function segmentsOf(value: string): Array<string> {
|
|
195
|
+
return value.split("/").filter((segment) => segment !== "");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* How specific a path is, so the table can be tried in the right order.
|
|
200
|
+
*
|
|
201
|
+
* A literal segment is worth more than a parameter and a parameter more than a
|
|
202
|
+
* catch-all, and a longer path outranks a shorter one — which is what makes
|
|
203
|
+
* `/api/users/new` win over `/api/users/[id]`.
|
|
204
|
+
*/
|
|
205
|
+
function specificity(routePath: string): number {
|
|
206
|
+
let score = 0;
|
|
207
|
+
for (const segment of segmentsOf(routePath)) {
|
|
208
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
209
|
+
score += 1;
|
|
210
|
+
} else if (segment.startsWith(":")) {
|
|
211
|
+
score += 10;
|
|
212
|
+
} else {
|
|
213
|
+
score += 100;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return score;
|
|
217
|
+
}
|
package/index.js
CHANGED
|
@@ -6,17 +6,29 @@
|
|
|
6
6
|
// `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
|
|
7
7
|
// is generated from the directory at build time; this module is the runtime
|
|
8
8
|
// that matches, loads, navigates and renders it.
|
|
9
|
+
//
|
|
10
|
+
// `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
|
|
11
|
+
// path that matched nothing, and what renders in place of a subtree that threw.
|
|
12
|
+
// Both are segment files, resolved by the nearest one above the path.
|
|
13
|
+
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
|
|
16
|
+
import type { RouteError } from "./internal/runtime.js";
|
|
9
17
|
|
|
10
18
|
export type {
|
|
11
19
|
AppProps,
|
|
20
|
+
ErrorBoundary,
|
|
21
|
+
ErrorModule,
|
|
12
22
|
LayoutModule,
|
|
13
23
|
LinkPrefetch,
|
|
14
24
|
LoaderArgs,
|
|
15
25
|
Metadata,
|
|
16
26
|
MetadataArgs,
|
|
17
27
|
NavigateOptions,
|
|
28
|
+
NotFoundBoundary,
|
|
18
29
|
PageModule,
|
|
19
30
|
ResolvedRoute,
|
|
31
|
+
RouteError,
|
|
20
32
|
RouteInfo,
|
|
21
33
|
RouteMatch,
|
|
22
34
|
RouteParamSpec,
|
|
@@ -25,22 +37,30 @@ export type {
|
|
|
25
37
|
RouteTable,
|
|
26
38
|
Router,
|
|
27
39
|
SearchParams,
|
|
40
|
+
TwitterCard,
|
|
28
41
|
} from "./internal/runtime.js";
|
|
29
42
|
|
|
30
43
|
export {
|
|
44
|
+
ForbiddenError,
|
|
31
45
|
Link,
|
|
32
46
|
NotFoundError,
|
|
33
47
|
RedirectError,
|
|
34
48
|
RouteView,
|
|
35
49
|
RouterProvider,
|
|
50
|
+
UnauthorizedError,
|
|
51
|
+
forbidden,
|
|
52
|
+
hasClientPage,
|
|
36
53
|
matchRoute,
|
|
37
54
|
notFound,
|
|
38
55
|
parseSearch,
|
|
39
56
|
permanentRedirect,
|
|
40
57
|
redirect,
|
|
58
|
+
resolveFailure,
|
|
41
59
|
resolveMatch,
|
|
60
|
+
routeErrorStatus,
|
|
42
61
|
routerView,
|
|
43
62
|
splitUrl,
|
|
63
|
+
unauthorized,
|
|
44
64
|
useIsServer,
|
|
45
65
|
useLoaderData,
|
|
46
66
|
useRoute,
|
|
@@ -49,16 +69,24 @@ export {
|
|
|
49
69
|
|
|
50
70
|
/** Props a page receives. */
|
|
51
71
|
export type PageProps<
|
|
52
|
-
TParams
|
|
72
|
+
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
53
73
|
TData = void,
|
|
54
74
|
> = {|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
readonly params: TParams,
|
|
76
|
+
readonly searchParams: { readonly [string]: string },
|
|
77
|
+
readonly data: TData,
|
|
78
|
+
|};
|
|
79
|
+
|
|
80
|
+
/** Props an `_uf.error.js` component receives. */
|
|
81
|
+
export type ErrorProps = {|
|
|
82
|
+
readonly error: RouteError,
|
|
83
|
+
readonly reset: () => void,
|
|
58
84
|
|};
|
|
59
85
|
|
|
60
86
|
/** Props a layout receives. */
|
|
61
|
-
export type LayoutProps<
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
export type LayoutProps<
|
|
88
|
+
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
89
|
+
> = {|
|
|
90
|
+
readonly params: TParams,
|
|
91
|
+
readonly children: React.Node,
|
|
64
92
|
|};
|
|
@@ -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";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the request the server half runs inside.
|
|
4
|
+
//
|
|
5
|
+
// One function, and it exists because two modules need the same refusal.
|
|
6
|
+
// `createMiddlewareRunner` and `createDispatcher` both used to build a request
|
|
7
|
+
// context of their own — `contextFor`, then `runWithContext`, then
|
|
8
|
+
// `drainDeferred` — which gave one request up to two contexts and ran what
|
|
9
|
+
// `after()` deferred before the response was written, and in the middleware's
|
|
10
|
+
// case before there was a response at all. See ubugeeei-prod/uf#389.
|
|
11
|
+
//
|
|
12
|
+
// Both now run inside whatever request the host established, which means both
|
|
13
|
+
// depend on the host having established one. That dependency is checked rather
|
|
14
|
+
// than assumed, for the reason `createApplicationHandler` gives about
|
|
15
|
+
// `entry.runMiddleware` itself: a missing half of the contract should be a
|
|
16
|
+
// failure on the first request, not an application that answers strangely.
|
|
17
|
+
//
|
|
18
|
+
// Server-only, like the two modules that import it. Nothing the browser loads
|
|
19
|
+
// reaches this.
|
|
20
|
+
|
|
21
|
+
import { insideRequest } from "@uniflowed/server/host";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Refuse to run outside a request, naming what has to establish one.
|
|
25
|
+
*
|
|
26
|
+
* The message is addressed to whoever wired the host, because that is who can
|
|
27
|
+
* fix it. Left to fail on its own, a mis-wired host would surface as the first
|
|
28
|
+
* `cookies()` in somebody's guard throwing "called outside a request … a
|
|
29
|
+
* static prerender, a module's top level, or a client component" — three
|
|
30
|
+
* places to look, none of them this one — and an application that calls no
|
|
31
|
+
* server function at all would sail past that and lose every `after()` instead.
|
|
32
|
+
*/
|
|
33
|
+
export function requireRequest(entry: string): void {
|
|
34
|
+
if (insideRequest()) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(
|
|
38
|
+
`@uniflowed/router: ${entry}() was called outside a request. A host owns the request: ` +
|
|
39
|
+
"begin it with `beginRequest` from `@uniflowed/server/host` (a bundled application " +
|
|
40
|
+
"re-exports it from `virtual:uf/server`), run the whole request inside `run`, and call " +
|
|
41
|
+
"`settle` once the response has been sent. See ubugeeei-prod/uf#389.",
|
|
42
|
+
);
|
|
43
|
+
}
|