@uniflowed/router 0.0.0-alpha.11 → 0.0.0-alpha.13
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 +198 -0
- package/client.js +35 -1
- package/handler.js +65 -2
- package/index.js +10 -1
- package/internal/action-endpoint.js +408 -0
- package/internal/action-wire.js +382 -0
- package/internal/hydration.js +861 -0
- package/internal/runtime.js +1037 -70
- package/internal/stream.js +122 -12
- package/package.json +4 -2
- package/server.js +68 -39
package/internal/stream.js
CHANGED
|
@@ -125,8 +125,13 @@ export type DocumentShell = {|
|
|
|
125
125
|
* `</head>` React writes.
|
|
126
126
|
*/
|
|
127
127
|
readonly head: string,
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* For an app that renders no document: everything up to the point uf's own
|
|
130
|
+
* head can still take tags — so it ends *inside* an open `<head>`.
|
|
131
|
+
*/
|
|
129
132
|
readonly open: string,
|
|
133
|
+
/** The rest of that head, and everything up to the app's markup. */
|
|
134
|
+
readonly body: string,
|
|
130
135
|
/** Everything after it. */
|
|
131
136
|
readonly close: string,
|
|
132
137
|
|};
|
|
@@ -293,6 +298,11 @@ function queueDestination(queue: ChunkQueue): NodeDestination {
|
|
|
293
298
|
* React writes a document with no head at all — an app whose root layout is
|
|
294
299
|
* `<html><body>` — the tags go in a head of uf's own, inserted after the
|
|
295
300
|
* opening tag, which is what the browser would have synthesized anyway.
|
|
301
|
+
*
|
|
302
|
+
* The shell case waits for the end of the run of hoistable elements React
|
|
303
|
+
* opened with, because that is where *its* tags go — see [`hoisted`]. Both
|
|
304
|
+
* waits are bounded by the head, and both are the same idea: the head goes out
|
|
305
|
+
* once, and everything that belongs in it has to be in hand by then.
|
|
296
306
|
*/
|
|
297
307
|
async function* assembled(
|
|
298
308
|
chunks: AsyncGenerator<string, void, void>,
|
|
@@ -302,7 +312,7 @@ async function* assembled(
|
|
|
302
312
|
let shape = "unknown";
|
|
303
313
|
|
|
304
314
|
for await (const chunk of chunks) {
|
|
305
|
-
if (shape === "document-open" || shape === "shell") {
|
|
315
|
+
if (shape === "document-open" || shape === "shell-open") {
|
|
306
316
|
yield chunk;
|
|
307
317
|
continue;
|
|
308
318
|
}
|
|
@@ -312,11 +322,16 @@ async function* assembled(
|
|
|
312
322
|
if (shape === "unknown") {
|
|
313
323
|
continue;
|
|
314
324
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
325
|
+
}
|
|
326
|
+
if (shape === "shell") {
|
|
327
|
+
const split = hoisted(held);
|
|
328
|
+
if (!split.complete) {
|
|
318
329
|
continue;
|
|
319
330
|
}
|
|
331
|
+
shape = "shell-open";
|
|
332
|
+
yield shell.open + split.head + shell.body + split.rest;
|
|
333
|
+
held = "";
|
|
334
|
+
continue;
|
|
320
335
|
}
|
|
321
336
|
// A document, and the tags go where its head closes.
|
|
322
337
|
const close = held.indexOf("</head>");
|
|
@@ -335,17 +350,21 @@ async function* assembled(
|
|
|
335
350
|
}
|
|
336
351
|
}
|
|
337
352
|
|
|
338
|
-
// The render ended before the decision could be made, or before
|
|
339
|
-
//
|
|
340
|
-
// `<body>` in it
|
|
353
|
+
// The render ended before the decision could be made, or before what was
|
|
354
|
+
// being waited for arrived: an empty document, one with neither `</head>` nor
|
|
355
|
+
// `<body>` in it, or a shell that is hoistable elements all the way down.
|
|
356
|
+
// There is nothing left to wait for in any of them.
|
|
341
357
|
if (shape === "document") {
|
|
342
358
|
yield ufDoctype(held + shell.head);
|
|
343
359
|
shape = "document-open";
|
|
344
|
-
} else if (shape === "unknown") {
|
|
345
|
-
|
|
346
|
-
|
|
360
|
+
} else if (shape === "shell" || shape === "unknown") {
|
|
361
|
+
// `complete` is not consulted: nothing more is coming, so a run that was
|
|
362
|
+
// still open is over and whatever was left of it is markup like any other.
|
|
363
|
+
const split = hoisted(held);
|
|
364
|
+
shape = "shell-open";
|
|
365
|
+
yield shell.open + split.head + shell.body + split.rest;
|
|
347
366
|
}
|
|
348
|
-
if (shape === "shell") {
|
|
367
|
+
if (shape === "shell-open") {
|
|
349
368
|
yield shell.close;
|
|
350
369
|
} else {
|
|
351
370
|
// The newline `assemble` ended a document with, kept: `uf build` writes
|
|
@@ -355,6 +374,97 @@ async function* assembled(
|
|
|
355
374
|
}
|
|
356
375
|
}
|
|
357
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The head elements React opened the app's markup with, split from the rest.
|
|
379
|
+
*
|
|
380
|
+
* `complete` is false while the buffer might still be in the middle of one — a
|
|
381
|
+
* chunk that ends inside `<meta cont`, or after a `<link>` and before whatever
|
|
382
|
+
* follows it. Deciding on an incomplete buffer would be a classification that
|
|
383
|
+
* depends on where React split its output, which is the bug `documentShape`
|
|
384
|
+
* above is written the way it is to avoid. The split is filled in either way,
|
|
385
|
+
* because the caller that has run out of chunks has nothing left to wait for
|
|
386
|
+
* and wants it.
|
|
387
|
+
*
|
|
388
|
+
* # Why a leading run rather than the whole document
|
|
389
|
+
*
|
|
390
|
+
* React hoists a `<title>`, a `<meta>` and a `<link>` into the `<head>` of a
|
|
391
|
+
* document *it* rendered. uf's shell is not one — React is handed the app, not
|
|
392
|
+
* the document — so with the shell every one of those landed in the body, and
|
|
393
|
+
* `<link rel="canonical">` in a body is a canonical link Google does not read.
|
|
394
|
+
* The fix is for uf to do the hoisting into the head it wrote itself.
|
|
395
|
+
*
|
|
396
|
+
* A leading run is what can be hoisted without holding the document. `RouteView`
|
|
397
|
+
* renders the route's metadata first, before the layouts and the page, so the
|
|
398
|
+
* run is exactly that metadata and the wait ends at the first byte of the
|
|
399
|
+
* application's own markup. Scanning further would mean buffering an arbitrary
|
|
400
|
+
* amount of a document to find a `<meta>` that might be at the end of it, which
|
|
401
|
+
* is streaming in shape and buffering in fact — the same trade this module
|
|
402
|
+
* refuses in `ChunkQueue`. So a tag a component renders further in stays where
|
|
403
|
+
* it is, and on a client React will hoist it into `document.head` itself.
|
|
404
|
+
*
|
|
405
|
+
* # Reading React's markup with a regular expression
|
|
406
|
+
*
|
|
407
|
+
* Which is only safe because it is React's. React escapes `>` in an attribute
|
|
408
|
+
* value and `<` in text, so the first `>` after an opening tag ends it and the
|
|
409
|
+
* first `</title>` ends a title — neither can appear inside one. This function
|
|
410
|
+
* is not an HTML parser and must never be handed markup from anywhere else.
|
|
411
|
+
*/
|
|
412
|
+
function hoisted(held: string): HoistedHead {
|
|
413
|
+
let index = 0;
|
|
414
|
+
while (index < held.length) {
|
|
415
|
+
const rest = held.slice(index);
|
|
416
|
+
const split = { head: held.slice(0, index), rest, complete: false };
|
|
417
|
+
const open = rest.match(/^<(title|meta|link)(?=[\s/>])/i);
|
|
418
|
+
if (open == null) {
|
|
419
|
+
// Not a hoistable element, or not yet enough bytes to say it is not one.
|
|
420
|
+
return { ...split, complete: !couldOpenHoistable(rest) };
|
|
421
|
+
}
|
|
422
|
+
const close = held.indexOf(">", index);
|
|
423
|
+
if (close === -1) {
|
|
424
|
+
return split;
|
|
425
|
+
}
|
|
426
|
+
if (open[1].toLowerCase() !== "title") {
|
|
427
|
+
index = close + 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const end = held.indexOf("</title>", close);
|
|
431
|
+
if (end === -1) {
|
|
432
|
+
return split;
|
|
433
|
+
}
|
|
434
|
+
index = end + "</title>".length;
|
|
435
|
+
}
|
|
436
|
+
// Every byte so far is a complete hoistable element, and the next one may
|
|
437
|
+
// still be on its way — the case a document that is metadata and nothing else
|
|
438
|
+
// ends in, and the reason the loop is bounded by the buffer rather than by
|
|
439
|
+
// `true`: a `while (true)` here is a function the checker reads as returning
|
|
440
|
+
// `void` on a path it cannot see is unreachable.
|
|
441
|
+
return { head: held, rest: "", complete: false };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** What [`hoisted`] found, and whether more bytes could still change it. */
|
|
445
|
+
type HoistedHead = {|
|
|
446
|
+
/** The hoistable elements the markup opened with. */
|
|
447
|
+
readonly head: string,
|
|
448
|
+
/** Everything after them. */
|
|
449
|
+
readonly rest: string,
|
|
450
|
+
/** Whether the run is known to have ended. */
|
|
451
|
+
readonly complete: boolean,
|
|
452
|
+
|};
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Whether `rest` could still turn into a hoistable element once more bytes
|
|
456
|
+
* arrive.
|
|
457
|
+
*
|
|
458
|
+
* True for `"<"` and for every proper prefix of `<title`, `<meta` and `<link` —
|
|
459
|
+
* the states a chunk boundary can leave the buffer in. False for `<main`, and
|
|
460
|
+
* false for `<titlebar>`, which is somebody's component and not a title however
|
|
461
|
+
* much of it has arrived.
|
|
462
|
+
*/
|
|
463
|
+
function couldOpenHoistable(rest: string): boolean {
|
|
464
|
+
const text = rest.toLowerCase();
|
|
465
|
+
return ["<title", "<meta", "<link"].some((tag) => tag.startsWith(text));
|
|
466
|
+
}
|
|
467
|
+
|
|
358
468
|
/**
|
|
359
469
|
* uf's spelling of the doctype, in place of whichever one React wrote.
|
|
360
470
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.13",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./index.js",
|
|
15
|
+
"./action": "./action.js",
|
|
15
16
|
"./client": "./client.js",
|
|
16
17
|
"./server": "./server.js",
|
|
17
18
|
"./package.json": "./package.json",
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
"./middleware": "./middleware.js"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
23
|
+
"action.js",
|
|
22
24
|
"client.js",
|
|
23
25
|
"handler.js",
|
|
24
26
|
"index.js",
|
|
@@ -31,6 +33,6 @@
|
|
|
31
33
|
"react-dom": ">=19"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
36
|
+
"@uniflowed/server": "0.0.0-alpha.13"
|
|
35
37
|
}
|
|
36
38
|
}
|
package/server.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
// `internal/stream.js` holds the mechanics and says which React renderer serves
|
|
24
24
|
// which.
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { noteRoute } from "@uniflowed/server/host";
|
|
27
|
+
import { ROOT_ID } from "./internal/document.js";
|
|
27
28
|
import * as React from "react";
|
|
28
29
|
|
|
29
30
|
import {
|
|
@@ -148,6 +149,23 @@ export type {
|
|
|
148
149
|
} from "./middleware.js";
|
|
149
150
|
export { createMiddlewareRunner } from "./middleware.js";
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The endpoint a `"use server"` export is dialled at.
|
|
154
|
+
*
|
|
155
|
+
* Here rather than beside `@uniflowed/router/action`, which is the browser's
|
|
156
|
+
* half of the same feature and must stay reachable from a client component:
|
|
157
|
+
* this one refuses outside a request, so it imports `internal/request.js` and
|
|
158
|
+
* through it `node:async_hooks`. The two halves share `internal/action-wire.js`
|
|
159
|
+
* and nothing else, which is what keeps one grammar rather than two.
|
|
160
|
+
*
|
|
161
|
+
* `virtual:uf/server` calls it with the table `virtual:uf/actions` built from
|
|
162
|
+
* the RSC manifest, and every host runs it between the middleware and the
|
|
163
|
+
* route handlers. See `internal/action-endpoint.js` for what the endpoint
|
|
164
|
+
* refuses and why.
|
|
165
|
+
*/
|
|
166
|
+
export type { ActionModule, ActionRecord } from "./internal/action-endpoint.js";
|
|
167
|
+
export { createActionDispatcher } from "./internal/action-endpoint.js";
|
|
168
|
+
|
|
151
169
|
/**
|
|
152
170
|
* What a URL turned out to be: a route to render, or a redirect to answer with.
|
|
153
171
|
*
|
|
@@ -201,13 +219,31 @@ export function createRenderer(options: {|
|
|
|
201
219
|
* The route to render, or the redirect to answer with instead.
|
|
202
220
|
*
|
|
203
221
|
* Shared by both entry points, because *what* a URL resolves to has nothing
|
|
204
|
-
* to do with how the answer is delivered
|
|
205
|
-
*
|
|
206
|
-
*
|
|
222
|
+
* to do with how the answer is delivered — with one exception, which is
|
|
223
|
+
* `defer` and is the exception that proves it. Whether the router may hand
|
|
224
|
+
* the page a loader that has not answered yet *is* a question about delivery:
|
|
225
|
+
* only a renderer with a `<Suspense>` fallback to send first has anywhere to
|
|
226
|
+
* put the wait. `render` says yes and `prerender` says no; see
|
|
227
|
+
* `ResolveOptions.defer` and ubugeeei-prod/uf#373.
|
|
228
|
+
*
|
|
229
|
+
* Returning the redirect rather than throwing it keeps the two callers from
|
|
230
|
+
* each having to remember that a redirect is the one thing `resolveMatch`
|
|
231
|
+
* lets out.
|
|
232
|
+
*
|
|
233
|
+
* `onMatch` is what makes the request's log line say `/orders/:id` rather
|
|
234
|
+
* than `/orders/8813`. It is handed to `resolveMatch` rather than read off
|
|
235
|
+
* the route this returns, because a loader runs *inside* that call and a
|
|
236
|
+
* loader has things to log: recording the route afterwards would leave every
|
|
237
|
+
* line the loader wrote claiming to belong to no route at all. `noteRoute`
|
|
238
|
+
* does nothing outside a request, which is what lets `prerender` — a build,
|
|
239
|
+
* with no request anywhere — call the same function.
|
|
207
240
|
*/
|
|
208
|
-
async function resolve(url: string): Promise<Resolution> {
|
|
241
|
+
async function resolve(url: string, defer: boolean): Promise<Resolution> {
|
|
209
242
|
try {
|
|
210
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
kind: "route",
|
|
245
|
+
route: await resolveMatch(table, url, { defer, onMatch: noteRoute }),
|
|
246
|
+
};
|
|
211
247
|
} catch (error) {
|
|
212
248
|
if (error instanceof RedirectError) {
|
|
213
249
|
return { kind: "redirect", error };
|
|
@@ -221,7 +257,7 @@ export function createRenderer(options: {|
|
|
|
221
257
|
assets: RenderAssets,
|
|
222
258
|
settings?: RenderOptions,
|
|
223
259
|
): Promise<RenderResult> {
|
|
224
|
-
const resolution = await resolve(url);
|
|
260
|
+
const resolution = await resolve(url, true);
|
|
225
261
|
if (resolution.kind === "redirect") {
|
|
226
262
|
return redirectDocument(resolution.error);
|
|
227
263
|
}
|
|
@@ -247,7 +283,7 @@ export function createRenderer(options: {|
|
|
|
247
283
|
let body: DocumentBody;
|
|
248
284
|
try {
|
|
249
285
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
250
|
-
shell: shellFor(
|
|
286
|
+
shell: shellFor(assets),
|
|
251
287
|
onError,
|
|
252
288
|
});
|
|
253
289
|
streaming = true;
|
|
@@ -277,7 +313,7 @@ export function createRenderer(options: {|
|
|
|
277
313
|
// where somebody can fix it.
|
|
278
314
|
streaming = true;
|
|
279
315
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
280
|
-
shell: shellFor(
|
|
316
|
+
shell: shellFor(assets),
|
|
281
317
|
onError,
|
|
282
318
|
});
|
|
283
319
|
}
|
|
@@ -296,7 +332,7 @@ export function createRenderer(options: {|
|
|
|
296
332
|
assets: RenderAssets,
|
|
297
333
|
settings?: RenderOptions,
|
|
298
334
|
): Promise<PrerenderResult> {
|
|
299
|
-
const resolution = await resolve(url);
|
|
335
|
+
const resolution = await resolve(url, false);
|
|
300
336
|
if (resolution.kind === "redirect") {
|
|
301
337
|
return redirectResult(redirectDocument(resolution.error));
|
|
302
338
|
}
|
|
@@ -306,7 +342,7 @@ export function createRenderer(options: {|
|
|
|
306
342
|
let html: string;
|
|
307
343
|
try {
|
|
308
344
|
html = await prerenderDocument(<App url={url} initial={resolved} />, {
|
|
309
|
-
shell: shellFor(
|
|
345
|
+
shell: shellFor(assets),
|
|
310
346
|
onError: report,
|
|
311
347
|
});
|
|
312
348
|
} catch (error) {
|
|
@@ -315,7 +351,7 @@ export function createRenderer(options: {|
|
|
|
315
351
|
}
|
|
316
352
|
resolved = await resolveFailure(table, url, error);
|
|
317
353
|
html = await prerenderDocument(<App url={url} initial={resolved} />, {
|
|
318
|
-
shell: shellFor(
|
|
354
|
+
shell: shellFor(assets),
|
|
319
355
|
onError: report,
|
|
320
356
|
});
|
|
321
357
|
}
|
|
@@ -369,14 +405,29 @@ function redirectDocument(error: RedirectError): RenderResult {
|
|
|
369
405
|
* `internal/stream.js` picks between them on the opening bytes React writes;
|
|
370
406
|
* everything either shape is made of is here, so what a uf document contains is
|
|
371
407
|
* still readable in one place.
|
|
408
|
+
*
|
|
409
|
+
* # Why the shell is three strings and not one
|
|
410
|
+
*
|
|
411
|
+
* Because uf's own `<head>` has to still be open when React's head tags arrive.
|
|
412
|
+
* React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
|
|
413
|
+
* itself, and here it wrote none — so with one string this shell closed its
|
|
414
|
+
* head before the app had rendered a byte, and every `og:` tag and the
|
|
415
|
+
* `<link rel="canonical">` landed in the body, where a crawler ignores them.
|
|
416
|
+
* `open` is uf's head up to that point, `body` is the rest of it and the
|
|
417
|
+
* wrapper, and what goes between them is whatever `assembled` lifts out of the
|
|
418
|
+
* app's own markup. See ubugeeei-prod/uf#547.
|
|
419
|
+
*
|
|
420
|
+
* That is also why no `<title>` is written here any more. It was, from
|
|
421
|
+
* `resolved.metadata.title` — the same string `Head` renders — so a document
|
|
422
|
+
* carried two of them, one in each place, and only one was where a browser
|
|
423
|
+
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
372
424
|
*/
|
|
373
|
-
function shellFor(
|
|
374
|
-
const head = headTags(assets)
|
|
375
|
-
const title =
|
|
376
|
-
resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
|
|
425
|
+
function shellFor(assets: RenderAssets): DocumentShell {
|
|
426
|
+
const head = headTags(assets);
|
|
377
427
|
return {
|
|
378
428
|
head,
|
|
379
|
-
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"
|
|
429
|
+
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
430
|
+
body: `${head}</head><body><div id="${ROOT_ID}">`,
|
|
380
431
|
close: `</div></body></html>\n`,
|
|
381
432
|
};
|
|
382
433
|
}
|
|
@@ -395,28 +446,6 @@ function headTags(assets: RenderAssets): string {
|
|
|
395
446
|
return tags;
|
|
396
447
|
}
|
|
397
448
|
|
|
398
|
-
/**
|
|
399
|
-
* The loader data, embedded for hydration.
|
|
400
|
-
*
|
|
401
|
-
* `<` is escaped inside the JSON so a string holding `</script>` cannot end
|
|
402
|
-
* the element early, and the script's type keeps the browser from executing
|
|
403
|
-
* it.
|
|
404
|
-
*/
|
|
405
|
-
function dataScript(data: mixed): string {
|
|
406
|
-
if (data === undefined) {
|
|
407
|
-
return "";
|
|
408
|
-
}
|
|
409
|
-
const json = JSON.stringify(data)
|
|
410
|
-
.replace(/</g, "\\u003c")
|
|
411
|
-
.replace(/\u2028/g, "\\u2028")
|
|
412
|
-
.replace(/\u2029/g, "\\u2029");
|
|
413
|
-
return `<script id="${DATA_ID}" type="application/json">${json}</script>`;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
449
|
function escapeAttribute(value: string): string {
|
|
417
450
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
418
451
|
}
|
|
419
|
-
|
|
420
|
-
function escapeText(value: string): string {
|
|
421
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
422
|
-
}
|