@uniflowed/router 0.0.0-alpha.40 → 0.0.0-alpha.42
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/handler.js +42 -105
- package/http-client.js +104 -0
- package/index.js +1 -0
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +7 -3
- package/internal/flight-browser.js +12 -2
- package/internal/flight.js +25 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +37 -0
- package/internal/resolve.js +11 -9
- package/internal/routing.js +4 -0
- package/internal/runtime.js +144 -41
- package/internal/server-instrumentation.js +12 -0
- package/internal/stream.js +24 -9
- package/native-navigation.js +217 -0
- package/native.js +10 -2
- package/package.json +19 -5
- package/rsc-ssr.js +10 -4
- package/rsc.js +70 -9
- package/server-components.js +1 -1
- package/server.js +18 -4
package/handler.js
CHANGED
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
|
|
72
72
|
import { asResponder, noteRoute } from "@uniflowed/server/host";
|
|
73
73
|
|
|
74
|
+
import { matchRoute } from "./internal/routing.js";
|
|
74
75
|
import { requireRequest } from "./internal/request.js";
|
|
75
76
|
import type { RouteParams } from "./internal/runtime.js";
|
|
76
77
|
|
|
@@ -135,61 +136,55 @@ export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
|
|
|
135
136
|
export function createDispatcher(options: {|
|
|
136
137
|
readonly handlers: $ReadOnlyArray<HandlerRecord>,
|
|
137
138
|
|}): (request: Request) => Promise<Response | null> {
|
|
138
|
-
|
|
139
|
-
// catch-all is the last thing tried.
|
|
140
|
-
const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
|
|
139
|
+
const table = [...options.handlers];
|
|
141
140
|
|
|
142
141
|
return async function dispatch(request: Request): Promise<Response | null> {
|
|
143
142
|
// The host's half of the contract, checked rather than assumed; see
|
|
144
143
|
// `./internal/request.js`.
|
|
145
144
|
requireRequest("dispatch");
|
|
146
145
|
const url = new URL(request.url);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return methodNotAllowed(module);
|
|
164
|
-
}
|
|
165
|
-
|
|
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 }),
|
|
177
|
-
);
|
|
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
|
+
}
|
|
178
162
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
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,
|
|
185
|
+
});
|
|
191
186
|
}
|
|
192
|
-
return
|
|
187
|
+
return response;
|
|
193
188
|
};
|
|
194
189
|
}
|
|
195
190
|
|
|
@@ -234,61 +229,3 @@ function methodNotAllowed(module: HandlerModule): Response {
|
|
|
234
229
|
headers: { allow: HANDLER_METHODS.filter((method) => own.has(method)).join(", ") },
|
|
235
230
|
});
|
|
236
231
|
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Match one route path against a pathname, returning its parameters.
|
|
240
|
-
*
|
|
241
|
-
* `null` rather than an empty object when it does not match, so a route with
|
|
242
|
-
* no parameters is still distinguishable from a miss.
|
|
243
|
-
*/
|
|
244
|
-
function matchPath(routePath: string, pathname: string): RouteParams | null {
|
|
245
|
-
const wanted = segmentsOf(routePath);
|
|
246
|
-
const given = segmentsOf(pathname);
|
|
247
|
-
const params: { [string]: string | Array<string> } = {};
|
|
248
|
-
|
|
249
|
-
for (let index = 0; index < wanted.length; index += 1) {
|
|
250
|
-
const segment = wanted[index];
|
|
251
|
-
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
252
|
-
// A catch-all takes the rest, and matches zero segments as well as many.
|
|
253
|
-
params[segment.slice(1, -1)] = given.slice(index);
|
|
254
|
-
return params as $FlowFixMe;
|
|
255
|
-
}
|
|
256
|
-
if (index >= given.length) {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
if (segment.startsWith(":")) {
|
|
260
|
-
params[segment.slice(1)] = given[index];
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
if (segment !== given[index]) {
|
|
264
|
-
return null;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
return wanted.length === given.length ? (params as $FlowFixMe) : null;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function segmentsOf(value: string): Array<string> {
|
|
272
|
-
return value.split("/").filter((segment) => segment !== "");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* How specific a path is, so the table can be tried in the right order.
|
|
277
|
-
*
|
|
278
|
-
* A literal segment is worth more than a parameter and a parameter more than a
|
|
279
|
-
* catch-all, and a longer path outranks a shorter one — which is what makes
|
|
280
|
-
* `/api/users/new` win over `/api/users/[id]`.
|
|
281
|
-
*/
|
|
282
|
-
function specificity(routePath: string): number {
|
|
283
|
-
let score = 0;
|
|
284
|
-
for (const segment of segmentsOf(routePath)) {
|
|
285
|
-
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
286
|
-
score += 1;
|
|
287
|
-
} else if (segment.startsWith(":")) {
|
|
288
|
-
score += 10;
|
|
289
|
-
} else {
|
|
290
|
-
score += 100;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return score;
|
|
294
|
-
}
|
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
|
@@ -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
|
+
}
|
|
@@ -88,7 +88,9 @@
|
|
|
88
88
|
// it. Written here because this is the file somebody reads before deciding
|
|
89
89
|
// otherwise.
|
|
90
90
|
|
|
91
|
-
import {
|
|
91
|
+
import { traceRequestPhase, reportRequestError } from "@uniflowed/server/instrumentation";
|
|
92
|
+
|
|
93
|
+
import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
|
|
92
94
|
|
|
93
95
|
import {
|
|
94
96
|
ACTION_CONTENT_TYPE,
|
|
@@ -163,7 +165,8 @@ export function createActionDispatcher(options: {|
|
|
|
163
165
|
if (request.method.toUpperCase() !== "POST") {
|
|
164
166
|
return new Response(null, { status: 405, headers: { ...ANSWER_HEADERS, allow: "POST" } });
|
|
165
167
|
}
|
|
166
|
-
|
|
168
|
+
const native = request.headers.has("uf-native-action");
|
|
169
|
+
if (native ? !nativeActionAllowed(request) : !sameOrigin(request)) {
|
|
167
170
|
return refusal(403);
|
|
168
171
|
}
|
|
169
172
|
if (!isJson(request.headers.get("content-type"))) {
|
|
@@ -231,7 +234,7 @@ export function createActionDispatcher(options: {|
|
|
|
231
234
|
// `decodeActionArguments` produced, so what is unchecked here is the
|
|
232
235
|
// shape of the function and not the shape of the payload.
|
|
233
236
|
const call = action as $FlowFixMe;
|
|
234
|
-
result = await call(...args);
|
|
237
|
+
result = await traceRequestPhase("action", () => call(...args));
|
|
235
238
|
} catch (error) {
|
|
236
239
|
report(record, error);
|
|
237
240
|
return refusal(500);
|
|
@@ -429,6 +432,7 @@ function refusal(status: number): Response {
|
|
|
429
432
|
* here and nowhere the caller can read.
|
|
430
433
|
*/
|
|
431
434
|
function report(record: ActionRecord, error: mixed): void {
|
|
435
|
+
reportRequestError(error, "action");
|
|
432
436
|
// eslint-disable-next-line no-console
|
|
433
437
|
console.error(`uf: server action \`${record.export}\` in \`${record.module}\` failed`, error);
|
|
434
438
|
}
|
|
@@ -28,7 +28,9 @@ import { createFromFetch, createFromReadableStream } from "react-server-dom-parc
|
|
|
28
28
|
import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
|
|
29
29
|
import {
|
|
30
30
|
FLIGHT_CONTENT_TYPE,
|
|
31
|
+
INTERCEPTED_FROM_HEADER,
|
|
31
32
|
type FetchedFlight,
|
|
33
|
+
type FlightFetchOptions,
|
|
32
34
|
type FlightRoot,
|
|
33
35
|
documentPathOf,
|
|
34
36
|
flightUrl,
|
|
@@ -186,16 +188,24 @@ export function readDocumentPayload(
|
|
|
186
188
|
* static host's `404.html` for a file it does not have, a middleware's own
|
|
187
189
|
* refusal — is a document, whatever its status.
|
|
188
190
|
*/
|
|
189
|
-
export async function fetchFlight(
|
|
191
|
+
export async function fetchFlight(
|
|
192
|
+
url: string,
|
|
193
|
+
options?: FlightFetchOptions,
|
|
194
|
+
): Promise<FetchedFlight> {
|
|
190
195
|
// Outside the `try` below, which answers every failure to fetch with a
|
|
191
196
|
// document load: too old a React is not a network failure, and a navigation
|
|
192
197
|
// that quietly reloaded the page would hide it.
|
|
193
198
|
requireServerComponentsReact(ENTRY);
|
|
199
|
+
const headers = new Headers({ accept: FLIGHT_CONTENT_TYPE });
|
|
200
|
+
const interceptedFrom = options?.interceptedFrom;
|
|
201
|
+
if (interceptedFrom != null) {
|
|
202
|
+
headers.set(INTERCEPTED_FROM_HEADER, interceptedFrom);
|
|
203
|
+
}
|
|
194
204
|
let response: Response;
|
|
195
205
|
try {
|
|
196
206
|
response = await fetch(flightUrl(url), {
|
|
197
207
|
credentials: "same-origin",
|
|
198
|
-
headers
|
|
208
|
+
headers,
|
|
199
209
|
});
|
|
200
210
|
} catch {
|
|
201
211
|
// A request that could not be made or followed: a dropped connection, or a
|
package/internal/flight.js
CHANGED
|
@@ -52,6 +52,7 @@ export type RouteState = {|
|
|
|
52
52
|
readonly viewTransition: ?string,
|
|
53
53
|
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
54
54
|
readonly error: ?RouteError,
|
|
55
|
+
readonly interception?: ?FlightInterception,
|
|
55
56
|
|};
|
|
56
57
|
|
|
57
58
|
/** Row 0 of a route's payload: the route, and the tree it rendered. */
|
|
@@ -60,6 +61,18 @@ export type FlightRoot = {|
|
|
|
60
61
|
readonly tree: Node,
|
|
61
62
|
|};
|
|
62
63
|
|
|
64
|
+
/** The intercepted URL a Flight payload rendered, and the page it rendered over. */
|
|
65
|
+
export type FlightInterception = {|
|
|
66
|
+
readonly pathname: string,
|
|
67
|
+
readonly search: string,
|
|
68
|
+
readonly from: string,
|
|
69
|
+
|};
|
|
70
|
+
|
|
71
|
+
/** What the browser may send with a payload request. */
|
|
72
|
+
export type FlightFetchOptions = {|
|
|
73
|
+
readonly interceptedFrom?: string,
|
|
74
|
+
|};
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* What fetching a route's payload turned into.
|
|
65
78
|
*
|
|
@@ -88,6 +101,7 @@ export type FetchedFlight =
|
|
|
88
101
|
|
|
89
102
|
/** The part of a resolved route that crosses to the browser. */
|
|
90
103
|
export function routeState(resolved: ResolvedRoute): RouteState {
|
|
104
|
+
const interception = resolved.interception;
|
|
91
105
|
return {
|
|
92
106
|
pathname: resolved.pathname,
|
|
93
107
|
search: resolved.search,
|
|
@@ -100,6 +114,14 @@ export function routeState(resolved: ResolvedRoute): RouteState {
|
|
|
100
114
|
viewTransition: resolved.viewTransition,
|
|
101
115
|
status: resolved.status,
|
|
102
116
|
error: resolved.error,
|
|
117
|
+
interception:
|
|
118
|
+
interception == null
|
|
119
|
+
? null
|
|
120
|
+
: {
|
|
121
|
+
pathname: interception.pathname,
|
|
122
|
+
search: interception.search,
|
|
123
|
+
from: interception.base.pathname + interception.base.search,
|
|
124
|
+
},
|
|
103
125
|
};
|
|
104
126
|
}
|
|
105
127
|
|
|
@@ -123,6 +145,9 @@ export const FLIGHT_SEGMENT: string = "__uf.flight";
|
|
|
123
145
|
/** The content type a payload is answered with. */
|
|
124
146
|
export const FLIGHT_CONTENT_TYPE: string = "text/x-component";
|
|
125
147
|
|
|
148
|
+
/** The request header that carries the page an intercepted payload is rendered over. */
|
|
149
|
+
export const INTERCEPTED_FROM_HEADER: string = "uf-intercepted-from";
|
|
150
|
+
|
|
126
151
|
/**
|
|
127
152
|
* The URL of `url`'s payload: its pathname with [`FLIGHT_SEGMENT`] appended,
|
|
128
153
|
* and its query string unchanged.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import type { RouteTable } from "./routing.js";
|
|
3
|
+
import { hasClientPage, matchRoute } from "./routing.js";
|
|
4
|
+
|
|
5
|
+
type Table = RouteTable<mixed, mixed, mixed, mixed, mixed>;
|
|
6
|
+
|
|
7
|
+
/** One parser for cold starts, warm deliveries and programmatic navigation. */
|
|
8
|
+
export function nativeLinkHref(table: Table, to: string): string | null {
|
|
9
|
+
const links = table.nativeLinks;
|
|
10
|
+
if (links == null || !/^https:\/\//i.test(to) || /[\\\u0000-\u0020]/.test(to)) return null;
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(to);
|
|
13
|
+
if (url.username !== "" || url.password !== "" || url.hash !== "") return null;
|
|
14
|
+
// URL does not reject malformed percent escapes. Encoded separators must
|
|
15
|
+
// not change the route when another layer decodes the path a second time.
|
|
16
|
+
decodeURIComponent(url.pathname + url.search);
|
|
17
|
+
if (/%(?:2f|5c)/i.test(url.pathname)) return null;
|
|
18
|
+
if (!links.origins.some((origin) => new URL(origin).origin === url.origin)) return null;
|
|
19
|
+
const match = matchRoute(table.routes, url.pathname);
|
|
20
|
+
if (match == null || !hasClientPage(match.route) || !links.routes.includes(match.route.path))
|
|
21
|
+
return null;
|
|
22
|
+
return url.pathname + url.search;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type NativeLinkSource = {|
|
|
29
|
+
readonly getInitialURL: () => Promise<string | null>,
|
|
30
|
+
readonly addEventListener: (
|
|
31
|
+
event: "url",
|
|
32
|
+
listener: (event: {| url: string |}) => void,
|
|
33
|
+
) => {| remove: () => void |},
|
|
34
|
+
|};
|
|
35
|
+
|
|
36
|
+
/** Pass React Native's Linking; rejected links stay with the OS/browser. */
|
|
37
|
+
export function createNativeLinking(
|
|
38
|
+
table: Table,
|
|
39
|
+
source: NativeLinkSource,
|
|
40
|
+
): {|
|
|
41
|
+
getInitialURL: () => Promise<string | null>,
|
|
42
|
+
subscribe: (listener: (href: string) => void) => () => void,
|
|
43
|
+
|} {
|
|
44
|
+
let last: string | null = null;
|
|
45
|
+
let deliveredAt = -Infinity;
|
|
46
|
+
function receive(url: string): string | null {
|
|
47
|
+
const href = nativeLinkHref(table, url);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (href == null || (href === last && now - deliveredAt < 1000)) return null;
|
|
50
|
+
last = href;
|
|
51
|
+
deliveredAt = now;
|
|
52
|
+
return href;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
async getInitialURL() {
|
|
56
|
+
const url = await source.getInitialURL();
|
|
57
|
+
return url == null ? null : receive(url);
|
|
58
|
+
},
|
|
59
|
+
subscribe(listener) {
|
|
60
|
+
const subscription = source.addEventListener("url", ({ url }) => {
|
|
61
|
+
const href = receive(url);
|
|
62
|
+
if (href != null) listener(href);
|
|
63
|
+
});
|
|
64
|
+
return () => subscription.remove();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import type { RouteModule, RouteTable } from "./routing.js";
|
|
3
|
+
|
|
4
|
+
export type NativeLayout = {
|
|
5
|
+
readonly segment: string,
|
|
6
|
+
readonly file: string,
|
|
7
|
+
readonly module: RouteModule<>,
|
|
8
|
+
};
|
|
9
|
+
export type NativeNode = {
|
|
10
|
+
name: string,
|
|
11
|
+
module: ?RouteModule<>,
|
|
12
|
+
layout: boolean,
|
|
13
|
+
children: Array<NativeNode>,
|
|
14
|
+
};
|
|
15
|
+
export type NativeTree = { root: NativeNode, paths: Map<string, $ReadOnlyArray<string>> };
|
|
16
|
+
|
|
17
|
+
/** Module identity comes from the generated table; group names never become URLs. */
|
|
18
|
+
export function nativeTree(table: RouteTable<>, layouts: $ReadOnlyArray<NativeLayout>): NativeTree {
|
|
19
|
+
const rootLayout = layouts.find((layout) => layout.segment === "/");
|
|
20
|
+
const root: NativeNode = { name: "root", module: rootLayout?.module, layout: true, children: [] };
|
|
21
|
+
const metadata = new Map(layouts.map((layout) => [layout.module, layout]));
|
|
22
|
+
const paths = new Map<string, $ReadOnlyArray<string>>();
|
|
23
|
+
for (const route of table.routes) {
|
|
24
|
+
if (route.page == null) continue;
|
|
25
|
+
let parent = root;
|
|
26
|
+
const path = [];
|
|
27
|
+
for (const load of route.layouts) {
|
|
28
|
+
if (load === rootLayout?.module) continue;
|
|
29
|
+
const layout = metadata.get(load);
|
|
30
|
+
if (layout == null)
|
|
31
|
+
throw new Error(`Native navigation: missing layout metadata for ${route.path}`);
|
|
32
|
+
const name = `layout:${layout.segment}`;
|
|
33
|
+
let child = parent.children.find((node) => node.name === name);
|
|
34
|
+
if (child == null) {
|
|
35
|
+
child = { name, module: load, layout: true, children: [] };
|
|
36
|
+
parent.children.push(child);
|
|
37
|
+
}
|
|
38
|
+
parent = child;
|
|
39
|
+
path.push(name);
|
|
40
|
+
}
|
|
41
|
+
parent.children.push({ name: route.path, module: route.page, layout: false, children: [] });
|
|
42
|
+
path.push(route.path);
|
|
43
|
+
paths.set(route.path, path);
|
|
44
|
+
}
|
|
45
|
+
return { root, paths };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type NavigationPayload = {
|
|
49
|
+
readonly ufHref: string,
|
|
50
|
+
readonly ufParams: { readonly [string]: string | $ReadOnlyArray<string> },
|
|
51
|
+
};
|
|
52
|
+
export type NavigationState = {
|
|
53
|
+
readonly key?: string,
|
|
54
|
+
readonly type?: string,
|
|
55
|
+
readonly index?: number,
|
|
56
|
+
readonly routes: $ReadOnlyArray<{
|
|
57
|
+
readonly name: string,
|
|
58
|
+
readonly params?: NavigationPayload,
|
|
59
|
+
readonly state?: NavigationState,
|
|
60
|
+
}>,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function stateForPath(
|
|
64
|
+
names: $ReadOnlyArray<string>,
|
|
65
|
+
payload: NavigationPayload,
|
|
66
|
+
): NavigationState {
|
|
67
|
+
const [name, ...rest] = names;
|
|
68
|
+
if (name == null) throw new Error("Native navigation: an empty screen path cannot be opened");
|
|
69
|
+
return {
|
|
70
|
+
routes: [
|
|
71
|
+
{
|
|
72
|
+
name,
|
|
73
|
+
...(rest.length === 0 ? { params: payload } : { state: stateForPath(rest, payload) }),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hrefFromState(state: NavigationState): string | null {
|
|
80
|
+
const route = state.routes[state.index ?? state.routes.length - 1];
|
|
81
|
+
if (route == null) return null;
|
|
82
|
+
return route.state == null ? (route.params?.ufHref ?? null) : hrefFromState(route.state);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** React Navigation's nested navigate payload, separate from user route params. */
|
|
86
|
+
export function paramsForPath(names: $ReadOnlyArray<string>, payload: NavigationPayload): mixed {
|
|
87
|
+
const [screen, ...rest] = names;
|
|
88
|
+
return screen == null ? payload : { screen, params: paramsForPath(rest, payload) };
|
|
89
|
+
}
|