@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.
@@ -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 the four of them from drifting apart.
317
- const opening = async (html: string): Promise<string> =>
318
- transformHead == null ? html : await transformHead(html);
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 + split.rest);
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 + split.rest);
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 chunk — everything up to and including the head —
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 `_uf.loading.js` looked broken.
632
+ * nothing until it was finished, and `$loading.js` looked broken.
603
633
  * See ubugeeei-prod/uf#374.
604
634
  *
605
- * The hook only ever sees the head, which is what makes this safe. Vite's
606
- * injections are string-based against `<head>`, and its dev hook handles a
607
- * document that ends mid-`<body>` without complaint — checked against Vite
608
- * 8.2.2 before this existed, because "the parse step is the risk" was the
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(assembled(queue.chunks(), options.shell, options.transformHead), () => abort()),
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(assembled(decoded(stream), options.shell, options.transformHead), () => {
697
- controller.abort();
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/_uf.middleware.js` guards `/dashboard` and everything under
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/_uf.middleware.js
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 `_uf.middleware.js` that the router quietly
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.18",
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.18",
37
- "@uniflowed/server": "0.0.0-alpha.18"
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 `_uf.loading.js` had nothing to be. Making the split
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 chunk — everything up to and including the head —
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 `_uf.loading.js` looked broken.
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 one chunk,
132
- * so `injectTo` is answered against a document that stops inside `<body>`.
133
- * Measured against Vite 8.2.2, injecting all four positions into a whole
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>` | **after `<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 *top* of the body rather than after the content, because
145
- * the content has not been rendered yet when the hook runs. For a `<script>`
146
- * that expects a complete DOM that is a real difference, and it is the price
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. `dev-head-transform`
152
- * in `tests/library` pins the table above, so the day it changes is a failing
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 {