@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.40
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/action.js +301 -0
- package/client.js +295 -8
- package/handler.js +101 -17
- package/index.js +71 -4
- package/internal/action-endpoint.js +434 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +228 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-ssr.js +78 -0
- package/internal/flight.js +158 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/request.js +43 -0
- package/internal/resolve.js +1615 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +474 -0
- package/internal/runtime.js +1511 -543
- package/internal/server-route.js +58 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +1084 -0
- package/middleware.js +350 -0
- package/native.js +408 -0
- package/package.json +36 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +334 -0
- package/server-components.js +159 -0
- package/server.js +448 -75
package/handler.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Route handlers: a path that answers a request instead of rendering a page.
|
|
4
4
|
//
|
|
5
|
-
// `app/api/users
|
|
5
|
+
// `app/api/users/$route.js` exporting `GET` and `POST` serves
|
|
6
6
|
// `/api/users`. A handler takes a `Request` and returns a `Response` — the
|
|
7
7
|
// platform's own types, not a framework's wrapper — because that is what runs
|
|
8
8
|
// unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
|
|
9
9
|
// position is that the host is a capability rather than a target.
|
|
10
10
|
//
|
|
11
|
-
// // app/api/users/[id]
|
|
11
|
+
// // app/api/users/[id]/$route.js
|
|
12
12
|
// // @flow
|
|
13
13
|
// export async function GET(request: Request, context: HandlerContext) {
|
|
14
14
|
// const user = await find(context.params.id);
|
|
@@ -25,8 +25,53 @@
|
|
|
25
25
|
// It does answer `405` itself when the path matches and the method does not,
|
|
26
26
|
// with the `Allow` header the specification requires — that is not the
|
|
27
27
|
// handler's business, and every handler would otherwise write it.
|
|
28
|
+
//
|
|
29
|
+
// # `QUERY`, and what refuses it
|
|
30
|
+
//
|
|
31
|
+
// `QUERY` is a `GET` with a body: safe, idempotent, cacheable, and the method
|
|
32
|
+
// that a search with more parameters than a URL can hold has been faking with a
|
|
33
|
+
// `POST` for twenty years. A handler exports it like any other verb, and this
|
|
34
|
+
// dispatcher matches it like any other verb, because there is nothing special
|
|
35
|
+
// about it *here*. What is special about it is the path between a client and
|
|
36
|
+
// this function, and that is the part worth writing down rather than leaving to
|
|
37
|
+
// be discovered in production.
|
|
38
|
+
//
|
|
39
|
+
// Three things refuse it, and they refuse it differently:
|
|
40
|
+
//
|
|
41
|
+
// * **A client that cannot send it.** The Fetch standard forbids `CONNECT`,
|
|
42
|
+
// `TRACE` and `TRACK` and allows any other token, so every browser and
|
|
43
|
+
// every runtime uf targets can send a `QUERY` today. `XMLHttpRequest` and
|
|
44
|
+
// `EventSource` cannot, and neither can a `<form>`.
|
|
45
|
+
// * **An intermediary that will not forward it.** This is the real one. A
|
|
46
|
+
// proxy, a CDN or a WAF that has a list of methods answers `405` or `501`
|
|
47
|
+
// itself, and the request never arrives — so the failure looks exactly like
|
|
48
|
+
// a route that does not exist, from a server that never saw it.
|
|
49
|
+
// `@uniflowed/fetch` names that case in the error rather than passing the
|
|
50
|
+
// status through, which is the whole of what "stated rather than
|
|
51
|
+
// discovered" can mean from the other end of a wire.
|
|
52
|
+
// * **A cache that does not know it is safe.** `QUERY` is cacheable in
|
|
53
|
+
// principle and the key includes the body, which almost nothing implements.
|
|
54
|
+
// uf's own route cache is `GET`-only and stays that way; anything in front
|
|
55
|
+
// of the application should be told not to store a `QUERY` at all.
|
|
56
|
+
//
|
|
57
|
+
// What uf deliberately does not do about any of it is accept a method-override
|
|
58
|
+
// header. `X-HTTP-Method-Override: QUERY` on a `POST` is the usual workaround
|
|
59
|
+
// and it is the shape of CVE-2025-29927: an inbound header steering dispatch,
|
|
60
|
+
// which `docs/security.md` forbids in the row about that CVE and in rule 3. A
|
|
61
|
+
// route that must work through hostile infrastructure exports `POST` as well
|
|
62
|
+
// and says so in its own file, where a reader can see it.
|
|
63
|
+
//
|
|
64
|
+
// It also does not establish the request a handler is inside. The host does,
|
|
65
|
+
// around the whole of it, so a handler and the guard above it share one
|
|
66
|
+
// context; see the same section in `./middleware.js`. This module used to
|
|
67
|
+
// build its own and drain it the moment the handler returned, which the
|
|
68
|
+
// comment there called "the response is in hand" — true, and not what
|
|
69
|
+
// `after()` promises. A handler that streams its body has not sent a byte at
|
|
70
|
+
// that point. See ubugeeei-prod/uf#389.
|
|
28
71
|
|
|
29
|
-
import {
|
|
72
|
+
import { asResponder, noteRoute } from "@uniflowed/server/host";
|
|
73
|
+
|
|
74
|
+
import { requireRequest } from "./internal/request.js";
|
|
30
75
|
import type { RouteParams } from "./internal/runtime.js";
|
|
31
76
|
|
|
32
77
|
/** What a handler is given besides the request. */
|
|
@@ -58,8 +103,27 @@ export type HandlerRecord = {|
|
|
|
58
103
|
* — and a module that exports a helper would then answer requests with it.
|
|
59
104
|
* `HEAD` falls back to `GET` with the body dropped, which is what a client
|
|
60
105
|
* asking for headers expects and what nobody remembers to write.
|
|
106
|
+
*
|
|
107
|
+
* It was closed in name only until `QUERY` was added. `pick` looked the method
|
|
108
|
+
* up on the module and this list decided nothing but the order of the `Allow`
|
|
109
|
+
* header, so a module exporting `PURGE` answered `PURGE` — the exact behaviour
|
|
110
|
+
* the paragraph above says is refused. Adding a verb was the moment to make the
|
|
111
|
+
* sentence true, because the alternative was adding one to a list nothing read.
|
|
112
|
+
*
|
|
113
|
+
* `QUERY` is here and `CONNECT` and `TRACE` are not, and the difference is not
|
|
114
|
+
* taste: the `fetch` specification forbids the last two outright, so a handler
|
|
115
|
+
* exporting either could never be reached by a browser.
|
|
61
116
|
*/
|
|
62
|
-
const
|
|
117
|
+
export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
|
|
118
|
+
"GET",
|
|
119
|
+
"HEAD",
|
|
120
|
+
"QUERY",
|
|
121
|
+
"POST",
|
|
122
|
+
"PUT",
|
|
123
|
+
"PATCH",
|
|
124
|
+
"DELETE",
|
|
125
|
+
"OPTIONS",
|
|
126
|
+
]);
|
|
63
127
|
|
|
64
128
|
/**
|
|
65
129
|
* Match a request against the handler table and run it.
|
|
@@ -76,6 +140,9 @@ export function createDispatcher(options: {|
|
|
|
76
140
|
const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
|
|
77
141
|
|
|
78
142
|
return async function dispatch(request: Request): Promise<Response | null> {
|
|
143
|
+
// The host's half of the contract, checked rather than assumed; see
|
|
144
|
+
// `./internal/request.js`.
|
|
145
|
+
requireRequest("dispatch");
|
|
79
146
|
const url = new URL(request.url);
|
|
80
147
|
for (const record of table) {
|
|
81
148
|
const params = matchPath(record.path, url.pathname);
|
|
@@ -83,6 +150,12 @@ export function createDispatcher(options: {|
|
|
|
83
150
|
continue;
|
|
84
151
|
}
|
|
85
152
|
|
|
153
|
+
// Before the module is loaded and before the method is checked, because
|
|
154
|
+
// this is the answer to "what was this request" and a `405` is as much
|
|
155
|
+
// this route's answer as a `200` is. A log of `/api/users/:id 405` is
|
|
156
|
+
// actionable; the same line with the path in it is a million lines.
|
|
157
|
+
noteRoute(record.path);
|
|
158
|
+
|
|
86
159
|
const module = await record.load();
|
|
87
160
|
const method = request.method.toUpperCase();
|
|
88
161
|
const handler = pick(module, method);
|
|
@@ -90,17 +163,18 @@ export function createDispatcher(options: {|
|
|
|
90
163
|
return methodNotAllowed(module);
|
|
91
164
|
}
|
|
92
165
|
|
|
93
|
-
//
|
|
94
|
-
// or `after()`
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
166
|
+
// In the host's request, so a handler that calls `headers()`,
|
|
167
|
+
// `cookies()` or `after()` answers about the same one its guard did, and
|
|
168
|
+
// what it defers is drained once, by the host, after the bytes are out.
|
|
169
|
+
//
|
|
170
|
+
// And inside `asResponder`, which is the other half: a route handler is
|
|
171
|
+
// one of the two things that owns a response, so it is one of the two
|
|
172
|
+
// places `draftMode().enable()` is allowed — and the `Set-Cookie` it
|
|
173
|
+
// decided on is written onto the response below rather than left on an
|
|
174
|
+
// object the host is about to discard. See ubugeeei-prod/uf#282.
|
|
175
|
+
const response = await asResponder("a route handler", async () =>
|
|
176
|
+
handler(request, { params, searchParams: url.searchParams }),
|
|
102
177
|
);
|
|
103
|
-
await drainDeferred(context);
|
|
104
178
|
|
|
105
179
|
// A `HEAD` answered by `GET` must not carry the body. The test is
|
|
106
180
|
// against the module's own `HEAD`, not `pick`'s — `pick` falls back to
|
|
@@ -119,8 +193,18 @@ export function createDispatcher(options: {|
|
|
|
119
193
|
};
|
|
120
194
|
}
|
|
121
195
|
|
|
122
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* The function for a method, falling back to `GET` for `HEAD`.
|
|
198
|
+
*
|
|
199
|
+
* The method is checked against `METHODS` first, which is what makes that list
|
|
200
|
+
* closed rather than decorative: without it a request could name any export,
|
|
201
|
+
* and a module's `PURGE` — or its `DEFAULT`, or a name a bundler added — would
|
|
202
|
+
* answer one.
|
|
203
|
+
*/
|
|
123
204
|
function pick(module: HandlerModule, method: string): Handler | null {
|
|
205
|
+
if (!HANDLER_METHODS.includes(method)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
124
208
|
const own = module[method];
|
|
125
209
|
if (typeof own === "function") {
|
|
126
210
|
return own as $FlowFixMe;
|
|
@@ -138,7 +222,7 @@ function pick(module: HandlerModule, method: string): Handler | null {
|
|
|
138
222
|
* do that here" from "there is nothing here".
|
|
139
223
|
*/
|
|
140
224
|
function methodNotAllowed(module: HandlerModule): Response {
|
|
141
|
-
const own = new Set(
|
|
225
|
+
const own = new Set(HANDLER_METHODS.filter((method) => typeof module[method] === "function"));
|
|
142
226
|
// A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
|
|
143
227
|
if (own.has("GET")) {
|
|
144
228
|
own.add("HEAD");
|
|
@@ -147,7 +231,7 @@ function methodNotAllowed(module: HandlerModule): Response {
|
|
|
147
231
|
// header reads in the conventional order however the module was written.
|
|
148
232
|
return new Response(null, {
|
|
149
233
|
status: 405,
|
|
150
|
-
headers: { allow:
|
|
234
|
+
headers: { allow: HANDLER_METHODS.filter((method) => own.has(method)).join(", ") },
|
|
151
235
|
});
|
|
152
236
|
}
|
|
153
237
|
|
package/index.js
CHANGED
|
@@ -2,21 +2,54 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `@uniflowed/router`: the file-system router.
|
|
4
4
|
//
|
|
5
|
-
// Pages live in `app/` as
|
|
6
|
-
//
|
|
5
|
+
// Pages live in `app/` as `$page.js` (or `.mdx`), layouts as
|
|
6
|
+
// `$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
|
+
// `$not-found.js` and `$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 — and the
|
|
13
|
+
// router root always has one of each, so a project that declares neither still
|
|
14
|
+
// answers a 404 inside its own layouts rather than beside them.
|
|
15
|
+
//
|
|
16
|
+
// `$template.js` is a layout that remounts on every navigation, for the
|
|
17
|
+
// cases where a layout's persistence is the wrong default.
|
|
18
|
+
//
|
|
19
|
+
// A directory named `@team` is a parallel-route slot: it contributes no URL
|
|
20
|
+
// segment, and the layout of the segment that holds it receives the slot as a
|
|
21
|
+
// `team` prop beside `children`. The slot's pages are matched against the same
|
|
22
|
+
// URL the page is, so one URL renders two subtrees at once, and
|
|
23
|
+
// `$default.js` is what a slot renders when the URL matched none of its
|
|
24
|
+
// routes. See ubugeeei-prod/uf#267.
|
|
25
|
+
//
|
|
26
|
+
// A directory named `(.)photo` inside a slot is an intercepting route: a client
|
|
27
|
+
// navigation that starts on a page the slot is on, and reaches the URL the
|
|
28
|
+
// directory stands in for, renders it in the slot and leaves the page
|
|
29
|
+
// underneath where it was. A document request for that URL — a reload, a
|
|
30
|
+
// shared link, a prerender — renders the ordinary page.
|
|
31
|
+
|
|
32
|
+
import * as React from "react";
|
|
33
|
+
|
|
34
|
+
import type { RouteError } from "./internal/runtime.js";
|
|
9
35
|
|
|
10
36
|
export type {
|
|
11
37
|
AppProps,
|
|
38
|
+
ErrorBoundary,
|
|
39
|
+
ErrorModule,
|
|
40
|
+
Interception,
|
|
41
|
+
JsonLd,
|
|
12
42
|
LayoutModule,
|
|
13
43
|
LinkPrefetch,
|
|
14
44
|
LoaderArgs,
|
|
15
45
|
Metadata,
|
|
16
46
|
MetadataArgs,
|
|
17
47
|
NavigateOptions,
|
|
48
|
+
NotFoundBoundary,
|
|
18
49
|
PageModule,
|
|
19
50
|
ResolvedRoute,
|
|
51
|
+
Robots,
|
|
52
|
+
RouteError,
|
|
20
53
|
RouteInfo,
|
|
21
54
|
RouteMatch,
|
|
22
55
|
RouteParamSpec,
|
|
@@ -24,27 +57,42 @@ export type {
|
|
|
24
57
|
RouteRecord,
|
|
25
58
|
RouteTable,
|
|
26
59
|
Router,
|
|
60
|
+
ResolvedSlot,
|
|
27
61
|
SearchParams,
|
|
62
|
+
SlotRecord,
|
|
63
|
+
SlotRouteRecord,
|
|
64
|
+
TemplateModule,
|
|
65
|
+
TwitterCard,
|
|
28
66
|
} from "./internal/runtime.js";
|
|
29
67
|
|
|
30
68
|
export {
|
|
69
|
+
ForbiddenError,
|
|
31
70
|
Link,
|
|
32
71
|
NotFoundError,
|
|
33
72
|
RedirectError,
|
|
34
73
|
RouteView,
|
|
35
74
|
RouterProvider,
|
|
75
|
+
UnauthorizedError,
|
|
76
|
+
basePath,
|
|
77
|
+
buildRoute,
|
|
78
|
+
forbidden,
|
|
79
|
+
hasClientPage,
|
|
36
80
|
matchRoute,
|
|
37
81
|
notFound,
|
|
38
82
|
parseSearch,
|
|
39
83
|
permanentRedirect,
|
|
40
84
|
redirect,
|
|
85
|
+
resolveFailure,
|
|
41
86
|
resolveMatch,
|
|
87
|
+
routeErrorStatus,
|
|
42
88
|
routerView,
|
|
43
89
|
splitUrl,
|
|
90
|
+
unauthorized,
|
|
44
91
|
useIsServer,
|
|
45
92
|
useLoaderData,
|
|
46
93
|
useRoute,
|
|
47
94
|
useRouter,
|
|
95
|
+
useSeo,
|
|
48
96
|
} from "./internal/runtime.js";
|
|
49
97
|
|
|
50
98
|
/** Props a page receives. */
|
|
@@ -57,10 +105,29 @@ export type PageProps<
|
|
|
57
105
|
readonly data: TData,
|
|
58
106
|
|};
|
|
59
107
|
|
|
60
|
-
/** Props
|
|
108
|
+
/** Props an `$error.js` component receives. */
|
|
109
|
+
export type ErrorProps = {|
|
|
110
|
+
readonly error: RouteError,
|
|
111
|
+
readonly reset: () => void,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Props a layout receives.
|
|
116
|
+
*
|
|
117
|
+
* A layout on a segment that declares parallel-route slots receives one more
|
|
118
|
+
* prop per slot, named after the directory without its `@`, and this exact
|
|
119
|
+
* type does not describe those — the names are the project's. Declare them: a
|
|
120
|
+
* layout beside `@team` and `@analytics` is
|
|
121
|
+
*
|
|
122
|
+
* component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
|
|
123
|
+
*
|
|
124
|
+
* and the router passes `null` for a slot the URL addressed by neither a route
|
|
125
|
+
* of its own nor a `$default.js`, so `{team ?? <Empty />}` is a thing that
|
|
126
|
+
* can be written and relied on.
|
|
127
|
+
*/
|
|
61
128
|
export type LayoutProps<
|
|
62
129
|
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
63
130
|
> = {|
|
|
64
131
|
readonly params: TParams,
|
|
65
|
-
readonly children: React
|
|
132
|
+
readonly children: React.Node,
|
|
66
133
|
|};
|