@uniflowed/router 0.0.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -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/deployment.js +160 -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 +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1597 -1329
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
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);
|
|
@@ -26,6 +26,41 @@
|
|
|
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
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
|
+
//
|
|
29
64
|
// It also does not establish the request a handler is inside. The host does,
|
|
30
65
|
// around the whole of it, so a handler and the guard above it share one
|
|
31
66
|
// context; see the same section in `./middleware.js`. This module used to
|
|
@@ -34,6 +69,9 @@
|
|
|
34
69
|
// `after()` promises. A handler that streams its body has not sent a byte at
|
|
35
70
|
// that point. See ubugeeei-prod/uf#389.
|
|
36
71
|
|
|
72
|
+
import { asResponder, noteRoute } from "@uniflowed/server/host";
|
|
73
|
+
|
|
74
|
+
import { matchRoute } from "./internal/routing.js";
|
|
37
75
|
import { requireRequest } from "./internal/request.js";
|
|
38
76
|
import type { RouteParams } from "./internal/runtime.js";
|
|
39
77
|
|
|
@@ -66,8 +104,27 @@ export type HandlerRecord = {|
|
|
|
66
104
|
* — and a module that exports a helper would then answer requests with it.
|
|
67
105
|
* `HEAD` falls back to `GET` with the body dropped, which is what a client
|
|
68
106
|
* asking for headers expects and what nobody remembers to write.
|
|
107
|
+
*
|
|
108
|
+
* It was closed in name only until `QUERY` was added. `pick` looked the method
|
|
109
|
+
* up on the module and this list decided nothing but the order of the `Allow`
|
|
110
|
+
* header, so a module exporting `PURGE` answered `PURGE` — the exact behaviour
|
|
111
|
+
* the paragraph above says is refused. Adding a verb was the moment to make the
|
|
112
|
+
* sentence true, because the alternative was adding one to a list nothing read.
|
|
113
|
+
*
|
|
114
|
+
* `QUERY` is here and `CONNECT` and `TRACE` are not, and the difference is not
|
|
115
|
+
* taste: the `fetch` specification forbids the last two outright, so a handler
|
|
116
|
+
* exporting either could never be reached by a browser.
|
|
69
117
|
*/
|
|
70
|
-
const
|
|
118
|
+
export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
|
|
119
|
+
"GET",
|
|
120
|
+
"HEAD",
|
|
121
|
+
"QUERY",
|
|
122
|
+
"POST",
|
|
123
|
+
"PUT",
|
|
124
|
+
"PATCH",
|
|
125
|
+
"DELETE",
|
|
126
|
+
"OPTIONS",
|
|
127
|
+
]);
|
|
71
128
|
|
|
72
129
|
/**
|
|
73
130
|
* Match a request against the handler table and run it.
|
|
@@ -79,55 +136,70 @@ const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
|
79
136
|
export function createDispatcher(options: {|
|
|
80
137
|
readonly handlers: $ReadOnlyArray<HandlerRecord>,
|
|
81
138
|
|}): (request: Request) => Promise<Response | null> {
|
|
82
|
-
|
|
83
|
-
// catch-all is the last thing tried.
|
|
84
|
-
const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
|
|
139
|
+
const table = [...options.handlers];
|
|
85
140
|
|
|
86
141
|
return async function dispatch(request: Request): Promise<Response | null> {
|
|
87
142
|
// The host's half of the contract, checked rather than assumed; see
|
|
88
143
|
// `./internal/request.js`.
|
|
89
144
|
requireRequest("dispatch");
|
|
90
145
|
const url = new URL(request.url);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
146
|
+
const match = matchRoute(table, url.pathname);
|
|
147
|
+
if (match == null) return null;
|
|
148
|
+
const { route: record, params } = match;
|
|
149
|
+
|
|
150
|
+
// Before the module is loaded and before the method is checked, because
|
|
151
|
+
// this is the answer to "what was this request" and a `405` is as much
|
|
152
|
+
// this route's answer as a `200` is. A log of `/api/users/:id 405` is
|
|
153
|
+
// actionable; the same line with the path in it is a million lines.
|
|
154
|
+
noteRoute(record.path);
|
|
155
|
+
|
|
156
|
+
const module = await record.load();
|
|
157
|
+
const method = request.method.toUpperCase();
|
|
158
|
+
const handler = pick(module, method);
|
|
159
|
+
if (handler == null) {
|
|
160
|
+
return methodNotAllowed(module);
|
|
161
|
+
}
|
|
103
162
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
163
|
+
// In the host's request, so a handler that calls `headers()`,
|
|
164
|
+
// `cookies()` or `after()` answers about the same one its guard did, and
|
|
165
|
+
// what it defers is drained once, by the host, after the bytes are out.
|
|
166
|
+
//
|
|
167
|
+
// And inside `asResponder`, which is the other half: a route handler is
|
|
168
|
+
// one of the two things that owns a response, so it is one of the two
|
|
169
|
+
// places `draftMode().enable()` is allowed — and the `Set-Cookie` it
|
|
170
|
+
// decided on is written onto the response below rather than left on an
|
|
171
|
+
// object the host is about to discard. See ubugeeei-prod/uf#282.
|
|
172
|
+
const response = await asResponder("a route handler", async () =>
|
|
173
|
+
handler(request, { params, searchParams: url.searchParams }),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// A `HEAD` answered by `GET` must not carry the body. The test is
|
|
177
|
+
// against the module's own `HEAD`, not `pick`'s — `pick` falls back to
|
|
178
|
+
// `GET`, so asking it whether a `HEAD` exists always said yes and the
|
|
179
|
+
// body went out anyway.
|
|
180
|
+
if (method === "HEAD" && typeof module.HEAD !== "function") {
|
|
181
|
+
return new Response(null, {
|
|
182
|
+
status: response.status,
|
|
183
|
+
statusText: response.statusText,
|
|
184
|
+
headers: response.headers,
|
|
110
185
|
});
|
|
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
186
|
}
|
|
125
|
-
return
|
|
187
|
+
return response;
|
|
126
188
|
};
|
|
127
189
|
}
|
|
128
190
|
|
|
129
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* The function for a method, falling back to `GET` for `HEAD`.
|
|
193
|
+
*
|
|
194
|
+
* The method is checked against `METHODS` first, which is what makes that list
|
|
195
|
+
* closed rather than decorative: without it a request could name any export,
|
|
196
|
+
* and a module's `PURGE` — or its `DEFAULT`, or a name a bundler added — would
|
|
197
|
+
* answer one.
|
|
198
|
+
*/
|
|
130
199
|
function pick(module: HandlerModule, method: string): Handler | null {
|
|
200
|
+
if (!HANDLER_METHODS.includes(method)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
131
203
|
const own = module[method];
|
|
132
204
|
if (typeof own === "function") {
|
|
133
205
|
return own as $FlowFixMe;
|
|
@@ -145,7 +217,7 @@ function pick(module: HandlerModule, method: string): Handler | null {
|
|
|
145
217
|
* do that here" from "there is nothing here".
|
|
146
218
|
*/
|
|
147
219
|
function methodNotAllowed(module: HandlerModule): Response {
|
|
148
|
-
const own = new Set(
|
|
220
|
+
const own = new Set(HANDLER_METHODS.filter((method) => typeof module[method] === "function"));
|
|
149
221
|
// A module exporting `GET` also answers `HEAD`, so `Allow` has to say so.
|
|
150
222
|
if (own.has("GET")) {
|
|
151
223
|
own.add("HEAD");
|
|
@@ -154,64 +226,6 @@ function methodNotAllowed(module: HandlerModule): Response {
|
|
|
154
226
|
// header reads in the conventional order however the module was written.
|
|
155
227
|
return new Response(null, {
|
|
156
228
|
status: 405,
|
|
157
|
-
headers: { allow:
|
|
229
|
+
headers: { allow: HANDLER_METHODS.filter((method) => own.has(method)).join(", ") },
|
|
158
230
|
});
|
|
159
231
|
}
|
|
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/http-client.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {
|
|
3
|
+
ACTION_CONTENT_TYPE,
|
|
4
|
+
ACTION_HEADER,
|
|
5
|
+
decodeActionResult,
|
|
6
|
+
encodeActionArguments,
|
|
7
|
+
isActionId,
|
|
8
|
+
type ActionValue,
|
|
9
|
+
} from "./internal/action-wire.js";
|
|
10
|
+
|
|
11
|
+
export type RouteClientOptions = {|
|
|
12
|
+
readonly origin: string,
|
|
13
|
+
readonly getToken?: () => Promise<string>,
|
|
14
|
+
readonly fetch?: (url: string, options: RequestOptions) => Promise<Response>,
|
|
15
|
+
readonly allowInsecureDevelopment?: boolean,
|
|
16
|
+
|};
|
|
17
|
+
|
|
18
|
+
export type RouteRequest = {|
|
|
19
|
+
readonly method?: string,
|
|
20
|
+
readonly headers?: { readonly [string]: string },
|
|
21
|
+
readonly body?: string,
|
|
22
|
+
readonly signal?: AbortSignal,
|
|
23
|
+
|};
|
|
24
|
+
|
|
25
|
+
type RequestOptions = {|
|
|
26
|
+
...RouteRequest,
|
|
27
|
+
readonly credentials: "omit",
|
|
28
|
+
readonly redirect: "error",
|
|
29
|
+
|};
|
|
30
|
+
|
|
31
|
+
/** Fetch transport shared by generated typed route clients and native actions. */
|
|
32
|
+
export function createRouteClient(
|
|
33
|
+
options: RouteClientOptions,
|
|
34
|
+
): (path: string, request?: RouteRequest) => Promise<Response> {
|
|
35
|
+
const origin = new URL(options.origin);
|
|
36
|
+
if (
|
|
37
|
+
origin.username ||
|
|
38
|
+
origin.password ||
|
|
39
|
+
origin.pathname !== "/" ||
|
|
40
|
+
origin.search ||
|
|
41
|
+
origin.hash
|
|
42
|
+
) {
|
|
43
|
+
throw new TypeError("createRouteClient origin must contain only a scheme and host");
|
|
44
|
+
}
|
|
45
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
|
|
46
|
+
if (
|
|
47
|
+
origin.protocol !== "https:" &&
|
|
48
|
+
!(origin.protocol === "http:" && (loopback || options.allowInsecureDevelopment === true))
|
|
49
|
+
) {
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
"createRouteClient requires HTTPS outside explicit development connections",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const send = options.fetch ?? globalThis.fetch;
|
|
55
|
+
return async (path, request = {}) => {
|
|
56
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
|
|
57
|
+
throw new TypeError("route client needs a same-origin absolute path");
|
|
58
|
+
}
|
|
59
|
+
const url = new URL(path, origin);
|
|
60
|
+
if (url.origin !== origin.origin) throw new TypeError("route client cannot change origin");
|
|
61
|
+
const headers: { [string]: string } = {};
|
|
62
|
+
for (const [name, value] of Object.entries(request.headers ?? {})) {
|
|
63
|
+
const lower = name.toLowerCase();
|
|
64
|
+
if (
|
|
65
|
+
["cookie", "authorization", "origin", "host"].includes(lower) ||
|
|
66
|
+
lower.startsWith("sec-fetch-")
|
|
67
|
+
) {
|
|
68
|
+
throw new TypeError(`route client does not accept the ${name} header`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof value !== "string") throw new TypeError(`route header ${name} must be a string`);
|
|
71
|
+
headers[name] = value;
|
|
72
|
+
}
|
|
73
|
+
if (options.getToken != null) {
|
|
74
|
+
const token = await options.getToken();
|
|
75
|
+
if (!/^[A-Za-z0-9\-._~+/]+=*$/.test(token) || token.length > 8192) {
|
|
76
|
+
throw new TypeError("route client received an invalid bearer credential");
|
|
77
|
+
}
|
|
78
|
+
headers.authorization = `Bearer ${token}`;
|
|
79
|
+
}
|
|
80
|
+
return send(url.href, { ...request, headers, credentials: "omit", redirect: "error" });
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Explicit action channel for clients holding application-issued bearer tokens. */
|
|
85
|
+
export function createNativeActionClient(options: {|
|
|
86
|
+
...RouteClientOptions,
|
|
87
|
+
readonly getToken: () => Promise<string>,
|
|
88
|
+
|}): (id: string, args: $ReadOnlyArray<mixed>, path?: string) => Promise<ActionValue | void> {
|
|
89
|
+
const send = createRouteClient(options);
|
|
90
|
+
return async (id, args, path = "/") => {
|
|
91
|
+
if (!isActionId(id)) throw new TypeError("native action needs an ID from the current build");
|
|
92
|
+
const response = await send(path, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
[ACTION_HEADER]: id,
|
|
96
|
+
"uf-native-action": "bearer-v1",
|
|
97
|
+
"content-type": ACTION_CONTENT_TYPE,
|
|
98
|
+
},
|
|
99
|
+
body: encodeActionArguments(args),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) throw new Error(`Native action failed with status ${response.status}`);
|
|
102
|
+
return decodeActionResult(await response.text());
|
|
103
|
+
};
|
|
104
|
+
}
|
package/index.js
CHANGED
|
@@ -2,14 +2,32 @@
|
|
|
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
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// `$not-found.js` and `$error.js` are the two boundaries: the page for a
|
|
11
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
|
|
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.
|
|
13
31
|
|
|
14
32
|
import * as React from "react";
|
|
15
33
|
|
|
@@ -19,6 +37,8 @@ export type {
|
|
|
19
37
|
AppProps,
|
|
20
38
|
ErrorBoundary,
|
|
21
39
|
ErrorModule,
|
|
40
|
+
Interception,
|
|
41
|
+
JsonLd,
|
|
22
42
|
LayoutModule,
|
|
23
43
|
LinkPrefetch,
|
|
24
44
|
LoaderArgs,
|
|
@@ -28,6 +48,7 @@ export type {
|
|
|
28
48
|
NotFoundBoundary,
|
|
29
49
|
PageModule,
|
|
30
50
|
ResolvedRoute,
|
|
51
|
+
Robots,
|
|
31
52
|
RouteError,
|
|
32
53
|
RouteInfo,
|
|
33
54
|
RouteMatch,
|
|
@@ -36,7 +57,12 @@ export type {
|
|
|
36
57
|
RouteRecord,
|
|
37
58
|
RouteTable,
|
|
38
59
|
Router,
|
|
60
|
+
ResolvedSlot,
|
|
39
61
|
SearchParams,
|
|
62
|
+
SlotRecord,
|
|
63
|
+
SlotRouteRecord,
|
|
64
|
+
TemplateModule,
|
|
65
|
+
TwitterCard,
|
|
40
66
|
} from "./internal/runtime.js";
|
|
41
67
|
|
|
42
68
|
export {
|
|
@@ -47,6 +73,8 @@ export {
|
|
|
47
73
|
RouteView,
|
|
48
74
|
RouterProvider,
|
|
49
75
|
UnauthorizedError,
|
|
76
|
+
basePath,
|
|
77
|
+
buildRoute,
|
|
50
78
|
forbidden,
|
|
51
79
|
hasClientPage,
|
|
52
80
|
matchRoute,
|
|
@@ -61,9 +89,11 @@ export {
|
|
|
61
89
|
splitUrl,
|
|
62
90
|
unauthorized,
|
|
63
91
|
useIsServer,
|
|
92
|
+
useLinkStatus,
|
|
64
93
|
useLoaderData,
|
|
65
94
|
useRoute,
|
|
66
95
|
useRouter,
|
|
96
|
+
useSeo,
|
|
67
97
|
} from "./internal/runtime.js";
|
|
68
98
|
|
|
69
99
|
/** Props a page receives. */
|
|
@@ -76,13 +106,26 @@ export type PageProps<
|
|
|
76
106
|
readonly data: TData,
|
|
77
107
|
|};
|
|
78
108
|
|
|
79
|
-
/** Props an
|
|
109
|
+
/** Props an `$error.js` component receives. */
|
|
80
110
|
export type ErrorProps = {|
|
|
81
111
|
readonly error: RouteError,
|
|
82
112
|
readonly reset: () => void,
|
|
83
113
|
|};
|
|
84
114
|
|
|
85
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Props a layout receives.
|
|
117
|
+
*
|
|
118
|
+
* A layout on a segment that declares parallel-route slots receives one more
|
|
119
|
+
* prop per slot, named after the directory without its `@`, and this exact
|
|
120
|
+
* type does not describe those — the names are the project's. Declare them: a
|
|
121
|
+
* layout beside `@team` and `@analytics` is
|
|
122
|
+
*
|
|
123
|
+
* component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
|
|
124
|
+
*
|
|
125
|
+
* and the router passes `null` for a slot the URL addressed by neither a route
|
|
126
|
+
* of its own nor a `$default.js`, so `{team ?? <Empty />}` is a thing that
|
|
127
|
+
* can be written and relied on.
|
|
128
|
+
*/
|
|
86
129
|
export type LayoutProps<
|
|
87
130
|
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
88
131
|
> = {|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
export type NavigationTiming = {|
|
|
4
|
+
readonly kind: "document" | "router",
|
|
5
|
+
readonly pathname: string,
|
|
6
|
+
readonly duration: number,
|
|
7
|
+
readonly status: "complete" | "error",
|
|
8
|
+
|};
|
|
9
|
+
|
|
10
|
+
export type ClientInstrumentation = {|
|
|
11
|
+
readonly register?: () => void | Promise<void>,
|
|
12
|
+
readonly onError?: (
|
|
13
|
+
error: mixed,
|
|
14
|
+
context: {| readonly source: "error" | "unhandledrejection" | "navigation" | "startup" |},
|
|
15
|
+
) => void | Promise<void>,
|
|
16
|
+
readonly onNavigation?: (timing: NavigationTiming) => void | Promise<void>,
|
|
17
|
+
|};
|
|
18
|
+
|
|
19
|
+
const observers: Set<ClientInstrumentation> = new Set();
|
|
20
|
+
|
|
21
|
+
function notify(body: () => mixed): void {
|
|
22
|
+
Promise.resolve()
|
|
23
|
+
.then(body)
|
|
24
|
+
.catch((error) => console.error("uf client instrumentation failed", error));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Installed by the client entry before hydration, with disposal during HMR. */
|
|
28
|
+
export async function installClientInstrumentation(
|
|
29
|
+
hooks: ClientInstrumentation,
|
|
30
|
+
): Promise<() => void> {
|
|
31
|
+
const error = (event: ErrorEvent) =>
|
|
32
|
+
notify(() => hooks.onError?.(event.error ?? event.message, { source: "error" }));
|
|
33
|
+
const rejection = (event: PromiseRejectionEvent) =>
|
|
34
|
+
notify(() => hooks.onError?.(event.reason, { source: "unhandledrejection" }));
|
|
35
|
+
window.addEventListener("error", error);
|
|
36
|
+
window.addEventListener("unhandledrejection", rejection);
|
|
37
|
+
observers.add(hooks);
|
|
38
|
+
let performanceObserver = null;
|
|
39
|
+
if (
|
|
40
|
+
typeof PerformanceObserver === "function" &&
|
|
41
|
+
PerformanceObserver.supportedEntryTypes.includes("navigation")
|
|
42
|
+
) {
|
|
43
|
+
performanceObserver = new PerformanceObserver((list) => {
|
|
44
|
+
for (const entry of list.getEntries()) {
|
|
45
|
+
const timing: NavigationTiming = {
|
|
46
|
+
kind: "document",
|
|
47
|
+
pathname: new URL(entry.name).pathname,
|
|
48
|
+
duration: entry.duration,
|
|
49
|
+
status: "complete",
|
|
50
|
+
};
|
|
51
|
+
notify(() => hooks.onNavigation?.(timing));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
performanceObserver.observe({ type: "navigation", buffered: true });
|
|
55
|
+
}
|
|
56
|
+
const dispose = () => {
|
|
57
|
+
window.removeEventListener("error", error);
|
|
58
|
+
window.removeEventListener("unhandledrejection", rejection);
|
|
59
|
+
observers.delete(hooks);
|
|
60
|
+
performanceObserver?.disconnect();
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
await hooks.register?.();
|
|
64
|
+
} catch (failure) {
|
|
65
|
+
notify(() => hooks.onError?.(failure, { source: "startup" }));
|
|
66
|
+
dispose();
|
|
67
|
+
}
|
|
68
|
+
return dispose;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The router's asynchronous navigation work; a hard navigation has browser timing entries. */
|
|
72
|
+
export async function observeNavigation<T>(to: string, body: () => Promise<T>): Promise<T> {
|
|
73
|
+
if (observers.size === 0) return body();
|
|
74
|
+
const started = performance.now();
|
|
75
|
+
const pathname = new URL(to, window.location.href).pathname;
|
|
76
|
+
let status: NavigationTiming["status"] = "complete";
|
|
77
|
+
try {
|
|
78
|
+
return await body();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
status = "error";
|
|
81
|
+
for (const hooks of observers) notify(() => hooks.onError?.(error, { source: "navigation" }));
|
|
82
|
+
throw error;
|
|
83
|
+
} finally {
|
|
84
|
+
const timing: NavigationTiming = {
|
|
85
|
+
kind: "router",
|
|
86
|
+
pathname,
|
|
87
|
+
duration: performance.now() - started,
|
|
88
|
+
status,
|
|
89
|
+
};
|
|
90
|
+
for (const hooks of observers) notify(() => hooks.onNavigation?.(timing));
|
|
91
|
+
}
|
|
92
|
+
}
|