@uniflowed/router 0.0.0-alpha.34 → 0.0.0-alpha.37
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 +24 -40
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +48 -25
- package/internal/compose.js +478 -0
- package/internal/error-view.js +189 -0
- package/internal/flight-browser.js +228 -0
- package/internal/flight-chunks.js +181 -0
- package/internal/flight-ssr.js +78 -0
- package/internal/flight.js +158 -0
- package/internal/head.js +219 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1615 -0
- package/internal/runtime.js +605 -2465
- package/internal/server-route.js +58 -0
- package/internal/shell.js +107 -0
- package/internal/stream.js +216 -3
- package/middleware.js +144 -14
- package/package.json +25 -6
- package/rsc-client.js +117 -0
- package/rsc-ssr.js +432 -0
- package/rsc.js +329 -0
- package/server-components.js +159 -0
- package/server.js +42 -88
package/internal/head.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a route's metadata, as the elements React
|
|
4
|
+
// hoists into `<head>`.
|
|
5
|
+
//
|
|
6
|
+
// Markup and nothing else — no hooks, no context — so the same component renders
|
|
7
|
+
// a route's head in the tree a server composes for React Server Components and
|
|
8
|
+
// in the tree the browser composes for a single-page application. `useSeo` in
|
|
9
|
+
// `./runtime.js` is the component-level way in, and it renders this too.
|
|
10
|
+
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import type { JsonLd, Metadata, Robots } from "./resolve.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One URL from a route's metadata, made absolute if it can be.
|
|
17
|
+
*
|
|
18
|
+
* Open Graph, Twitter and `rel="canonical"` all want an absolute URL, and a
|
|
19
|
+
* route module cannot know the host it is served from — so `metadataBase` is
|
|
20
|
+
* how a site says it once, and this is where it is applied.
|
|
21
|
+
*
|
|
22
|
+
* Three things it deliberately does not do. It does not resolve against the
|
|
23
|
+
* *page's* URL: `Head` renders inside the route and does not know it, and a
|
|
24
|
+
* `metadataBase` is a site-wide fact rather than a per-page one. It does not
|
|
25
|
+
* invent a base: with none declared the value is emitted exactly as written,
|
|
26
|
+
* which is what every page that predates this field already gets. And it does
|
|
27
|
+
* not throw — a `metadataBase` that is not a URL is a mistake in one field,
|
|
28
|
+
* and turning it into a blank page would be a worse answer than an unresolved
|
|
29
|
+
* `og:image`.
|
|
30
|
+
*/
|
|
31
|
+
function absoluteUrl(value: string, base: void | string): string {
|
|
32
|
+
if (base == null) return value;
|
|
33
|
+
try {
|
|
34
|
+
return new URL(value, base).href;
|
|
35
|
+
} catch {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The `robots` directives, as one `content` string, or `null` for none.
|
|
42
|
+
*
|
|
43
|
+
* `null` rather than an empty string, so a page that declared nothing gets no
|
|
44
|
+
* tag at all: "index, follow" is what a document with no `robots` meta already
|
|
45
|
+
* means, and writing it out tells a crawler what it had already assumed.
|
|
46
|
+
*
|
|
47
|
+
* Each declared field contributes its directive and no field implies another.
|
|
48
|
+
* `index: true` therefore emits `index` rather than nothing — the value is
|
|
49
|
+
* there to overrule a section that said otherwise, and a directive that
|
|
50
|
+
* disappeared because it agreed with the default would be a page saying
|
|
51
|
+
* something and no evidence of it in the markup.
|
|
52
|
+
*/
|
|
53
|
+
function robotsContent(robots: void | Robots): ?string {
|
|
54
|
+
if (robots == null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const directives: Array<string> = [];
|
|
58
|
+
if (robots.index != null) {
|
|
59
|
+
directives.push(robots.index ? "index" : "noindex");
|
|
60
|
+
}
|
|
61
|
+
if (robots.follow != null) {
|
|
62
|
+
directives.push(robots.follow ? "follow" : "nofollow");
|
|
63
|
+
}
|
|
64
|
+
if (robots.maxSnippet != null) {
|
|
65
|
+
directives.push(`max-snippet:${robots.maxSnippet}`);
|
|
66
|
+
}
|
|
67
|
+
if (robots.maxImagePreview != null) {
|
|
68
|
+
directives.push(`max-image-preview:${robots.maxImagePreview}`);
|
|
69
|
+
}
|
|
70
|
+
return directives.length === 0 ? null : directives.join(", ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One JSON-LD object as the text of a `<script>`.
|
|
75
|
+
*
|
|
76
|
+
* `<` is escaped so a string inside the data holding `</script>` cannot end
|
|
77
|
+
* the element early — the same escape `server.js` applies to the embedded
|
|
78
|
+
* loader data, and for the same reason: the text is the application's and the
|
|
79
|
+
* element it lands in is terminated by a character sequence rather than by a
|
|
80
|
+
* length. `dataScript` also escapes U+2028 and U+2029; those are about a
|
|
81
|
+
* string being parsed as JavaScript source, and this one never is.
|
|
82
|
+
*/
|
|
83
|
+
function jsonLdText(entry: JsonLd): string {
|
|
84
|
+
return JSON.stringify(entry).replace(/</g, "\\u003c");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One JSON-LD object, as the element that carries it.
|
|
89
|
+
*
|
|
90
|
+
* A function rather than an element written inline, because the suppression
|
|
91
|
+
* needs a line of its own; `docs/app/$layout.js` has the same shape for the
|
|
92
|
+
* same reason. `security/no-dangerously-set-inner-html` is about markup that
|
|
93
|
+
* came from somewhere and has to be sanitized before a browser parses it as
|
|
94
|
+
* HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
|
|
95
|
+
* answer for markup and no answer at all for JSON. This string is
|
|
96
|
+
* `JSON.stringify`'s output with `<` escaped, so nothing in it can close the
|
|
97
|
+
* element, and it is never parsed as HTML. There is also no other spelling:
|
|
98
|
+
* React escapes a text child, so `{"@type":"Article"}` would reach the page as
|
|
99
|
+
* `"@type"`, which is not JSON-LD any more.
|
|
100
|
+
*/
|
|
101
|
+
function jsonLdScript(entry: JsonLd): React.Node {
|
|
102
|
+
const text = jsonLdText(entry);
|
|
103
|
+
const html = { __html: text };
|
|
104
|
+
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
105
|
+
return <script key={text} type="application/ld+json" dangerouslySetInnerHTML={html} />;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export component Head(metadata: Metadata) {
|
|
109
|
+
const { title, description, metadataBase, canonical, robots } = metadata;
|
|
110
|
+
const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
|
|
111
|
+
const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
|
|
112
|
+
const crawler = robotsContent(robots);
|
|
113
|
+
// Read out of `alternates` once rather than through it at every use: the map
|
|
114
|
+
// is read inside a callback, and a refinement of `alternates.languages` does
|
|
115
|
+
// not survive being carried into one.
|
|
116
|
+
const languages = alternates?.languages;
|
|
117
|
+
// A page that said what it is called has said what its card is called. Every
|
|
118
|
+
// site that had to write both wrote the same string twice, and the second
|
|
119
|
+
// one is the one that goes stale — the docs site shipped thirty pages whose
|
|
120
|
+
// share cards carried an image and no title at all.
|
|
121
|
+
//
|
|
122
|
+
// `??`, not `||`: an empty string is a decision, and a page that deliberately
|
|
123
|
+
// has no card title should get none rather than the document's.
|
|
124
|
+
const cardTitle = openGraph?.title ?? title;
|
|
125
|
+
const cardDescription = openGraph?.description ?? description;
|
|
126
|
+
// `og:type` is one of the four properties Open Graph requires. A default is
|
|
127
|
+
// the difference between a document with a card and a document without one,
|
|
128
|
+
// and `website` is right for everything that is not an article or a video.
|
|
129
|
+
const cardType = openGraph?.type ?? "website";
|
|
130
|
+
// Only when the card was asked for. A page with no `twitter.card` gets no
|
|
131
|
+
// Twitter tags at all, which is what a site that never wanted one meant.
|
|
132
|
+
const twitterTitle = twitter != null ? (twitter.title ?? cardTitle) : null;
|
|
133
|
+
const twitterDescription = twitter != null ? (twitter.description ?? cardDescription) : null;
|
|
134
|
+
const twitterImageAlt = twitter != null ? (twitter.imageAlt ?? openGraph?.imageAlt) : null;
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
{title != null ? <title>{title}</title> : null}
|
|
138
|
+
{description != null ? <meta name="description" content={description} /> : null}
|
|
139
|
+
{crawler != null ? <meta name="robots" content={crawler} /> : null}
|
|
140
|
+
{href != null ? <link rel="canonical" href={href} /> : null}
|
|
141
|
+
{/* The set is reciprocal and includes this page, so a `hreflang` list is
|
|
142
|
+
usually the same list on every page of it — which is why it belongs
|
|
143
|
+
on the layout they share rather than on each of them.
|
|
144
|
+
|
|
145
|
+
`hrefLang` is React's spelling and it reaches the markup unchanged,
|
|
146
|
+
which is worth knowing before grepping a document for `hreflang` and
|
|
147
|
+
concluding it is missing. HTML attribute names are case-insensitive,
|
|
148
|
+
so the parser every crawler runs reads it as the same attribute; the
|
|
149
|
+
lowercase spelling is the one React warns about. */}
|
|
150
|
+
{languages != null
|
|
151
|
+
? Object.keys(languages).map((language) => (
|
|
152
|
+
<link
|
|
153
|
+
key={language}
|
|
154
|
+
rel="alternate"
|
|
155
|
+
hrefLang={language}
|
|
156
|
+
href={absoluteUrl(languages[language], metadataBase)}
|
|
157
|
+
/>
|
|
158
|
+
))
|
|
159
|
+
: null}
|
|
160
|
+
{pagination?.prev != null ? (
|
|
161
|
+
<link rel="prev" href={absoluteUrl(pagination.prev, metadataBase)} />
|
|
162
|
+
) : null}
|
|
163
|
+
{pagination?.next != null ? (
|
|
164
|
+
<link rel="next" href={absoluteUrl(pagination.next, metadataBase)} />
|
|
165
|
+
) : null}
|
|
166
|
+
{/* `og:url` *is* the canonical URL of the page, in Open Graph's own
|
|
167
|
+
words, so one declaration answers both rather than asking a project
|
|
168
|
+
to write the same URL twice and keep them in step. */}
|
|
169
|
+
{href != null ? <meta property="og:url" content={href} /> : null}
|
|
170
|
+
{cardTitle != null ? <meta property="og:title" content={cardTitle} /> : null}
|
|
171
|
+
{cardDescription != null ? (
|
|
172
|
+
<meta property="og:description" content={cardDescription} />
|
|
173
|
+
) : null}
|
|
174
|
+
{/* Only alongside something else. A document with `og:type` and nothing
|
|
175
|
+
more is not a card; it is one meta tag saying the page is a page. */}
|
|
176
|
+
{cardTitle != null || cardDescription != null || openGraph?.images != null ? (
|
|
177
|
+
<meta property="og:type" content={cardType} />
|
|
178
|
+
) : null}
|
|
179
|
+
{openGraph?.siteName != null ? (
|
|
180
|
+
<meta property="og:site_name" content={openGraph.siteName} />
|
|
181
|
+
) : null}
|
|
182
|
+
{openGraph?.images != null
|
|
183
|
+
? openGraph.images.map((image) => (
|
|
184
|
+
<meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
|
|
185
|
+
))
|
|
186
|
+
: null}
|
|
187
|
+
{openGraph?.imageAlt != null && openGraph?.images != null ? (
|
|
188
|
+
<meta property="og:image:alt" content={openGraph.imageAlt} />
|
|
189
|
+
) : null}
|
|
190
|
+
{/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
|
|
191
|
+
not, and a `property="twitter:card"` is ignored by the crawler that
|
|
192
|
+
reads it. */}
|
|
193
|
+
{twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
|
|
194
|
+
{twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
|
|
195
|
+
{twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
|
|
196
|
+
{/* X reads the `og:` tags when these are absent, so these are not
|
|
197
|
+
required — and every validator asks for them anyway, which is a good
|
|
198
|
+
enough reason when the value is one the page has already given. They
|
|
199
|
+
fall back through the card's title to the document's. */}
|
|
200
|
+
{twitterTitle != null ? <meta name="twitter:title" content={twitterTitle} /> : null}
|
|
201
|
+
{twitterDescription != null ? (
|
|
202
|
+
<meta name="twitter:description" content={twitterDescription} />
|
|
203
|
+
) : null}
|
|
204
|
+
{twitter?.images != null
|
|
205
|
+
? twitter.images.map((image) => (
|
|
206
|
+
<meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
|
|
207
|
+
))
|
|
208
|
+
: null}
|
|
209
|
+
{twitterImageAlt != null && twitter?.images != null ? (
|
|
210
|
+
<meta name="twitter:image:alt" content={twitterImageAlt} />
|
|
211
|
+
) : null}
|
|
212
|
+
{/* Last, and not hoisted into `<head>` with the rest: React hoists a
|
|
213
|
+
`<title>`, a `<meta>` and a `<link>`, and not a script whose body it
|
|
214
|
+
would have to carry. JSON-LD is read from anywhere in the document,
|
|
215
|
+
so these render where the route does. */}
|
|
216
|
+
{jsonLd != null ? jsonLd.map(jsonLdScript) : null}
|
|
217
|
+
</>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a server's document, made ready for React to
|
|
4
|
+
// hydrate.
|
|
5
|
+
//
|
|
6
|
+
// Where a server wrote the head's metadata, an element React leaves behind, and
|
|
7
|
+
// the placeholder action React writes into a form whose action is a function
|
|
8
|
+
// (its server and its client spell that placeholder differently): none of these
|
|
9
|
+
// comes from a route, and each would be reported as a mismatch. So each is put
|
|
10
|
+
// right before `hydrateRoot` compares the markup with the tree. Both ways of
|
|
11
|
+
// hydrating do it — `hydrate` in `../client.js`, for a route rendered from its
|
|
12
|
+
// modules, and `hydrateFlight` in `../rsc-client.js`, for one React Server
|
|
13
|
+
// Components rendered — so the code lives here rather than in either entry.
|
|
14
|
+
|
|
15
|
+
export function prepareDocumentForHydration(document: Document): void {
|
|
16
|
+
const head = document.head;
|
|
17
|
+
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
18
|
+
if (envelope != null && head.firstChild !== envelope) {
|
|
19
|
+
head.insertBefore(envelope, head.firstChild);
|
|
20
|
+
}
|
|
21
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
|
|
22
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
|
|
23
|
+
document.getElementById("_R_")?.remove();
|
|
24
|
+
normalizeReactFormActions(document);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
|
|
28
|
+
if (meta == null) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const colorScheme = head.querySelector('meta[name="color-scheme"]');
|
|
32
|
+
if (colorScheme != null && colorScheme !== meta) {
|
|
33
|
+
head.insertBefore(meta, colorScheme);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
head.appendChild(meta);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
|
|
40
|
+
const CLIENT_FORM_PLACEHOLDER =
|
|
41
|
+
"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')";
|
|
42
|
+
|
|
43
|
+
function normalizeReactFormActions(document: Document): void {
|
|
44
|
+
for (const form of document.querySelectorAll("form")) {
|
|
45
|
+
if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
|
|
46
|
+
form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the React that React Server Components need.
|
|
4
|
+
//
|
|
5
|
+
// The router installs beside React 19.2.3, the React that Expo SDK 57 and React
|
|
6
|
+
// Native 0.87 ship. Matching a URL, the native route table and a route rendered
|
|
7
|
+
// from its modules need nothing newer (ubugeeei-prod/uf#992). React Server
|
|
8
|
+
// Components do. They render through `react-server-dom-parcel`, React's own
|
|
9
|
+
// Flight renderer and client, which is released with React and requires the
|
|
10
|
+
// React it was released with: 19.3. So that package is an optional peer, and
|
|
11
|
+
// every module that loads it asks here first.
|
|
12
|
+
//
|
|
13
|
+
// # Why a check at run time
|
|
14
|
+
//
|
|
15
|
+
// A peer range cannot say it. The router has one range for `react`, and that
|
|
16
|
+
// range has to admit 19.2.3 for a native app, while an optional peer that is
|
|
17
|
+
// absent is never compared with anything. The failure is also not where the
|
|
18
|
+
// mistake is: React's Flight client imports against React 19.2 and fails later,
|
|
19
|
+
// inside a render, in terms of React's internals. So each entry that loads
|
|
20
|
+
// Flight refuses before it does anything, and names the React it found and the
|
|
21
|
+
// one it needs.
|
|
22
|
+
//
|
|
23
|
+
// It is a call rather than a statement at module scope, because no shipped
|
|
24
|
+
// module runs anything when it is imported
|
|
25
|
+
// (`crates/uf_lib/tests/package_surface.rs`).
|
|
26
|
+
|
|
27
|
+
import * as React from "react";
|
|
28
|
+
|
|
29
|
+
/** The oldest React that React Server Components render on. */
|
|
30
|
+
export const SERVER_COMPONENTS_REACT: string = "19.3.0";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why `entry` cannot run on React `installed`, or `null` when it can.
|
|
34
|
+
*
|
|
35
|
+
* The release numbers are compared and a prerelease tag is ignored, so a 19.3
|
|
36
|
+
* canary counts as 19.3: React names a canary after the release it leads to.
|
|
37
|
+
*/
|
|
38
|
+
export function serverComponentsRefusal(entry: string, installed: string): string | null {
|
|
39
|
+
const found = releaseOf(installed);
|
|
40
|
+
const needed = releaseOf(SERVER_COMPONENTS_REACT);
|
|
41
|
+
if (found != null && needed != null && !isBefore(found, needed)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
`@uniflowed/router: ${entry} needs React ${SERVER_COMPONENTS_REACT} or newer for React ` +
|
|
46
|
+
`Server Components, and the React it loaded is ${installed}. Install react, react-dom and ` +
|
|
47
|
+
"react-server-dom-parcel at ^19.3.0, or set `app.rsc: false` in uf.config.js to render " +
|
|
48
|
+
"routes from their modules, which the router supports from React 19.2.3."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refuse, with [`serverComponentsRefusal`]'s sentence, unless the React this
|
|
54
|
+
* module loaded can render React Server Components.
|
|
55
|
+
*/
|
|
56
|
+
export function requireServerComponentsReact(entry: string): void {
|
|
57
|
+
const refusal = serverComponentsRefusal(entry, React.version);
|
|
58
|
+
if (refusal != null) {
|
|
59
|
+
throw new Error(refusal);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `[major, minor, patch]` of a version, or `null` when it does not start with one. */
|
|
64
|
+
function releaseOf(version: string): [number, number, number] | null {
|
|
65
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
66
|
+
return match == null ? null : [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether release `a` comes before release `b`. */
|
|
70
|
+
function isBefore(a: [number, number, number], b: [number, number, number]): boolean {
|
|
71
|
+
for (let index = 0; index < 3; index += 1) {
|
|
72
|
+
if (a[index] !== b[index]) {
|
|
73
|
+
return a[index] < b[index];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|