@uniflowed/router 0.0.0-alpha.9 → 0.1.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.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1593 -1341
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -106
@@ -0,0 +1,12 @@
1
+ // @flow
2
+
3
+ import { traceRequestPhase } from "@uniflowed/server/instrumentation";
4
+ import { NotFoundError, RedirectError } from "./routing.js";
5
+
6
+ export function traceLoader(body: () => mixed | Promise<mixed>): Promise<mixed> {
7
+ return traceRequestPhase(
8
+ "loader",
9
+ async () => body(),
10
+ (error) => error instanceof RedirectError || error instanceof NotFoundError,
11
+ );
12
+ }
@@ -0,0 +1,58 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the route a server component is rendering in.
4
+ //
5
+ // `useRoute()` in the browser reads the router's context, and a server
6
+ // component has no context to read. The graph it renders in resolves `react`
7
+ // under the `react-server` condition, which is the build with no `useContext`
8
+ // in it (ubugeeei-prod/uf#519), and a layout that highlights the section it is
9
+ // in — this repository's own documentation site has two — would otherwise have
10
+ // to become a client component to find out where it is.
11
+ //
12
+ // So the Flight renderer runs each render inside a store holding the route it
13
+ // resolved, and the server half of the hooks reads it back. `AsyncLocalStorage`
14
+ // and not React's `cache`: `cache` finds its render through React's own request
15
+ // storage, which the build React ships for Deno does not have, so after the
16
+ // first `await` in an async server component it would stop finding the render
17
+ // at all. A store of this module's own follows every continuation of the
18
+ // render on every host `@uniflowed/server` already runs on, and it is one value
19
+ // per render, so two requests in flight cannot read each other's route.
20
+ //
21
+ // Server-only by construction: nothing in the browser's graph imports this.
22
+
23
+ import { AsyncLocalStorage } from "node:async_hooks";
24
+
25
+ import type { RouteState } from "./flight.js";
26
+ import { requireServerComponentsReact } from "./react-version.js";
27
+
28
+ const storage: AsyncLocalStorage<RouteState> = new AsyncLocalStorage();
29
+
30
+ /** Run `body` as a render of `route`. Everything it starts sees the route. */
31
+ export function withServerRoute<T>(route: RouteState, body: () => T): T {
32
+ return storage.run(route, body);
33
+ }
34
+
35
+ /**
36
+ * The route being rendered, or a refusal naming the caller.
37
+ *
38
+ * A refusal rather than `null`, because the only way to arrive here without a
39
+ * route is to call a router hook from a server module that is not being
40
+ * rendered by the router — a script, a route handler, a module evaluated at
41
+ * import time — and each of those is a mistake worth a sentence.
42
+ *
43
+ * The React version is checked first. On a React older than 19.3 the Flight
44
+ * renderer that would have put the hook inside a route refuses to start, so the
45
+ * sentence worth reading is that one, not "outside a route".
46
+ */
47
+ export function serverRoute(caller: string): RouteState {
48
+ requireServerComponentsReact(`${caller}()`);
49
+ const route = storage.getStore();
50
+ if (route == null) {
51
+ throw new Error(
52
+ `@uniflowed/router: ${caller}() was called in a server module outside a route the router ` +
53
+ "is rendering. Server Components read the route they render in; a route handler reads " +
54
+ "the request it was given, and a module evaluated at import time has no route at all.",
55
+ );
56
+ }
57
+ return route;
58
+ }
@@ -0,0 +1,125 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the documents an HTML renderer answers with
4
+ // that are not a route's own markup — uf's shell around that markup, and a
5
+ // redirect.
6
+ //
7
+ // `../server.js` renders a route from its modules, and `../rsc-ssr.js` renders
8
+ // one from the payload React Server Components wrote. They are two entries so
9
+ // that the second, which loads React's Flight client, stays out of every bundle
10
+ // that renders no Server Component (ubugeeei-prod/uf#992). Both write the same
11
+ // documents around what they render, which is why those documents live here and
12
+ // not in either entry.
13
+
14
+ import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
15
+ import { addressOf } from "./base-path.js";
16
+ import { DEPLOYMENT_META } from "./deployment.js";
17
+ import { ROOT_ID } from "./document.js";
18
+ import type { RedirectError } from "./routing.js";
19
+ import { type DocumentShell, bodyOfText } from "./stream.js";
20
+
21
+ /** A redirect, as the finished document `prerender` answers with. */
22
+ export async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
23
+ return {
24
+ status: document.status,
25
+ headers: document.headers,
26
+ html: await document.text(),
27
+ };
28
+ }
29
+
30
+ export function redirectDocument(error: RedirectError): RenderResult {
31
+ // Under `app.router.basePath`, and in the trailing-slash policy's spelling:
32
+ // `redirect("/sign-in")` names an application path, and a browser follows
33
+ // an address.
34
+ const address = addressOf(error.to);
35
+ const target = escapeAttribute(address);
36
+ // A document rather than an empty body, because a redirect is still an answer
37
+ // a browser may be shown; it goes through the same three methods as a
38
+ // rendered one so that a host has one shape to write, not two.
39
+ const body = bodyOfText(
40
+ `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
41
+ );
42
+ return {
43
+ status: error.permanent ? 308 : 307,
44
+ headers: { Location: address },
45
+ pipe: body.pipe,
46
+ stream: body.stream,
47
+ text: body.text,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * The document uf writes around the app's markup.
53
+ *
54
+ * The same two shapes `assemble` chose between, decided from the same evidence
55
+ * — whether the markup opens with `<html>` — but stated up front instead of
56
+ * afterwards, because a stream has no "afterwards" in which to splice a head.
57
+ * An app whose root layout renders `<html>` owns the whole document and the
58
+ * client hydrates `document`, so uf contributes only the tags that go before
59
+ * `</head>`. An app that renders only content is wrapped in a minimal shell
60
+ * around `<div id="uf-root">`, which is what the client hydrates instead.
61
+ *
62
+ * `internal/stream.js` picks between them on the opening bytes React writes;
63
+ * everything either shape is made of is here, so what a uf document contains is
64
+ * still readable in one place.
65
+ *
66
+ * # Why the shell is three strings and not one
67
+ *
68
+ * Because uf's own `<head>` has to still be open when React's head tags arrive.
69
+ * React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
70
+ * itself, and here it wrote none — so with one string this shell closed its
71
+ * head before the app had rendered a byte, and every `og:` tag and the
72
+ * `<link rel="canonical">` landed in the body, where a crawler ignores them.
73
+ * `open` is uf's head up to that point, `body` is the rest of it and the
74
+ * wrapper, and what goes between them is whatever `assembled` lifts out of the
75
+ * app's own markup. See ubugeeei-prod/uf#547.
76
+ *
77
+ * That is also why no `<title>` is written here any more. It was, from
78
+ * `resolved.metadata.title` — the same string `Head` renders — so a document
79
+ * carried two of them, one in each place, and only one was where a browser
80
+ * looks. Hoisting the rendered one leaves the metadata with a single source.
81
+ */
82
+ export function shellFor(assets: RenderAssets, nonce?: string | null): DocumentShell {
83
+ const head = headTags(assets, nonce);
84
+ return {
85
+ head,
86
+ open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
87
+ body: `${head}</head><body><div id="${ROOT_ID}">`,
88
+ close: `</div></body></html>\n`,
89
+ };
90
+ }
91
+
92
+ function headTags(assets: RenderAssets, nonce?: string | null): string {
93
+ let tags = "";
94
+ // Which build this document is, first, because everything after it is a URL
95
+ // that build wrote. The browser reads it once and names it on every action
96
+ // call and payload request, so a server on another build can refuse rather
97
+ // than answer with ids and chunks this page does not have. See
98
+ // `./deployment.js`.
99
+ const deployment = assets.deployment;
100
+ if (deployment != null && deployment !== "") {
101
+ tags += `<meta name="${DEPLOYMENT_META}" content="${escapeAttribute(deployment)}">`;
102
+ }
103
+ for (const href of assets.styles) {
104
+ tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
105
+ }
106
+ for (const href of assets.preloads) {
107
+ tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
108
+ }
109
+ // The nonce goes on the client entry even though it is `src` rather than
110
+ // inline, because a policy of `script-src 'nonce-…'` admits *no* script
111
+ // without one — a nonce policy is not an inline policy with an exception in
112
+ // it. A project whose policy also names `'self'` pays nothing for the
113
+ // attribute being here, and one whose policy is `'strict-dynamic'` needs it:
114
+ // that is the directive under which this script is the root of trust every
115
+ // chunk it imports inherits from.
116
+ const carried = nonce == null ? "" : ` nonce="${escapeAttribute(nonce)}"`;
117
+ for (const src of assets.scripts) {
118
+ tags += `<script type="module" src="${escapeAttribute(src)}"${carried}></script>`;
119
+ }
120
+ return tags;
121
+ }
122
+
123
+ function escapeAttribute(value: string): string {
124
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
125
+ }