@uniflowed/router 0.0.0-alpha.18 → 0.0.0-alpha.21
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/client.js +187 -5
- package/handler.js +15 -6
- package/index.js +29 -6
- package/internal/action-wire.js +6 -3
- package/internal/boundaries.js +557 -0
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +1 -1
- package/internal/hydration.js +232 -39
- package/internal/inspector.js +615 -0
- package/internal/payload-rows.js +258 -0
- package/internal/payload.js +669 -0
- package/internal/runtime.js +754 -50
- package/internal/stream.js +87 -17
- package/middleware.js +3 -3
- package/package.json +5 -4
- package/server.js +72 -17
package/internal/stream.js
CHANGED
|
@@ -43,6 +43,8 @@ import * as React from "react";
|
|
|
43
43
|
import * as ReactDOMServer from "react-dom/server";
|
|
44
44
|
import * as ReactDOMStatic from "react-dom/static";
|
|
45
45
|
|
|
46
|
+
import { type StreamRecord, inspected } from "./inspector.js";
|
|
47
|
+
|
|
46
48
|
/**
|
|
47
49
|
* Where a document is written, when the host has a Node stream.
|
|
48
50
|
*
|
|
@@ -313,9 +315,14 @@ async function* assembled(
|
|
|
313
315
|
let shape = "unknown";
|
|
314
316
|
// The opening chunk is the only one the hook sees, and every path below
|
|
315
317
|
// reaches exactly one of them. Awaiting here rather than at each `yield`
|
|
316
|
-
// keeps
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
// keeps them from drifting apart. The hook sees only the document opening:
|
|
319
|
+
// Vite's HTML parser is happy with an open body, but not with a React chunk
|
|
320
|
+
// that happens to end inside an attribute.
|
|
321
|
+
const opening = async (html: string, rest: string = ""): Promise<string> => {
|
|
322
|
+
const split = transformableOpening(html);
|
|
323
|
+
const transformed = transformHead == null ? split.opening : await transformHead(split.opening);
|
|
324
|
+
return transformed + split.rest + rest;
|
|
325
|
+
};
|
|
319
326
|
|
|
320
327
|
for await (const chunk of chunks) {
|
|
321
328
|
if (shape === "document-open" || shape === "shell-open") {
|
|
@@ -335,7 +342,7 @@ async function* assembled(
|
|
|
335
342
|
continue;
|
|
336
343
|
}
|
|
337
344
|
shape = "shell-open";
|
|
338
|
-
yield await opening(shell.open + split.head + shell.body
|
|
345
|
+
yield await opening(shell.open + split.head + shell.body, split.rest);
|
|
339
346
|
held = "";
|
|
340
347
|
continue;
|
|
341
348
|
}
|
|
@@ -350,6 +357,10 @@ async function* assembled(
|
|
|
350
357
|
// `<body` before `</head>` means React wrote no head; give the tags one.
|
|
351
358
|
const body = held.search(/<body[\s>]/i);
|
|
352
359
|
if (body !== -1) {
|
|
360
|
+
const bodyEnd = held.indexOf(">", body);
|
|
361
|
+
if (bodyEnd === -1) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
353
364
|
shape = "document-open";
|
|
354
365
|
yield await opening(
|
|
355
366
|
ufDoctype(`${held.slice(0, body)}<head>${shell.head}</head>${held.slice(body)}`),
|
|
@@ -370,7 +381,7 @@ async function* assembled(
|
|
|
370
381
|
// still open is over and whatever was left of it is markup like any other.
|
|
371
382
|
const split = hoisted(held);
|
|
372
383
|
shape = "shell-open";
|
|
373
|
-
yield await opening(shell.open + split.head + shell.body
|
|
384
|
+
yield await opening(shell.open + split.head + shell.body, split.rest);
|
|
374
385
|
}
|
|
375
386
|
if (shape === "shell-open") {
|
|
376
387
|
yield shell.close;
|
|
@@ -382,6 +393,25 @@ async function* assembled(
|
|
|
382
393
|
}
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
function transformableOpening(html: string): {| readonly opening: string, readonly rest: string |} {
|
|
397
|
+
const lower = html.toLowerCase();
|
|
398
|
+
const headEnd = lower.indexOf("</head>");
|
|
399
|
+
if (headEnd === -1) {
|
|
400
|
+
return { opening: html, rest: "" };
|
|
401
|
+
}
|
|
402
|
+
const afterHead = headEnd + "</head>".length;
|
|
403
|
+
const body = lower.indexOf("<body", afterHead);
|
|
404
|
+
if (body === -1) {
|
|
405
|
+
return { opening: html.slice(0, afterHead), rest: html.slice(afterHead) };
|
|
406
|
+
}
|
|
407
|
+
const bodyEnd = html.indexOf(">", body);
|
|
408
|
+
if (bodyEnd === -1) {
|
|
409
|
+
return { opening: html.slice(0, afterHead), rest: html.slice(afterHead) };
|
|
410
|
+
}
|
|
411
|
+
const afterBody = bodyEnd + 1;
|
|
412
|
+
return { opening: html.slice(0, afterBody), rest: html.slice(afterBody) };
|
|
413
|
+
}
|
|
414
|
+
|
|
385
415
|
/**
|
|
386
416
|
* The head elements React opened the app's markup with, split from the rest.
|
|
387
417
|
*
|
|
@@ -591,29 +621,57 @@ export type RenderOptions = {|
|
|
|
591
621
|
*/
|
|
592
622
|
readonly onError: (error: mixed) => void,
|
|
593
623
|
/**
|
|
594
|
-
* Rewrite the opening
|
|
595
|
-
* before it goes out.
|
|
624
|
+
* Rewrite the document opening — the head and, when present, the body start
|
|
625
|
+
* tag — before it goes out.
|
|
596
626
|
*
|
|
597
627
|
* For `uf dev`, and only for it. Vite's `transformIndexHtml` rewrites asset
|
|
598
628
|
* URLs and injects `/@vite/client` and the refresh preamble, and it is a
|
|
599
629
|
* *whole document* hook, so the development server used to collect the page
|
|
600
630
|
* and transform it at the end. That made the one place a developer would
|
|
601
631
|
* notice streaming the one place it did not happen: a slow page showed
|
|
602
|
-
* nothing until it was finished, and
|
|
632
|
+
* nothing until it was finished, and `$loading.js` looked broken.
|
|
603
633
|
* See ubugeeei-prod/uf#374.
|
|
604
634
|
*
|
|
605
|
-
* The hook
|
|
606
|
-
* injections are string-based against `<head
|
|
607
|
-
* document that
|
|
608
|
-
*
|
|
609
|
-
* open question on that issue.
|
|
635
|
+
* The hook never sees application body markup, which is what makes this
|
|
636
|
+
* safe. Vite's injections are string-based against `<head>` and `<body>`,
|
|
637
|
+
* and its parser accepts a document that stops after the body start tag.
|
|
638
|
+
* It does not accept a React chunk that ends in the middle of an attribute.
|
|
610
639
|
*
|
|
611
640
|
* Absent everywhere else. `uf start`, `uf preview` and every deploy adapter
|
|
612
641
|
* have no such hook and stream already.
|
|
613
642
|
*/
|
|
614
643
|
readonly transformHead?: (html: string) => Promise<string>,
|
|
644
|
+
/**
|
|
645
|
+
* Told what left, in what order, and what each chunk built.
|
|
646
|
+
*
|
|
647
|
+
* For `uf dev`, like `transformHead` above, and absent everywhere else —
|
|
648
|
+
* which is what makes it free rather than cheap: with nothing supplied no
|
|
649
|
+
* recorder is constructed, `./inspector.js` is never entered, and the
|
|
650
|
+
* generator a host consumes is the same object it was. See that module for
|
|
651
|
+
* what is done with the record and why it is a development affordance rather
|
|
652
|
+
* than a metric.
|
|
653
|
+
*
|
|
654
|
+
* Called once per document, after the last chunk and also after a render that
|
|
655
|
+
* was abandoned partway. `prerenderDocument` never calls it: a build resolves
|
|
656
|
+
* everything before it writes a byte, so there is no order to report.
|
|
657
|
+
*/
|
|
658
|
+
readonly onStream?: (record: StreamRecord) => void,
|
|
615
659
|
|};
|
|
616
660
|
|
|
661
|
+
/**
|
|
662
|
+
* `chunks`, recorded on the way out when `uf dev` asked for it.
|
|
663
|
+
*
|
|
664
|
+
* The wrapping goes here rather than around `assembled` in each of the callers
|
|
665
|
+
* so that what is recorded is unambiguous: these are the bytes the host is
|
|
666
|
+
* handed, head and all, and not React's own output on the way past.
|
|
667
|
+
*/
|
|
668
|
+
function outgoing(
|
|
669
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
670
|
+
onStream?: (record: StreamRecord) => void,
|
|
671
|
+
): AsyncGenerator<string, void, void> {
|
|
672
|
+
return onStream == null ? chunks : inspected(chunks, onStream, () => performance.now());
|
|
673
|
+
}
|
|
674
|
+
|
|
617
675
|
/**
|
|
618
676
|
* Stream `node` as a document, resolving once the shell is ready.
|
|
619
677
|
*
|
|
@@ -632,7 +690,13 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
632
690
|
onShellReady() {
|
|
633
691
|
pipe(queueDestination(queue));
|
|
634
692
|
resolve(
|
|
635
|
-
bodyOf(
|
|
693
|
+
bodyOf(
|
|
694
|
+
outgoing(
|
|
695
|
+
assembled(queue.chunks(), options.shell, options.transformHead),
|
|
696
|
+
options.onStream,
|
|
697
|
+
),
|
|
698
|
+
() => abort(),
|
|
699
|
+
),
|
|
636
700
|
);
|
|
637
701
|
},
|
|
638
702
|
onShellError(error: mixed) {
|
|
@@ -693,9 +757,15 @@ export function renderWithReadableStream(
|
|
|
693
757
|
const controller = new AbortController();
|
|
694
758
|
return render(node, { onError: options.onError, signal: controller.signal }).then(
|
|
695
759
|
(stream: ByteSource) =>
|
|
696
|
-
bodyOf(
|
|
697
|
-
|
|
698
|
-
|
|
760
|
+
bodyOf(
|
|
761
|
+
outgoing(
|
|
762
|
+
assembled(decoded(stream), options.shell, options.transformHead),
|
|
763
|
+
options.onStream,
|
|
764
|
+
),
|
|
765
|
+
() => {
|
|
766
|
+
controller.abort();
|
|
767
|
+
},
|
|
768
|
+
),
|
|
699
769
|
);
|
|
700
770
|
}
|
|
701
771
|
|
package/middleware.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Middleware: what runs before a path answers, whatever answers it.
|
|
4
4
|
//
|
|
5
|
-
// `app/dashboard
|
|
5
|
+
// `app/dashboard/$middleware.js` guards `/dashboard` and everything under
|
|
6
6
|
// it — the pages, the route handlers, and the paths under it that match
|
|
7
7
|
// nothing at all. There is no `matcher` to write because the directory the
|
|
8
8
|
// file sits in *is* the matcher, which is the same composition rule layouts
|
|
9
9
|
// already use and the reason uf does not inherit Next's regular expressions.
|
|
10
10
|
//
|
|
11
|
-
// // app/dashboard
|
|
11
|
+
// // app/dashboard/$middleware.js
|
|
12
12
|
// // @flow
|
|
13
13
|
// import { cookies } from "@uniflowed/server";
|
|
14
14
|
//
|
|
@@ -167,7 +167,7 @@ export function createMiddlewareRunner(options: {|
|
|
|
167
167
|
*
|
|
168
168
|
* `default` or `middleware`, the same two spellings a page offers for its
|
|
169
169
|
* component. Anything else is an authoring mistake and throws rather than
|
|
170
|
-
* being skipped: a file named
|
|
170
|
+
* being skipped: a file named `$middleware.js` that the router quietly
|
|
171
171
|
* ignored is the bug this whole module exists to stop happening.
|
|
172
172
|
*/
|
|
173
173
|
function pick(module: MiddlewareModule, file: string): Middleware {
|
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.21",
|
|
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",
|
|
@@ -26,14 +26,15 @@
|
|
|
26
26
|
"index.js",
|
|
27
27
|
"internal",
|
|
28
28
|
"middleware.js",
|
|
29
|
-
"server.js"
|
|
29
|
+
"server.js",
|
|
30
|
+
"!*.test.js"
|
|
30
31
|
],
|
|
31
32
|
"peerDependencies": {
|
|
32
33
|
"react": ">=19",
|
|
33
34
|
"react-dom": ">=19"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
37
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
37
|
+
"@uniflowed/hooks": "0.0.0-alpha.21",
|
|
38
|
+
"@uniflowed/server": "0.0.0-alpha.21"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/server.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// They were one function with `renderToString` behind it, which answered the
|
|
20
20
|
// first question by giving up on it: nothing streamed, so nothing could
|
|
21
|
-
// usefully suspend, so
|
|
21
|
+
// usefully suspend, so `$loading.js` had nothing to be. Making the split
|
|
22
22
|
// explicit is the point of ubugeeei-prod/uf#254 rather than a side effect —
|
|
23
23
|
// `internal/stream.js` holds the mechanics and says which React renderer serves
|
|
24
24
|
// which.
|
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
resolveMatch,
|
|
47
47
|
} from "./internal/runtime.js";
|
|
48
48
|
|
|
49
|
+
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
50
|
+
|
|
49
51
|
/** Asset URLs to reference from the document. */
|
|
50
52
|
export type RenderAssets = {|
|
|
51
53
|
readonly scripts: $ReadOnlyArray<string>,
|
|
@@ -113,46 +115,62 @@ export type RenderOptions = {|
|
|
|
113
115
|
*/
|
|
114
116
|
readonly onError?: (error: mixed) => void,
|
|
115
117
|
/**
|
|
116
|
-
* Rewrite the opening
|
|
117
|
-
* before it goes out.
|
|
118
|
+
* Rewrite the document opening — the head and, when present, the body start
|
|
119
|
+
* tag — before it goes out.
|
|
118
120
|
*
|
|
119
121
|
* For `uf dev` and nothing else. Vite's `transformIndexHtml` injects
|
|
120
122
|
* `/@vite/client` and the refresh preamble and rewrites asset URLs, and it
|
|
121
123
|
* is a *whole document* hook, so the development server used to collect the
|
|
122
124
|
* page and transform it at the end. That made the one place a developer
|
|
123
125
|
* would notice streaming the one place it did not happen: a slow page showed
|
|
124
|
-
* nothing until it was finished, and
|
|
126
|
+
* nothing until it was finished, and `$loading.js` looked broken.
|
|
125
127
|
* See ubugeeei-prod/uf#374.
|
|
126
128
|
*
|
|
127
129
|
* A production host passes nothing here and streams as it always did.
|
|
128
130
|
*
|
|
129
131
|
* # What a plugin that injects into the body gets
|
|
130
132
|
*
|
|
131
|
-
* `transformIndexHtml` is a whole-document hook and this hands it
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* document and into a head-only one:
|
|
133
|
+
* `transformIndexHtml` is a whole-document hook and this hands it only the
|
|
134
|
+
* parseable opening of the document. Measured against Vite 8.2.2, injecting
|
|
135
|
+
* all four positions into a whole document and into the streamed opening:
|
|
135
136
|
*
|
|
136
137
|
* | `injectTo` | whole document | streamed |
|
|
137
138
|
* | -------------- | ------------------- | -------- |
|
|
138
139
|
* | `head-prepend` | after `<head>` | same |
|
|
139
140
|
* | `head` | before `</head>` | same |
|
|
140
141
|
* | `body-prepend` | after `<body>` | same |
|
|
141
|
-
* | `body` | before `</body>` |
|
|
142
|
+
* | `body` | before `</body>` | after `<body>` |
|
|
142
143
|
*
|
|
143
144
|
* Nothing is dropped — every tag still reaches the document — but a `body`
|
|
144
|
-
* tag lands at the
|
|
145
|
-
* the content
|
|
146
|
-
*
|
|
147
|
-
* of streaming: a hook that wants the whole document and a server that sends
|
|
148
|
-
* the head first cannot both be satisfied.
|
|
145
|
+
* tag lands at the top of the body rather than after the content, because
|
|
146
|
+
* the content is deliberately not passed to the hook. That keeps Vite's
|
|
147
|
+
* parser away from chunk boundaries that may sit inside an attribute.
|
|
149
148
|
*
|
|
150
149
|
* uf's own injections are `head` and `head-prepend`, and Vite's client is
|
|
151
|
-
* head-injected, so this is about a third-party plugin.
|
|
152
|
-
*
|
|
153
|
-
* test rather than a surprise.
|
|
150
|
+
* head-injected, so this is about a third-party plugin.
|
|
151
|
+
* `packages/vite/dev-head-transform.test.js` pins the table above, so the day
|
|
152
|
+
* it changes is a failing test rather than a surprise.
|
|
154
153
|
*/
|
|
155
154
|
readonly transformHead?: (html: string) => Promise<string>,
|
|
155
|
+
/**
|
|
156
|
+
* Told, in words, when a document streamed differently than it did last time.
|
|
157
|
+
*
|
|
158
|
+
* For `uf dev` and nothing else, like `transformHead` above. It answers the
|
|
159
|
+
* half of ubugeeei-prod/uf#520 that is about the wire — what arrived, in what
|
|
160
|
+
* order, and which part of the tree each chunk built — for the stream uf has
|
|
161
|
+
* today, which is a document whose Suspense boundaries resolve independently.
|
|
162
|
+
* `internal/inspector.js` is what it is and what it deliberately is not.
|
|
163
|
+
*
|
|
164
|
+
* A host that passes nothing here records nothing: no recorder is
|
|
165
|
+
* constructed, and the chunks a production stream yields are untouched.
|
|
166
|
+
*
|
|
167
|
+
* It is handed a message and its detail lines rather than the record they
|
|
168
|
+
* came from, because the caller is `@uniflowed/vite` — plain JavaScript, run
|
|
169
|
+
* by Vite before any Flow transform exists, which is why `DEVTOOLS_HOOK` and
|
|
170
|
+
* `DIAGNOSTIC_ENDPOINT` are spelled twice rather than imported. The
|
|
171
|
+
* vocabulary of the report belongs on this side of that line.
|
|
172
|
+
*/
|
|
173
|
+
readonly onStream?: (diagnostic: StreamDiagnostic) => void,
|
|
156
174
|
|};
|
|
157
175
|
|
|
158
176
|
/** The two ids the server writes and the client reads. */
|
|
@@ -304,6 +322,11 @@ export function createRenderer(options: {|
|
|
|
304
322
|
}
|
|
305
323
|
let resolved: ResolvedRoute = resolution.route;
|
|
306
324
|
const report = settings?.onError ?? (() => {});
|
|
325
|
+
// Built once and shared by both renders below, so a page that threw its
|
|
326
|
+
// shell away and rendered its error boundary instead reports the stream the
|
|
327
|
+
// browser was actually sent rather than the one that was abandoned.
|
|
328
|
+
const send = settings?.onStream;
|
|
329
|
+
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
307
330
|
|
|
308
331
|
// React reports an exception to `onError` *and*, if it was in the shell, to
|
|
309
332
|
// `onShellError` — so forwarding both would tell the host about one failure
|
|
@@ -327,6 +350,7 @@ export function createRenderer(options: {|
|
|
|
327
350
|
shell: shellFor(assets),
|
|
328
351
|
onError,
|
|
329
352
|
transformHead: settings?.transformHead,
|
|
353
|
+
onStream,
|
|
330
354
|
});
|
|
331
355
|
streaming = true;
|
|
332
356
|
// Recovered before the shell was ready: a `<Suspense>` boundary whose
|
|
@@ -358,6 +382,7 @@ export function createRenderer(options: {|
|
|
|
358
382
|
shell: shellFor(assets),
|
|
359
383
|
onError,
|
|
360
384
|
transformHead: settings?.transformHead,
|
|
385
|
+
onStream,
|
|
361
386
|
});
|
|
362
387
|
}
|
|
363
388
|
|
|
@@ -465,6 +490,36 @@ function redirectDocument(error: RedirectError): RenderResult {
|
|
|
465
490
|
* carried two of them, one in each place, and only one was where a browser
|
|
466
491
|
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
467
492
|
*/
|
|
493
|
+
/**
|
|
494
|
+
* The document a single-page build writes, and the only one it writes.
|
|
495
|
+
*
|
|
496
|
+
* `app.rendering.modes: ["csr"]` renders no route at build time: the client
|
|
497
|
+
* router resolves and renders every one of them in the browser, so what the
|
|
498
|
+
* build has to leave behind is the *chrome* — the stylesheets, the module
|
|
499
|
+
* script, and the empty root the client renders into. That is exactly
|
|
500
|
+
* [`shellFor`]'s three strings with nothing between them, which is why this is
|
|
501
|
+
* three concatenations rather than a fourth shape of document to keep in step
|
|
502
|
+
* with the other three.
|
|
503
|
+
*
|
|
504
|
+
* No React runs. There is nothing to render: no URL has been asked for, and
|
|
505
|
+
* whatever this document is served for is decided by the host rather than by
|
|
506
|
+
* this build.
|
|
507
|
+
*
|
|
508
|
+
* # What it costs, said here because it is not visible from the file
|
|
509
|
+
*
|
|
510
|
+
* The document has no `<title>`, no `<meta name="description">` and no content.
|
|
511
|
+
* A crawler that runs no JavaScript sees an empty page for **every** URL, and a
|
|
512
|
+
* reader sees nothing until the bundle has loaded and the route has resolved.
|
|
513
|
+
* That is what a single-page application is, and it is why `modes: ["csr"]` is
|
|
514
|
+
* a declaration a project makes rather than something a build falls back to.
|
|
515
|
+
* A project that wants a document per route has `ssg`, and one that wants a
|
|
516
|
+
* document per request has `ssr`.
|
|
517
|
+
*/
|
|
518
|
+
export function shellDocument(assets: RenderAssets): string {
|
|
519
|
+
const shell = shellFor(assets);
|
|
520
|
+
return `${shell.open}${shell.body}${shell.close}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
468
523
|
function shellFor(assets: RenderAssets): DocumentShell {
|
|
469
524
|
const head = headTags(assets);
|
|
470
525
|
return {
|