@uniflowed/router 0.2.0 → 0.4.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 +58 -1
- package/client.js +27 -8
- package/internal/action-endpoint.js +102 -2
- package/internal/action-wire.js +70 -8
- package/internal/compose.js +14 -4
- package/internal/deployment.js +3 -2
- package/internal/devtools.js +1 -1
- package/internal/error-view.js +1 -1
- package/internal/flight-browser.js +86 -10
- package/internal/flight-chunks.js +7 -7
- package/internal/flight-rows.js +7 -2
- package/internal/flight-ssr.js +10 -3
- package/internal/flight.js +32 -0
- package/internal/hydration.js +11 -6
- package/internal/navigation-cache.js +1 -1
- package/internal/payload-rows.js +5 -4
- package/internal/payload.js +18 -12
- package/internal/prepare-document.js +6 -0
- package/internal/resolve.js +17 -11
- package/internal/routing.js +77 -12
- package/internal/runtime.js +114 -6
- package/internal/stream.js +29 -14
- package/middleware.js +20 -4
- package/package.json +6 -4
- package/rsc-ssr.js +14 -5
- package/rsc.js +23 -7
- package/server.js +2 -2
- package/testing.js +980 -0
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { requireServerComponentsReact } from "./react-version.js";
|
|
40
40
|
|
|
41
41
|
/** A module namespace, as far as the loader looks into one. */
|
|
42
|
-
type ModuleNamespace = {
|
|
42
|
+
type ModuleNamespace = { readonly [string]: mixed };
|
|
43
43
|
|
|
44
44
|
/** The entry a refusal names: the one an application reaches this module through. */
|
|
45
45
|
const ENTRY = "@uniflowed/router/rsc/client";
|
|
@@ -86,16 +86,24 @@ export function installBrowserModules(): void {
|
|
|
86
86
|
throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
|
|
87
87
|
};
|
|
88
88
|
parcelRequire.meta = { publicUrl: "", devServer: null };
|
|
89
|
-
|
|
89
|
+
// `Reflect`, because Flow reads a property name given to
|
|
90
|
+
// `Object.defineProperty` as one the target must already declare, and
|
|
91
|
+
// `parcelRequire` is exactly the global nothing declares. `Object`'s throws
|
|
92
|
+
// where this answers `false`, so the throw is kept.
|
|
93
|
+
const defined = Reflect.defineProperty(globalThis, "parcelRequire", {
|
|
90
94
|
value: parcelRequire,
|
|
91
95
|
writable: true,
|
|
92
96
|
configurable: true,
|
|
93
97
|
});
|
|
98
|
+
if (!defined) {
|
|
99
|
+
throw new TypeError("@uniflowed/router: could not install parcelRequire on the global object");
|
|
100
|
+
}
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
/** The parts of a `Document` the reader uses. */
|
|
97
104
|
type DocumentLike = interface {
|
|
98
|
-
|
|
105
|
+
// A method, as a `Document`'s is: a method cannot be read off as a property.
|
|
106
|
+
querySelectorAll(selector: string): Iterable<ElementLike>,
|
|
99
107
|
readonly documentElement: mixed,
|
|
100
108
|
};
|
|
101
109
|
|
|
@@ -222,12 +230,16 @@ export async function fetchFlight(
|
|
|
222
230
|
const landed = new URL(response.url, window.location.href);
|
|
223
231
|
const document = documentPathOf(landed.pathname);
|
|
224
232
|
const type = response.headers.get("content-type") ?? "";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
// A payload file a static host could not type (#1495) is taken for what it
|
|
234
|
+
// is only after its first bytes say so; see [`untypedPayload`].
|
|
235
|
+
const payload =
|
|
236
|
+
landed.origin === window.location.origin && document != null
|
|
237
|
+
? type.startsWith(FLIGHT_CONTENT_TYPE)
|
|
238
|
+
? response
|
|
239
|
+
: await untypedPayload(response, type)
|
|
240
|
+
: null;
|
|
241
|
+
if (payload == null || document == null) {
|
|
242
|
+
void response.body?.cancel().catch(() => {});
|
|
231
243
|
// The URL the reader asked for when the redirect did not end on a payload,
|
|
232
244
|
// rather than the one it ended on. A middleware that answered with its
|
|
233
245
|
// sign-in page wrote a `next=` naming the payload URL; loading the document
|
|
@@ -237,6 +249,70 @@ export async function fetchFlight(
|
|
|
237
249
|
return {
|
|
238
250
|
kind: "flight",
|
|
239
251
|
url: `${document}${landed.search}`,
|
|
240
|
-
root: createFromFetch(Promise.resolve(
|
|
252
|
+
root: createFromFetch(Promise.resolve(payload)),
|
|
241
253
|
};
|
|
242
254
|
}
|
|
255
|
+
|
|
256
|
+
/** The types a host sends for a file it does not know, or no type at all. */
|
|
257
|
+
const UNTYPED: $ReadOnlyArray<string> = Object.freeze([
|
|
258
|
+
"",
|
|
259
|
+
"application/octet-stream",
|
|
260
|
+
"binary/octet-stream",
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/** How far into a body to look for the first row before giving up. */
|
|
264
|
+
const SNIFF_LIMIT = 256;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `response` as a payload, when a host served one without saying so.
|
|
268
|
+
*
|
|
269
|
+
* A prerendered page's payload is a file, `<route>/__uf.flight`, and a static
|
|
270
|
+
* host that does not know the extension sends it with no `Content-Type` (the
|
|
271
|
+
* Workers asset server, Pages) or as `application/octet-stream`. Refusing it
|
|
272
|
+
* made every client navigation on such a host a full page load. The type
|
|
273
|
+
* check is not dropped for it, it is replaced by one that cannot be fooled the
|
|
274
|
+
* same way: the answer has to be a `200` for the payload URL itself (no
|
|
275
|
+
* redirect — a sign-in page reached by one is what the type check is for),
|
|
276
|
+
* untyped rather than typed as something else (`text/html` is a document,
|
|
277
|
+
* whatever its bytes), and its first line has to be a Flight row, `<hex id>:`.
|
|
278
|
+
* Anything else answers `null` and the router loads the document, as before.
|
|
279
|
+
*
|
|
280
|
+
* The bytes read to decide are put back in front of the rest, so React reads
|
|
281
|
+
* the whole payload.
|
|
282
|
+
*/
|
|
283
|
+
async function untypedPayload(response: Response, type: string): Promise<Response | null> {
|
|
284
|
+
const media = type.split(";")[0].trim().toLowerCase();
|
|
285
|
+
if (!UNTYPED.includes(media) || response.status !== 200 || response.redirected) return null;
|
|
286
|
+
const body = response.body;
|
|
287
|
+
if (body == null) return null;
|
|
288
|
+
const reader = body.getReader();
|
|
289
|
+
const read: Array<Uint8Array> = [];
|
|
290
|
+
let seen = "";
|
|
291
|
+
const decoder = new TextDecoder();
|
|
292
|
+
while (!seen.includes("\n") && seen.length < SNIFF_LIMIT) {
|
|
293
|
+
const step = await reader.read();
|
|
294
|
+
if (step.done === true) break;
|
|
295
|
+
read.push(step.value);
|
|
296
|
+
seen += decoder.decode(step.value, { stream: true });
|
|
297
|
+
}
|
|
298
|
+
if (!/^[0-9a-f]+:/i.test(seen)) {
|
|
299
|
+
void reader.cancel().catch(() => {});
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const replayed = new ReadableStream({
|
|
303
|
+
start(controller) {
|
|
304
|
+
for (const chunk of read) controller.enqueue(chunk);
|
|
305
|
+
},
|
|
306
|
+
async pull(controller) {
|
|
307
|
+
const step = await reader.read();
|
|
308
|
+
if (step.done === true) controller.close();
|
|
309
|
+
else controller.enqueue(step.value);
|
|
310
|
+
},
|
|
311
|
+
cancel(reason) {
|
|
312
|
+
return reader.cancel(reason);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const headers = new Headers(response.headers);
|
|
316
|
+
headers.set("content-type", FLIGHT_CONTENT_TYPE);
|
|
317
|
+
return new Response(replayed, { status: 200, headers });
|
|
318
|
+
}
|
|
@@ -142,13 +142,13 @@ export function flightChunkBytes(text: string): Uint8Array | null {
|
|
|
142
142
|
if (typeof value === "string") {
|
|
143
143
|
return new TextEncoder().encode(value);
|
|
144
144
|
}
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
Object.keys(value).length === 1
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
146
|
+
// Read once into a local: a refinement of `value.bytes` does not survive
|
|
147
|
+
// to the call, a `const` does.
|
|
148
|
+
const bytes = value.bytes;
|
|
149
|
+
if (typeof bytes === "string" && Object.keys(value).length === 1) {
|
|
150
|
+
return fromBase64(bytes);
|
|
151
|
+
}
|
|
152
152
|
}
|
|
153
153
|
throw new Error(`@uniflowed/router: a ${FLIGHT_CHUNK_ATTRIBUTE} element holds no payload chunk`);
|
|
154
154
|
}
|
package/internal/flight-rows.js
CHANGED
|
@@ -39,7 +39,12 @@ const NEWLINE = 0x0a;
|
|
|
39
39
|
const ERROR_TAG = "E".charCodeAt(0);
|
|
40
40
|
|
|
41
41
|
/** One row's place in the payload. */
|
|
42
|
-
type Row = {|
|
|
42
|
+
type Row = {|
|
|
43
|
+
readonly start: number,
|
|
44
|
+
readonly end: number,
|
|
45
|
+
readonly tag: number,
|
|
46
|
+
readonly body: number,
|
|
47
|
+
|};
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
50
|
* Every row of a finished payload, in order.
|
|
@@ -92,7 +97,7 @@ function rowsOf(bytes: Uint8Array): Array<Row> {
|
|
|
92
97
|
export function withoutErrorRows(
|
|
93
98
|
payload: Uint8Array,
|
|
94
99
|
left: (digest: string) => boolean,
|
|
95
|
-
): {|
|
|
100
|
+
): {| readonly payload: Uint8Array, readonly removed: number |} {
|
|
96
101
|
const decoder = new TextDecoder();
|
|
97
102
|
const kept: Array<Uint8Array> = [];
|
|
98
103
|
let removed = 0;
|
package/internal/flight-ssr.js
CHANGED
|
@@ -28,7 +28,7 @@ import type { FlightRoot } from "./flight.js";
|
|
|
28
28
|
import { requireServerComponentsReact } from "./react-version.js";
|
|
29
29
|
|
|
30
30
|
/** A module namespace, as far as the hook looks into one. */
|
|
31
|
-
type ModuleNamespace = {
|
|
31
|
+
type ModuleNamespace = { readonly [string]: mixed };
|
|
32
32
|
|
|
33
33
|
/** The server copy of the client module at a browser chunk URL. */
|
|
34
34
|
export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
|
|
@@ -64,11 +64,18 @@ export function installServerModules(load: ClientModuleLoader): void {
|
|
|
64
64
|
throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
|
|
65
65
|
};
|
|
66
66
|
parcelRequire.meta = { publicUrl: "", devServer: null };
|
|
67
|
-
|
|
67
|
+
// `Reflect`, because Flow reads a property name given to
|
|
68
|
+
// `Object.defineProperty` as one the target must already declare, and
|
|
69
|
+
// `parcelRequire` is exactly the global nothing declares. `Object`'s throws
|
|
70
|
+
// where this answers `false`, so the throw is kept.
|
|
71
|
+
const defined = Reflect.defineProperty(globalThis, "parcelRequire", {
|
|
68
72
|
value: parcelRequire,
|
|
69
73
|
writable: true,
|
|
70
74
|
configurable: true,
|
|
71
75
|
});
|
|
76
|
+
if (!defined) {
|
|
77
|
+
throw new TypeError("@uniflowed/router: could not install parcelRequire on the global object");
|
|
78
|
+
}
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|
|
@@ -82,7 +89,7 @@ export function installServerModules(load: ClientModuleLoader): void {
|
|
|
82
89
|
*/
|
|
83
90
|
export function readPayload(
|
|
84
91
|
stream: ReadableStream<Uint8Array>,
|
|
85
|
-
options?: {|
|
|
92
|
+
options?: {| readonly partial?: boolean |},
|
|
86
93
|
): Promise<FlightRoot> {
|
|
87
94
|
requireServerComponentsReact(ENTRY);
|
|
88
95
|
return options?.partial === true
|
package/internal/flight.js
CHANGED
|
@@ -108,6 +108,38 @@ export type FetchedFlight =
|
|
|
108
108
|
readonly url: string,
|
|
109
109
|
|};
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* `error` as it may cross into a Flight payload: a thrown value that is not an
|
|
113
|
+
* `Error` becomes one.
|
|
114
|
+
*
|
|
115
|
+
* The route's error travels to the browser as a prop of the error page and as
|
|
116
|
+
* part of the route state, and React serialises it the way it serialises any
|
|
117
|
+
* prop. For an `Error` that is safe by React's own rule: a production payload
|
|
118
|
+
* carries the digest and none of the message or the stack. A thrown value
|
|
119
|
+
* that is *not* an `Error` gets no such rule. A string, or a plain object
|
|
120
|
+
* such as an API client's `{ message, details, hint }`, would be serialised
|
|
121
|
+
* as the data it is, and a query, a table name or a token in it would be
|
|
122
|
+
* published to whoever asked for the page.
|
|
123
|
+
*
|
|
124
|
+
* So it is wrapped. The message is the value's `String()`, which `uf dev`
|
|
125
|
+
* shows and which a production payload drops along with every other `Error`
|
|
126
|
+
* message. The original value is still what the server reported: this is
|
|
127
|
+
* applied only to the copy that crosses. `unauthorized` and `forbidden` carry
|
|
128
|
+
* nothing and pass through as they are.
|
|
129
|
+
*/
|
|
130
|
+
export function crossableRouteError(error: ?RouteError): ?RouteError {
|
|
131
|
+
if (error == null || error.kind !== "thrown" || error.error instanceof Error) {
|
|
132
|
+
return error;
|
|
133
|
+
}
|
|
134
|
+
let text = "a value that is not an Error was thrown";
|
|
135
|
+
try {
|
|
136
|
+
text = String(error.error);
|
|
137
|
+
} catch {
|
|
138
|
+
// A value whose `toString` throws: the sentence above is all it gets.
|
|
139
|
+
}
|
|
140
|
+
return { kind: "thrown", error: new Error(text) };
|
|
141
|
+
}
|
|
142
|
+
|
|
111
143
|
/** The part of a resolved route that crosses to the browser. */
|
|
112
144
|
export function routeState(resolved: ResolvedRoute): RouteState {
|
|
113
145
|
const interception = resolved.interception;
|
package/internal/hydration.js
CHANGED
|
@@ -195,21 +195,26 @@ type DetachedHeadStyle = {|
|
|
|
195
195
|
* puts them back in the same order.
|
|
196
196
|
*/
|
|
197
197
|
export function prepareDevHeadForHydration(document: Document): () => void {
|
|
198
|
-
|
|
198
|
+
// A document without a head has nothing of uf's in it to hide.
|
|
199
|
+
const head = document.head;
|
|
200
|
+
if (head == null) {
|
|
201
|
+
return () => {};
|
|
202
|
+
}
|
|
203
|
+
for (const script of head.querySelectorAll("script")) {
|
|
199
204
|
if (isDevHeadScript(script)) {
|
|
200
205
|
script.remove();
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
|
-
for (const child of Array.from(
|
|
208
|
+
for (const child of Array.from(head.childNodes)) {
|
|
204
209
|
if (isIgnorableHeadWhitespace(child)) {
|
|
205
|
-
|
|
210
|
+
head.removeChild(child);
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
const detached = [];
|
|
209
|
-
for (const style of
|
|
214
|
+
for (const style of head.querySelectorAll("style")) {
|
|
210
215
|
if (isViteDevStyle(style)) {
|
|
211
216
|
const anchor = document.createComment("uf dev style");
|
|
212
|
-
|
|
217
|
+
head.insertBefore(anchor, style);
|
|
213
218
|
style.remove();
|
|
214
219
|
detached.push({ anchor, style });
|
|
215
220
|
}
|
|
@@ -243,7 +248,7 @@ function restoreDevHeadStyles(
|
|
|
243
248
|
parent.insertBefore(style, anchor);
|
|
244
249
|
parent.removeChild(anchor);
|
|
245
250
|
} else {
|
|
246
|
-
document.head
|
|
251
|
+
document.head?.appendChild(style);
|
|
247
252
|
}
|
|
248
253
|
}
|
|
249
254
|
}
|
|
@@ -62,7 +62,7 @@ let staleTimeMs: number = 0;
|
|
|
62
62
|
export function installStaleTime(seconds: number): void {
|
|
63
63
|
staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
|
|
64
64
|
if (import.meta.hot != null) {
|
|
65
|
-
Object.defineProperty(
|
|
65
|
+
Object.defineProperty(globalThis as $FlowFixMe, "__UF_NAVIGATION_CACHE__", {
|
|
66
66
|
configurable: true,
|
|
67
67
|
value: inspectNavigationCache,
|
|
68
68
|
});
|
package/internal/payload-rows.js
CHANGED
|
@@ -81,13 +81,15 @@ export type PayloadReader = {|
|
|
|
81
81
|
|
|
82
82
|
/** The parts of a `Document` this module uses, so it needs no DOM lib. */
|
|
83
83
|
type DocumentLike = interface {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Methods rather than function-valued properties: a `Document`'s are
|
|
85
|
+
// methods, and a method cannot be read off its object as a property.
|
|
86
|
+
querySelectorAll(selector: string): Iterable<ElementLike>,
|
|
87
|
+
readonly documentElement: ?Node,
|
|
86
88
|
};
|
|
87
89
|
|
|
88
90
|
/** The parts of an `Element` this module uses. */
|
|
89
91
|
type ElementLike = interface {
|
|
90
|
-
|
|
92
|
+
getAttribute(name: string): ?string,
|
|
91
93
|
readonly textContent: string | null,
|
|
92
94
|
};
|
|
93
95
|
|
|
@@ -261,7 +263,6 @@ export function domObserver(
|
|
|
261
263
|
const observer = new MutationObserver(() => {
|
|
262
264
|
callback();
|
|
263
265
|
});
|
|
264
|
-
// $FlowFixMe[incompatible-call] `documentElement` is a `Node`; the interface above says only what is read.
|
|
265
266
|
observer.observe(root, { childList: true, subtree: true });
|
|
266
267
|
return () => {
|
|
267
268
|
observer.disconnect();
|
package/internal/payload.js
CHANGED
|
@@ -241,8 +241,8 @@ function asThenable(value: mixed): Promise<mixed> | null {
|
|
|
241
241
|
if (value == null || (typeof value !== "object" && typeof value !== "function")) {
|
|
242
242
|
return null;
|
|
243
243
|
}
|
|
244
|
-
const then: mixed = (value
|
|
245
|
-
return typeof then === "function" ? (value
|
|
244
|
+
const then: mixed = (value as $FlowFixMe).then;
|
|
245
|
+
return typeof then === "function" ? (value as $FlowFixMe) : null;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** Whether a string would be read as a reference and so has to be escaped. */
|
|
@@ -315,7 +315,7 @@ function needsEncoding(root: mixed, label: string): boolean {
|
|
|
315
315
|
continue;
|
|
316
316
|
}
|
|
317
317
|
if (isPlainObject(value)) {
|
|
318
|
-
const source: {
|
|
318
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
319
319
|
for (const key of Object.keys(source)) {
|
|
320
320
|
stack.push({ value: source[key], path: `${path}.${key}`, depth: depth + 1 });
|
|
321
321
|
}
|
|
@@ -381,7 +381,7 @@ export function encodePayload(root: mixed, label: string): EncodedPayload {
|
|
|
381
381
|
continue;
|
|
382
382
|
}
|
|
383
383
|
if (Array.isArray(value)) {
|
|
384
|
-
const encoded: Array<mixed> = new Array(value.length).fill(null);
|
|
384
|
+
const encoded: Array<mixed> = new Array<mixed>(value.length).fill(null);
|
|
385
385
|
emit(encoded);
|
|
386
386
|
// Pushed in reverse so that popping visits index 0 first: the id a
|
|
387
387
|
// promise is given has to follow the order the payload reads in.
|
|
@@ -403,7 +403,7 @@ export function encodePayload(root: mixed, label: string): EncodedPayload {
|
|
|
403
403
|
emit(value);
|
|
404
404
|
continue;
|
|
405
405
|
}
|
|
406
|
-
const source: {
|
|
406
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
407
407
|
const encoded: { [string]: mixed } = {};
|
|
408
408
|
emit(encoded);
|
|
409
409
|
const keys = Object.keys(source);
|
|
@@ -491,7 +491,7 @@ export function decodePayload(root: mixed, resolve: RowResolver, label: string):
|
|
|
491
491
|
continue;
|
|
492
492
|
}
|
|
493
493
|
if (Array.isArray(value)) {
|
|
494
|
-
const rebuilt: Array<mixed> = new Array(value.length).fill(null);
|
|
494
|
+
const rebuilt: Array<mixed> = new Array<mixed>(value.length).fill(null);
|
|
495
495
|
emit(rebuilt);
|
|
496
496
|
for (let index = value.length - 1; index >= 0; index -= 1) {
|
|
497
497
|
stack.push({
|
|
@@ -509,7 +509,7 @@ export function decodePayload(root: mixed, resolve: RowResolver, label: string):
|
|
|
509
509
|
emit(value);
|
|
510
510
|
continue;
|
|
511
511
|
}
|
|
512
|
-
const source: {
|
|
512
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
513
513
|
const rebuilt: { [string]: mixed } = {};
|
|
514
514
|
emit(rebuilt);
|
|
515
515
|
const keys = Object.keys(source);
|
|
@@ -553,7 +553,7 @@ function hasReference(root: mixed, label: string): boolean {
|
|
|
553
553
|
continue;
|
|
554
554
|
}
|
|
555
555
|
if (isPlainObject(value)) {
|
|
556
|
-
const source: {
|
|
556
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
557
557
|
for (const key of Object.keys(source)) {
|
|
558
558
|
stack.push({ value: source[key], path: `${path}.${key}`, depth: depth + 1 });
|
|
559
559
|
}
|
|
@@ -632,7 +632,13 @@ function parsePayloadRowId(digits: string): number | null {
|
|
|
632
632
|
* only one of them.
|
|
633
633
|
*/
|
|
634
634
|
export function payloadJson(value: mixed): string {
|
|
635
|
-
|
|
635
|
+
const text = JSON.stringify(value);
|
|
636
|
+
if (text === undefined) {
|
|
637
|
+
// `undefined`, a function or a symbol: nothing JSON can say. This threw
|
|
638
|
+
// before too, as a `TypeError` about `replace`; now it says what it is.
|
|
639
|
+
throw new TypeError("@uniflowed/router: a payload value has no JSON form");
|
|
640
|
+
}
|
|
641
|
+
return text
|
|
636
642
|
.replace(/</g, "\\u003c")
|
|
637
643
|
.replace(/\u2028/g, "\\u2028")
|
|
638
644
|
.replace(/\u2029/g, "\\u2029");
|
|
@@ -660,9 +666,9 @@ export function parseRowMessage(text: string, id: number): PayloadRowMessage {
|
|
|
660
666
|
if (!isPlainObject(parsed)) {
|
|
661
667
|
throw new PayloadValueError(label, "is not a row message");
|
|
662
668
|
}
|
|
663
|
-
const message: {
|
|
664
|
-
const hasValue = Object.
|
|
665
|
-
const hasError = Object.
|
|
669
|
+
const message: { readonly [string]: mixed } = parsed as $FlowFixMe;
|
|
670
|
+
const hasValue = Object.hasOwn(message, "value");
|
|
671
|
+
const hasError = Object.hasOwn(message, "error");
|
|
666
672
|
if (hasValue === hasError) {
|
|
667
673
|
throw new PayloadValueError(label, "must carry exactly one of `value` and `error`");
|
|
668
674
|
}
|
|
@@ -19,6 +19,12 @@ export function prepareDocumentForHydration(document: Document): void {
|
|
|
19
19
|
// document that arrived. See `./deployment.js`.
|
|
20
20
|
rememberDeployment(document);
|
|
21
21
|
const head = document.head;
|
|
22
|
+
if (head == null) {
|
|
23
|
+
// Nothing a server wrote into a head to put right; the forms still are.
|
|
24
|
+
document.getElementById("_R_")?.remove();
|
|
25
|
+
normalizeReactFormActions(document);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
22
28
|
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
23
29
|
if (envelope != null && head.firstChild !== envelope) {
|
|
24
30
|
head.insertBefore(envelope, head.firstChild);
|
package/internal/resolve.js
CHANGED
|
@@ -639,7 +639,7 @@ export function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
639
639
|
pending = load();
|
|
640
640
|
moduleCache.set(load, pending);
|
|
641
641
|
}
|
|
642
|
-
// $FlowFixMe[incompatible-
|
|
642
|
+
// $FlowFixMe[incompatible-type] the cache is keyed by the loader, whose result type it stores.
|
|
643
643
|
return pending;
|
|
644
644
|
}
|
|
645
645
|
|
|
@@ -755,9 +755,12 @@ async function resolveRoute(
|
|
|
755
755
|
"client JavaScript, so the browser navigates to it rather than rendering it",
|
|
756
756
|
);
|
|
757
757
|
}
|
|
758
|
-
|
|
758
|
+
// A pair rather than one spread array, so that the page stays a
|
|
759
|
+
// `PageModule` and the layouts `LayoutModule`s: `Promise.all` of a mixed
|
|
760
|
+
// list answers with the union of the two.
|
|
761
|
+
const [page, layouts] = await Promise.all([
|
|
759
762
|
loadOnce(load),
|
|
760
|
-
|
|
763
|
+
Promise.all(matched.route.layouts.map((layout) => loadOnce(layout))),
|
|
761
764
|
]);
|
|
762
765
|
// Started here and awaited at the end: the boundary's module does not depend
|
|
763
766
|
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
@@ -1460,11 +1463,11 @@ async function resolveNotFound(
|
|
|
1460
1463
|
): Promise<ResolvedRoute> {
|
|
1461
1464
|
const record = nearestBoundary(table.notFound, pathname);
|
|
1462
1465
|
const load = record?.page;
|
|
1463
|
-
const [page,
|
|
1466
|
+
const [page, layouts] = await Promise.all([
|
|
1464
1467
|
load == null
|
|
1465
1468
|
? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
|
|
1466
1469
|
: loadOnce(load),
|
|
1467
|
-
|
|
1470
|
+
Promise.all((record?.layouts ?? []).map((layout) => loadOnce(layout))),
|
|
1468
1471
|
]);
|
|
1469
1472
|
const metadata = await resolveMetadata(page, layouts, {
|
|
1470
1473
|
params: {},
|
|
@@ -1527,11 +1530,14 @@ async function resolveMetadata(
|
|
|
1527
1530
|
}
|
|
1528
1531
|
if (page.frontmatter != null) {
|
|
1529
1532
|
const { title, description } = page.frontmatter;
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
}
|
|
1533
|
+
// One field at a time: spreading two conditional objects is a union per
|
|
1534
|
+
// spread, and Flow refuses to multiply them out.
|
|
1535
|
+
if (title != null) {
|
|
1536
|
+
merged = { ...merged, title };
|
|
1537
|
+
}
|
|
1538
|
+
if (description != null) {
|
|
1539
|
+
merged = { ...merged, description };
|
|
1540
|
+
}
|
|
1535
1541
|
}
|
|
1536
1542
|
if (page.metadata != null) {
|
|
1537
1543
|
take(page.metadata);
|
|
@@ -1587,7 +1593,7 @@ export function errorTitle(error: RouteError): string {
|
|
|
1587
1593
|
return match (error) {
|
|
1588
1594
|
{kind: "unauthorized"} => "Sign in required",
|
|
1589
1595
|
{kind: "forbidden"} => "Not allowed",
|
|
1590
|
-
{kind: "thrown"} => "Something went wrong",
|
|
1596
|
+
{kind: "thrown", ...} => "Something went wrong",
|
|
1591
1597
|
};
|
|
1592
1598
|
}
|
|
1593
1599
|
|
package/internal/routing.js
CHANGED
|
@@ -150,7 +150,7 @@ export type RouteTable<
|
|
|
150
150
|
type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed, mixed>;
|
|
151
151
|
|
|
152
152
|
/** A URL matched against a table. */
|
|
153
|
-
export type RouteMatch<TRoute
|
|
153
|
+
export type RouteMatch<TRoute extends { readonly path: string, ... } = UnknownRouteRecord> = {|
|
|
154
154
|
readonly route: TRoute,
|
|
155
155
|
readonly params: RouteParams,
|
|
156
156
|
|};
|
|
@@ -172,7 +172,7 @@ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
|
172
172
|
return match (error) {
|
|
173
173
|
{kind: "unauthorized"} => 401,
|
|
174
174
|
{kind: "forbidden"} => 403,
|
|
175
|
-
{kind: "thrown"} => 500,
|
|
175
|
+
{kind: "thrown", ...} => 500,
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -200,12 +200,77 @@ export class ForbiddenError extends Error {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* The URL schemes a navigation never follows, because following one runs
|
|
205
|
+
* script in the page rather than loading one.
|
|
206
|
+
*
|
|
207
|
+
* `javascript:` is the reason. `location.assign("javascript:…")` and
|
|
208
|
+
* `location.replace("javascript:…")` execute it in the current document, with
|
|
209
|
+
* the page's origin and its cookies, so a router that hands a caller's string
|
|
210
|
+
* to either is a DOM XSS sink the moment that string comes from a query
|
|
211
|
+
* parameter: `redirect(searchParams.get("next"))` is the sign-in flow in half
|
|
212
|
+
* the applications ever written. React 19 refuses the same scheme in `href`
|
|
213
|
+
* for the same reason, and `Link` renders through that; this is the half of
|
|
214
|
+
* the router React never sees. `vbscript:` is the same thing in an older
|
|
215
|
+
* browser, and `data:` a document of the sender's choosing that browsers now
|
|
216
|
+
* refuse to navigate to at the top level anyway — refused here too, so the
|
|
217
|
+
* answer does not depend on which browser was asked.
|
|
218
|
+
*/
|
|
219
|
+
const SCRIPT_SCHEMES: $ReadOnlyArray<string> = ["javascript:", "vbscript:", "data:"];
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The scheme `to` would run as script when navigated to, or `null` when it is
|
|
223
|
+
* a URL a navigation may follow.
|
|
224
|
+
*
|
|
225
|
+
* Parsed with the WHATWG URL parser rather than compared as text, because the
|
|
226
|
+
* browser is what decides and it forgives a great deal: leading spaces and
|
|
227
|
+
* control characters are stripped, tabs and newlines anywhere are dropped, and
|
|
228
|
+
* the scheme is case-insensitive — ` JaVa\tScRiPt:` is `javascript:`. The base
|
|
229
|
+
* only resolves a relative path, which is never one of these.
|
|
230
|
+
*/
|
|
231
|
+
export function scriptSchemeOf(to: string): string | null {
|
|
232
|
+
let parsed: URL;
|
|
233
|
+
try {
|
|
234
|
+
parsed = new URL(to, "http://uf.invalid/");
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return SCRIPT_SCHEMES.includes(parsed.protocol) ? parsed.protocol : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Throw unless `to` is a URL a navigation may follow.
|
|
243
|
+
*
|
|
244
|
+
* Named after the call that asked (`redirect()`, `router.push()`), so the
|
|
245
|
+
* stack a developer reads points at the line that built the URL. The URL
|
|
246
|
+
* itself is not repeated: it is often a value a visitor chose, and an error
|
|
247
|
+
* message is something applications log.
|
|
248
|
+
*/
|
|
249
|
+
export function refuseScriptUrl(to: string, caller: string): void {
|
|
250
|
+
const scheme = scriptSchemeOf(to);
|
|
251
|
+
if (scheme != null) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`@uniflowed/router: ${caller} refused a ${scheme} URL, which a browser would run as ` +
|
|
254
|
+
"script in this page rather than load. If the URL came from a query parameter, allow " +
|
|
255
|
+
"only paths on this site before navigating to it.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Thrown by `redirect()`; the renderer answers with a redirect.
|
|
262
|
+
*
|
|
263
|
+
* Refuses a script URL when it is built (see [`refuseScriptUrl`]), so every
|
|
264
|
+
* place that follows one — the server's `Location`, the browser's
|
|
265
|
+
* `location.replace` in a single-page application, a form's `303` — is
|
|
266
|
+
* covered by one check rather than by one each.
|
|
267
|
+
*/
|
|
204
268
|
export class RedirectError extends Error {
|
|
205
269
|
to: string;
|
|
206
270
|
permanent: boolean;
|
|
207
271
|
|
|
208
272
|
constructor(to: string, permanent: boolean) {
|
|
273
|
+
refuseScriptUrl(to, permanent ? "permanentRedirect()" : "redirect()");
|
|
209
274
|
super(`redirect to ${to}`);
|
|
210
275
|
this.name = "RedirectError";
|
|
211
276
|
this.to = to;
|
|
@@ -241,9 +306,9 @@ function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
|
241
306
|
let score = 0;
|
|
242
307
|
for (const segment of segments) {
|
|
243
308
|
score += match (segment) {
|
|
244
|
-
{kind: "static"} => 3,
|
|
245
|
-
{kind: "param"} => 2,
|
|
246
|
-
{kind: "catchAll"} => 1,
|
|
309
|
+
{kind: "static", ...} => 3,
|
|
310
|
+
{kind: "param", ...} => 2,
|
|
311
|
+
{kind: "catchAll", ...} => 1,
|
|
247
312
|
};
|
|
248
313
|
}
|
|
249
314
|
return score;
|
|
@@ -338,12 +403,12 @@ function decodeSegment(segment: string): string {
|
|
|
338
403
|
}
|
|
339
404
|
|
|
340
405
|
/** Whether this table can render the route in the browser. */
|
|
341
|
-
export function hasClientPage(route: {
|
|
406
|
+
export function hasClientPage(route: { readonly page?: mixed, ... }): boolean {
|
|
342
407
|
return route.page != null;
|
|
343
408
|
}
|
|
344
409
|
|
|
345
410
|
/** Match a pathname against the table, preferring the most specific route. */
|
|
346
|
-
export function matchRoute<TRoute
|
|
411
|
+
export function matchRoute<TRoute extends { readonly path: string, ... }>(
|
|
347
412
|
routes: $ReadOnlyArray<TRoute>,
|
|
348
413
|
pathname: string,
|
|
349
414
|
): ?RouteMatch<TRoute> {
|
|
@@ -356,7 +421,7 @@ export function matchRoute<TRoute: { +path: string, ... }>(
|
|
|
356
421
|
* A slot is a second table matched against the same URL, and it has to be
|
|
357
422
|
* matched by this function rather than by one of its own.
|
|
358
423
|
*/
|
|
359
|
-
export function matchIn<TRoute
|
|
424
|
+
export function matchIn<TRoute extends { readonly path: string, ... }>(
|
|
360
425
|
routes: $ReadOnlyArray<TRoute>,
|
|
361
426
|
pathname: string,
|
|
362
427
|
): ?RouteMatch<TRoute> {
|
|
@@ -389,8 +454,8 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
|
|
|
389
454
|
for (const segment of segments) {
|
|
390
455
|
const next = match (segment) {
|
|
391
456
|
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
392
|
-
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
393
|
-
{kind: "catchAll"} => parts.length,
|
|
457
|
+
{kind: "param", ...} => index < parts.length ? index + 1 : -1,
|
|
458
|
+
{kind: "catchAll", ...} => parts.length,
|
|
394
459
|
};
|
|
395
460
|
if (next === -1) {
|
|
396
461
|
return false;
|
|
@@ -401,7 +466,7 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
|
|
|
401
466
|
}
|
|
402
467
|
|
|
403
468
|
/** The nearest boundary above `pathname`, or `null` when none covers it. */
|
|
404
|
-
export function nearestBoundary<TBoundary
|
|
469
|
+
export function nearestBoundary<TBoundary extends { readonly path: string, ... }>(
|
|
405
470
|
boundaries: $ReadOnlyArray<TBoundary>,
|
|
406
471
|
pathname: string,
|
|
407
472
|
): ?TBoundary {
|