@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/rsc.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/rsc`: a route, rendered as React Server Components.
|
|
4
|
+
//
|
|
5
|
+
// This module runs in the module graph uf resolves under the `react-server`
|
|
6
|
+
// export condition, which is the graph where `react` is the build with no
|
|
7
|
+
// `useState` in it and where React's Flight renderer is allowed to load
|
|
8
|
+
// (ubugeeei-prod/uf#519). It does three things, and each is a piece that
|
|
9
|
+
// already existed somewhere else:
|
|
10
|
+
//
|
|
11
|
+
// 1. **Resolve the URL** — `resolveMatch`, the same resolution a single-page
|
|
12
|
+
// application runs in the browser: the match, the modules, the loader, the
|
|
13
|
+
// metadata, the boundaries.
|
|
14
|
+
// 2. **Compose the tree** — `composeRoute`, the same walk `RouteView` makes,
|
|
15
|
+
// so a layout, a fallback, a template and a slot land where they would in
|
|
16
|
+
// any other render.
|
|
17
|
+
// 3. **Render it as a Flight payload** — `renderToReadableStream` from
|
|
18
|
+
// `react-server-dom-parcel`, which is React's own renderer and React's own
|
|
19
|
+
// wire format. uf writes no serialiser here and no decoder anywhere.
|
|
20
|
+
//
|
|
21
|
+
// The payload's root value is a [`FlightRoot`]: the route, as the part of it a
|
|
22
|
+
// hook reads, and the tree. The HTML renderer (`./server.js`) reads it to write
|
|
23
|
+
// the document, and the browser (`./client.js`) reads the same bytes to hydrate
|
|
24
|
+
// and, after that, to navigate — so what the browser renders is exactly what
|
|
25
|
+
// the server rendered, and no page, layout or loader module is ever the
|
|
26
|
+
// browser's to evaluate. A `"use client"` module is the one thing that crosses,
|
|
27
|
+
// as a reference to a chunk the browser loads.
|
|
28
|
+
//
|
|
29
|
+
// # What leaves before the payload does
|
|
30
|
+
//
|
|
31
|
+
// A redirect, and the status. Both are decided while the route resolves, which
|
|
32
|
+
// is before anything renders, so `renderFlight` answers a redirect instead of a
|
|
33
|
+
// stream and a route with the status it resolved to. That is the head-before-
|
|
34
|
+
// the-first-byte property `./internal/stream.js` depends on, kept from the
|
|
35
|
+
// other side: nothing the renderer learns while streaming can change the
|
|
36
|
+
// response line.
|
|
37
|
+
//
|
|
38
|
+
// # Error boundaries have to be client modules
|
|
39
|
+
//
|
|
40
|
+
// An `$error.js` catches a throw while the *browser* renders, so its component
|
|
41
|
+
// is the browser's, and a server component cannot be passed to a client one —
|
|
42
|
+
// Flight refuses a function where a reference was expected. So each boundary's
|
|
43
|
+
// component crosses as the client reference its `"use client"` directive made it
|
|
44
|
+
// in this graph, and a boundary module without the directive is refused here,
|
|
45
|
+
// with its file named, rather than by React with a sentence about functions.
|
|
46
|
+
|
|
47
|
+
import * as React from "react";
|
|
48
|
+
import { use } from "react";
|
|
49
|
+
// React's Flight server. Only this graph can load it: it refuses to evaluate
|
|
50
|
+
// unless `react` resolved under the `react-server` condition.
|
|
51
|
+
import { renderToReadableStream } from "react-server-dom-parcel/server";
|
|
52
|
+
|
|
53
|
+
import { noteRoute } from "@uniflowed/server/host";
|
|
54
|
+
|
|
55
|
+
import { BoundaryReporter } from "./internal/boundaries.js";
|
|
56
|
+
import { routeBoundaries } from "./internal/boundary-data.js";
|
|
57
|
+
import { composeRoute, pageComponent } from "./internal/compose.js";
|
|
58
|
+
import { ErrorRoutePage } from "./internal/error-view.js";
|
|
59
|
+
import { type FlightRoot, routeState } from "./internal/flight.js";
|
|
60
|
+
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
61
|
+
import type { ErrorModule, PageModule, ResolvedRoute, ResolvedSlot } from "./internal/resolve.js";
|
|
62
|
+
import { resolveFailure, resolveMatch } from "./internal/resolve.js";
|
|
63
|
+
import type { RouteParams, RouteTable, SearchParams } from "./internal/routing.js";
|
|
64
|
+
import { RedirectError, nearestBoundary } from "./internal/routing.js";
|
|
65
|
+
import { withServerRoute } from "./internal/server-route.js";
|
|
66
|
+
|
|
67
|
+
export type { FlightRoot, RouteState } from "./internal/flight.js";
|
|
68
|
+
// For `virtual:uf/rsc`, so a Server Component's `basePath()` is the project's.
|
|
69
|
+
export { installRouting } from "./internal/base-path.js";
|
|
70
|
+
|
|
71
|
+
/** What a host may tell the renderer about one render. */
|
|
72
|
+
export type FlightOptions = {|
|
|
73
|
+
/**
|
|
74
|
+
* Whether a loader may be left running into a `$loading.js` boundary.
|
|
75
|
+
*
|
|
76
|
+
* On by default: a payload streams, so a slow loader is a fallback now and
|
|
77
|
+
* its answer later. A prerender turns it off, because a file has no "later".
|
|
78
|
+
*/
|
|
79
|
+
readonly defer?: boolean,
|
|
80
|
+
/**
|
|
81
|
+
* Every exception the render recovered from, including the late ones.
|
|
82
|
+
*
|
|
83
|
+
* What it returns becomes the exception's digest. React writes the digest
|
|
84
|
+
* into the row that stands in for the part of the tree that failed, and sets
|
|
85
|
+
* it on the error its Flight client rebuilds from that row — which is how
|
|
86
|
+
* `./server.js` recognises, in the HTML renderer, the copy of an exception
|
|
87
|
+
* this renderer has already reported.
|
|
88
|
+
*/
|
|
89
|
+
readonly onError?: (error: mixed) => ?string,
|
|
90
|
+
/**
|
|
91
|
+
* Render the error boundary for this exception instead of resolving the URL.
|
|
92
|
+
*
|
|
93
|
+
* What the HTML renderer asks for when the shell it was streaming from the
|
|
94
|
+
* payload threw before its first byte: the route resolved, and rendering it
|
|
95
|
+
* did not.
|
|
96
|
+
*/
|
|
97
|
+
readonly failure?: {| readonly error: mixed |},
|
|
98
|
+
/** Stops the render, for a reader that went away. */
|
|
99
|
+
readonly signal?: AbortSignal,
|
|
100
|
+
|};
|
|
101
|
+
|
|
102
|
+
/** A render: a redirect to answer with, or a route and its payload. */
|
|
103
|
+
export type FlightRender =
|
|
104
|
+
| {| readonly kind: "redirect", readonly status: 307 | 308, readonly location: string |}
|
|
105
|
+
| {|
|
|
106
|
+
readonly kind: "route",
|
|
107
|
+
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
108
|
+
/** The payload, as React writes it. Read exactly once. */
|
|
109
|
+
readonly stream: ReadableStream<Uint8Array>,
|
|
110
|
+
/**
|
|
111
|
+
* The exception this route resolved to its error boundary for, when it did.
|
|
112
|
+
*
|
|
113
|
+
* The same field `./server.js` has always carried as `error`: `uf build`
|
|
114
|
+
* fails a route that set it and `uf dev` reports it. `forbidden()` and
|
|
115
|
+
* `unauthorized()` do not set it.
|
|
116
|
+
*/
|
|
117
|
+
readonly failure: mixed,
|
|
118
|
+
|};
|
|
119
|
+
|
|
120
|
+
/** Render one URL. */
|
|
121
|
+
export type FlightRenderer = (url: string, options?: FlightOptions) => Promise<FlightRender>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether this bundle marks the boundaries it renders.
|
|
125
|
+
*
|
|
126
|
+
* `BOUNDARY_MARKS` in `./internal/runtime.js` has the argument; a build replaces
|
|
127
|
+
* `import.meta.hot` with `undefined` and every use below folds away.
|
|
128
|
+
*/
|
|
129
|
+
const BOUNDARY_MARKS: boolean = import.meta.hot != null;
|
|
130
|
+
|
|
131
|
+
/** How a client reference says what it is. React's symbol, not uf's. */
|
|
132
|
+
const CLIENT_REFERENCE: symbol = Symbol.for("react.client.reference");
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The renderer for one route table.
|
|
136
|
+
*
|
|
137
|
+
* The table is the server's whole one — every page, layout and boundary module
|
|
138
|
+
* — and it is `virtual:uf/routes` as this graph generates it.
|
|
139
|
+
*/
|
|
140
|
+
export function createFlightRenderer(options: {|
|
|
141
|
+
readonly routes: RouteTable["routes"],
|
|
142
|
+
readonly notFound: RouteTable["notFound"],
|
|
143
|
+
readonly errors: RouteTable["errors"],
|
|
144
|
+
|}): FlightRenderer {
|
|
145
|
+
// Before anything else: on a React older than 19.3 nothing below can render,
|
|
146
|
+
// and React's Flight renderer would only say so from inside a render.
|
|
147
|
+
requireServerComponentsReact("@uniflowed/router/rsc");
|
|
148
|
+
const table: RouteTable = {
|
|
149
|
+
routes: options.routes,
|
|
150
|
+
notFound: options.notFound,
|
|
151
|
+
errors: options.errors,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return async function renderFlight(url: string, settings?: FlightOptions): Promise<FlightRender> {
|
|
155
|
+
let resolved: ResolvedRoute;
|
|
156
|
+
try {
|
|
157
|
+
const failure = settings?.failure;
|
|
158
|
+
resolved =
|
|
159
|
+
failure == null
|
|
160
|
+
? // `onMatch` records the route pattern on the request before the
|
|
161
|
+
// loader runs, so every line the loader logs names its route.
|
|
162
|
+
await resolveMatch(table, url, { defer: settings?.defer !== false, onMatch: noteRoute })
|
|
163
|
+
: await resolveFailure(table, url, failure.error);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof RedirectError) {
|
|
166
|
+
return { kind: "redirect", status: error.permanent ? 308 : 307, location: error.to };
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const errorFile = nearestBoundary(table.errors, resolved.pathname)?.file ?? null;
|
|
172
|
+
const route = forTheBrowser(resolved, errorFile);
|
|
173
|
+
const state = routeState(route);
|
|
174
|
+
const marks = BOUNDARY_MARKS ? routeBoundaries(route, errorFile) : null;
|
|
175
|
+
const tree = (
|
|
176
|
+
<>
|
|
177
|
+
{composeRoute(route, { page: pageElement(route), marks })}
|
|
178
|
+
{BOUNDARY_MARKS && marks != null ? (
|
|
179
|
+
<BoundaryReporter path={route.path} boundaries={marks} />
|
|
180
|
+
) : null}
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
const root: FlightRoot = { route: state, tree };
|
|
184
|
+
const report = settings?.onError;
|
|
185
|
+
// Inside the route's store, so a server component's `useRoute()` finds the
|
|
186
|
+
// route however many `await`s into the render it asks.
|
|
187
|
+
const stream = withServerRoute(state, () =>
|
|
188
|
+
renderToReadableStream(root, {
|
|
189
|
+
// Handed over as it is, so that the digest it returns reaches the row.
|
|
190
|
+
// Absent, React logs the exception to the console itself.
|
|
191
|
+
onError: report,
|
|
192
|
+
signal: settings?.signal,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
return { kind: "route", status: route.status, stream, failure: renderFailure(route) };
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The page element: the route's page with its data, the deferred page that
|
|
201
|
+
* waits for its loader, or the error route's page.
|
|
202
|
+
*
|
|
203
|
+
* An element for a component rather than a call, so that a page module with no
|
|
204
|
+
* component in it throws while React renders — inside the boundaries the
|
|
205
|
+
* composition placed — rather than before the render has begun.
|
|
206
|
+
*/
|
|
207
|
+
function pageElement(route: ResolvedRoute): React.Node {
|
|
208
|
+
const error = route.error;
|
|
209
|
+
if (error != null) {
|
|
210
|
+
return <ErrorRoutePage module={route.errorBoundary.module} error={error} />;
|
|
211
|
+
}
|
|
212
|
+
const deferred = route.deferred;
|
|
213
|
+
if (deferred != null) {
|
|
214
|
+
return (
|
|
215
|
+
<DeferredPage
|
|
216
|
+
page={route.page}
|
|
217
|
+
params={route.params}
|
|
218
|
+
searchParams={route.searchParams}
|
|
219
|
+
loader={deferred}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return (
|
|
224
|
+
<RoutePage
|
|
225
|
+
page={route.page}
|
|
226
|
+
params={route.params}
|
|
227
|
+
searchParams={route.searchParams}
|
|
228
|
+
data={route.data}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A route's page, with its loader's answer. */
|
|
234
|
+
component RoutePage(
|
|
235
|
+
page: PageModule,
|
|
236
|
+
params: RouteParams,
|
|
237
|
+
searchParams: SearchParams,
|
|
238
|
+
data: mixed,
|
|
239
|
+
) {
|
|
240
|
+
const Page = pageComponent(page);
|
|
241
|
+
return <Page params={params} searchParams={searchParams} data={data} />;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The same page, once the loader the router deferred has answered.
|
|
246
|
+
*
|
|
247
|
+
* `use` rather than an `async` component: it suspends at the same point, inside
|
|
248
|
+
* the innermost `$loading.js` boundary, and it is the one spelling the
|
|
249
|
+
* browser's `AwaitedPage` already uses for the same wait.
|
|
250
|
+
*/
|
|
251
|
+
component DeferredPage(
|
|
252
|
+
page: PageModule,
|
|
253
|
+
params: RouteParams,
|
|
254
|
+
searchParams: SearchParams,
|
|
255
|
+
loader: Promise<mixed>,
|
|
256
|
+
) {
|
|
257
|
+
return <RoutePage page={page} params={params} searchParams={searchParams} data={use(loader)} />;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The resolved route with every error boundary as the browser receives it.
|
|
262
|
+
*
|
|
263
|
+
* An error module is the server's module; what the browser needs is its
|
|
264
|
+
* component, and in this graph a `"use client"` component *is* a client
|
|
265
|
+
* reference. So each boundary becomes `{ default: <reference> }` — a plain
|
|
266
|
+
* object Flight can carry — and a boundary whose component is not a reference
|
|
267
|
+
* is refused with its file named.
|
|
268
|
+
*/
|
|
269
|
+
function forTheBrowser(resolved: ResolvedRoute, file: string | null): ResolvedRoute {
|
|
270
|
+
const boundary = resolved.errorBoundary;
|
|
271
|
+
return {
|
|
272
|
+
...resolved,
|
|
273
|
+
errorBoundary: { above: boundary.above, module: clientErrorModule(boundary.module, file) },
|
|
274
|
+
slots: resolved.slots.map(slotForTheBrowser),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function slotForTheBrowser(slot: ResolvedSlot): ResolvedSlot {
|
|
279
|
+
const boundary = slot.errorBoundary;
|
|
280
|
+
return {
|
|
281
|
+
...slot,
|
|
282
|
+
errorBoundary:
|
|
283
|
+
boundary == null
|
|
284
|
+
? null
|
|
285
|
+
: { above: boundary.above, module: clientErrorModule(boundary.module, null) },
|
|
286
|
+
slots: slot.slots.map(slotForTheBrowser),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function clientErrorModule(module: ?ErrorModule, file: string | null): ?ErrorModule {
|
|
291
|
+
if (module == null) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const component = module.default ?? module.Error;
|
|
295
|
+
if (component == null) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (!isClientReference(component)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`@uniflowed/router: ${file ?? "an `$error.js` inside a slot"} is an error boundary, and an ` +
|
|
303
|
+
"error boundary catches a throw while the browser renders — so its component runs in " +
|
|
304
|
+
"the browser, and the module has to open with the use client directive, as its first " +
|
|
305
|
+
"statement.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return { default: component };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isClientReference(value: mixed): boolean {
|
|
312
|
+
if (value == null || (typeof value !== "object" && typeof value !== "function")) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
const tagged: { +$$typeof?: mixed, ... } = (value: $FlowFixMe);
|
|
316
|
+
return tagged.$$typeof === CLIENT_REFERENCE;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The exception a resolved route fell back to its error boundary for. */
|
|
320
|
+
function renderFailure(resolved: ResolvedRoute): mixed {
|
|
321
|
+
if (resolved.error == null) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
return match (resolved.error) {
|
|
325
|
+
{kind: "thrown", error: const error} => error,
|
|
326
|
+
{kind: "unauthorized"} => undefined,
|
|
327
|
+
{kind: "forbidden"} => undefined,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router`, as a React Server Component imports it.
|
|
4
|
+
//
|
|
5
|
+
// The package root has two entries and an export condition picks between them:
|
|
6
|
+
// a module graph resolved under `react-server` — the one uf renders server
|
|
7
|
+
// components in (ubugeeei-prod/uf#519) — gets this file, and every other graph
|
|
8
|
+
// gets `./index.js`. Both export the same names, which is the point. A layout
|
|
9
|
+
// imports `Link` and `useRoute` from `@uniflowed/router` and means the same
|
|
10
|
+
// thing wherever it is rendered; what differs is what the names *are* here.
|
|
11
|
+
//
|
|
12
|
+
// * `Link`, `RouterProvider`, `RouteView` and `routerView` come from
|
|
13
|
+
// `./internal/runtime.js`, a `"use client"` module, so in this graph each one
|
|
14
|
+
// is a client reference: rendered as markup on the server and hydrated in
|
|
15
|
+
// the browser, with its code shipped for the browser's half only.
|
|
16
|
+
// * `useRoute`, `useLoaderData` and `useSeo` are server implementations. A
|
|
17
|
+
// server component has no context to read, so they read the route the
|
|
18
|
+
// Flight renderer is rendering — `./internal/server-route.js` — which is the
|
|
19
|
+
// same route the browser's hooks read back out of the payload. This
|
|
20
|
+
// repository's documentation site highlights the section a reader is in
|
|
21
|
+
// from a server layout, and would otherwise have needed a client component to
|
|
22
|
+
// find out where it was.
|
|
23
|
+
// * `useRouter` refuses, by name. Navigation happens in the browser, and a
|
|
24
|
+
// router a server component could hold would be methods that can never do
|
|
25
|
+
// anything.
|
|
26
|
+
// * `useIsServer` answers `true`, which is what it has always answered while a
|
|
27
|
+
// server renders.
|
|
28
|
+
//
|
|
29
|
+
// Everything else — matching, the router's control errors, resolution — is
|
|
30
|
+
// React-free and is the same code `./index.js` exports.
|
|
31
|
+
|
|
32
|
+
import * as React from "react";
|
|
33
|
+
import { use } from "react";
|
|
34
|
+
|
|
35
|
+
import { Head } from "./internal/head.js";
|
|
36
|
+
import type { Metadata } from "./internal/resolve.js";
|
|
37
|
+
import type { RouteInfo, Router } from "./internal/runtime.js";
|
|
38
|
+
import { serverRoute } from "./internal/server-route.js";
|
|
39
|
+
|
|
40
|
+
export type { ErrorProps, LayoutProps, PageProps } from "./index.js";
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
AppProps,
|
|
44
|
+
ErrorBoundary,
|
|
45
|
+
ErrorModule,
|
|
46
|
+
Interception,
|
|
47
|
+
JsonLd,
|
|
48
|
+
LayoutModule,
|
|
49
|
+
LinkPrefetch,
|
|
50
|
+
LoaderArgs,
|
|
51
|
+
Metadata,
|
|
52
|
+
MetadataArgs,
|
|
53
|
+
NavigateOptions,
|
|
54
|
+
NotFoundBoundary,
|
|
55
|
+
PageModule,
|
|
56
|
+
ResolvedRoute,
|
|
57
|
+
Robots,
|
|
58
|
+
RouteError,
|
|
59
|
+
RouteInfo,
|
|
60
|
+
RouteMatch,
|
|
61
|
+
RouteParamSpec,
|
|
62
|
+
RouteParams,
|
|
63
|
+
RouteRecord,
|
|
64
|
+
RouteTable,
|
|
65
|
+
Router,
|
|
66
|
+
ResolvedSlot,
|
|
67
|
+
SearchParams,
|
|
68
|
+
SlotRecord,
|
|
69
|
+
SlotRouteRecord,
|
|
70
|
+
TemplateModule,
|
|
71
|
+
TwitterCard,
|
|
72
|
+
} from "./internal/runtime.js";
|
|
73
|
+
|
|
74
|
+
export { Link, RouteView, RouterProvider, routerView } from "./internal/runtime.js";
|
|
75
|
+
// `app.router.basePath`, for a Server Component that builds an address itself.
|
|
76
|
+
// The rsc entry installs it; `./internal/base-path.js` has no directive, so this
|
|
77
|
+
// graph gets the function rather than a reference to it.
|
|
78
|
+
export { basePath } from "./internal/base-path.js";
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
ForbiddenError,
|
|
82
|
+
NotFoundError,
|
|
83
|
+
RedirectError,
|
|
84
|
+
UnauthorizedError,
|
|
85
|
+
buildRoute,
|
|
86
|
+
forbidden,
|
|
87
|
+
hasClientPage,
|
|
88
|
+
matchRoute,
|
|
89
|
+
notFound,
|
|
90
|
+
parseSearch,
|
|
91
|
+
permanentRedirect,
|
|
92
|
+
redirect,
|
|
93
|
+
routeErrorStatus,
|
|
94
|
+
splitUrl,
|
|
95
|
+
unauthorized,
|
|
96
|
+
} from "./internal/routing.js";
|
|
97
|
+
|
|
98
|
+
export { resolveFailure, resolveMatch } from "./internal/resolve.js";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The route this server component is rendering in.
|
|
102
|
+
*
|
|
103
|
+
* `pending` is always `false`: a pending navigation is a fact about a browser
|
|
104
|
+
* that has asked for the next route and not received it, and a server renders
|
|
105
|
+
* the route it was asked for. `data` waits for a loader the router deferred,
|
|
106
|
+
* which is what the browser's `useRoute` does too, so a component that reads
|
|
107
|
+
* the data suspends at the same boundary on both sides.
|
|
108
|
+
*/
|
|
109
|
+
export hook useRoute(): RouteInfo {
|
|
110
|
+
const route = serverRoute("useRoute");
|
|
111
|
+
return {
|
|
112
|
+
path: route.path,
|
|
113
|
+
pathname: route.pathname,
|
|
114
|
+
params: route.params,
|
|
115
|
+
searchParams: route.searchParams,
|
|
116
|
+
data: route.deferred == null ? route.data : use(route.deferred),
|
|
117
|
+
pending: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The current page's loader data; see `useRoute` for what a deferred loader does. */
|
|
122
|
+
export hook useLoaderData(): mixed {
|
|
123
|
+
const route = serverRoute("useLoaderData");
|
|
124
|
+
return route.deferred == null ? route.data : use(route.deferred);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A refusal: navigation is the browser's.
|
|
129
|
+
*
|
|
130
|
+
* Thrown rather than handed back as methods that do nothing, because a server
|
|
131
|
+
* component that called `router.push` in response to something would be
|
|
132
|
+
* waiting for a navigation that cannot happen, and nothing would say so.
|
|
133
|
+
*/
|
|
134
|
+
export hook useRouter(): Router {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"@uniflowed/router: useRouter() was called in a Server Component, and navigation happens " +
|
|
137
|
+
"in the browser. Call it from a client component — a module that opens with the use " +
|
|
138
|
+
"client directive — or render a <Link>, which is one, and which a Server Component can " +
|
|
139
|
+
"render.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Head elements a server component contributes, with the route's
|
|
145
|
+
* `metadataBase` applied to the relative URLs it names.
|
|
146
|
+
*
|
|
147
|
+
* The same component the browser's `useSeo` renders, and the same base: the one
|
|
148
|
+
* the root layout declared, read from the route rather than from context.
|
|
149
|
+
*/
|
|
150
|
+
export hook useSeo(seo: Metadata): React.Node {
|
|
151
|
+
const route = serverRoute("useSeo");
|
|
152
|
+
const base = seo.metadataBase ?? route.metadata.metadataBase;
|
|
153
|
+
return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Whether the app is being rendered on the server: here, always. */
|
|
157
|
+
export hook useIsServer(): boolean {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
package/server.js
CHANGED
|
@@ -24,14 +24,11 @@
|
|
|
24
24
|
// which.
|
|
25
25
|
|
|
26
26
|
import { noteRoute } from "@uniflowed/server/host";
|
|
27
|
-
import { ROOT_ID } from "./internal/document.js";
|
|
28
27
|
import * as React from "react";
|
|
29
28
|
|
|
30
29
|
import {
|
|
31
30
|
type DocumentBody,
|
|
32
|
-
type DocumentShell,
|
|
33
31
|
type WritableLike,
|
|
34
|
-
bodyOfText,
|
|
35
32
|
prerenderDocument,
|
|
36
33
|
renderDocument,
|
|
37
34
|
} from "./internal/stream.js";
|
|
@@ -47,6 +44,7 @@ import {
|
|
|
47
44
|
} from "./internal/runtime.js";
|
|
48
45
|
|
|
49
46
|
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
47
|
+
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
50
48
|
|
|
51
49
|
/** Asset URLs to reference from the document. */
|
|
52
50
|
export type RenderAssets = {|
|
|
@@ -101,6 +99,31 @@ export type PrerenderResult = {|
|
|
|
101
99
|
readonly headers?: { readonly [string]: string },
|
|
102
100
|
/** The exception this render fell back to its error boundary for; see [`RenderResult`]. */
|
|
103
101
|
readonly error?: mixed,
|
|
102
|
+
/**
|
|
103
|
+
* The Flight payload the document was rendered from, for a document React
|
|
104
|
+
* Server Components rendered.
|
|
105
|
+
*
|
|
106
|
+
* `uf build` writes it beside the document as the route's payload file, so a
|
|
107
|
+
* browser that navigates to a prerendered route fetches a file rather than
|
|
108
|
+
* asking a server — which is what makes a static host able to serve client
|
|
109
|
+
* navigation at all. Absent for a document rendered from its modules.
|
|
110
|
+
*/
|
|
111
|
+
readonly payload?: Uint8Array,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A route's payload, as a browser navigating to it is answered.
|
|
116
|
+
*
|
|
117
|
+
* `stream` is `null` for a redirect, whose `location` is already the target's
|
|
118
|
+
* payload URL when the target is on this origin: `fetch` follows it and lands
|
|
119
|
+
* on a payload.
|
|
120
|
+
*/
|
|
121
|
+
export type FlightResponse = {|
|
|
122
|
+
readonly status: number,
|
|
123
|
+
readonly headers: { readonly [string]: string },
|
|
124
|
+
readonly stream: ReadableStream<Uint8Array> | null,
|
|
125
|
+
/** The exception the route resolved to its error boundary for; see [`RenderResult`]. */
|
|
126
|
+
readonly error?: mixed,
|
|
104
127
|
|};
|
|
105
128
|
|
|
106
129
|
/** What a host may tell the renderer about one request. */
|
|
@@ -208,6 +231,14 @@ export type {
|
|
|
208
231
|
} from "./middleware.js";
|
|
209
232
|
export { createMiddlewareRunner } from "./middleware.js";
|
|
210
233
|
|
|
234
|
+
/**
|
|
235
|
+
* `app.router.basePath` and `trailingSlash`, for `virtual:uf/server` to install
|
|
236
|
+
* before the first render, and `basePath()` for a middleware or a route handler
|
|
237
|
+
* that builds an address itself. See `./internal/base-path.js`.
|
|
238
|
+
*/
|
|
239
|
+
export type { RoutingSettings, TrailingSlash } from "./internal/base-path.js";
|
|
240
|
+
export { basePath, installRouting } from "./internal/base-path.js";
|
|
241
|
+
|
|
211
242
|
/**
|
|
212
243
|
* The endpoint a `"use server"` export is dialled at.
|
|
213
244
|
*
|
|
@@ -237,15 +268,6 @@ type Resolution =
|
|
|
237
268
|
| {| readonly kind: "route", readonly route: ResolvedRoute |}
|
|
238
269
|
| {| readonly kind: "redirect", readonly error: RedirectError |};
|
|
239
270
|
|
|
240
|
-
/** A redirect, as the finished document `prerender` answers with. */
|
|
241
|
-
async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
|
|
242
|
-
return {
|
|
243
|
-
status: document.status,
|
|
244
|
-
headers: document.headers,
|
|
245
|
-
html: await document.text(),
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
271
|
/** The two ways one app answers for a URL. */
|
|
250
272
|
export type Renderer = {|
|
|
251
273
|
readonly render: (
|
|
@@ -258,6 +280,14 @@ export type Renderer = {|
|
|
|
258
280
|
assets: RenderAssets,
|
|
259
281
|
options?: RenderOptions,
|
|
260
282
|
) => Promise<PrerenderResult>,
|
|
283
|
+
/**
|
|
284
|
+
* A route's payload, for a browser that is navigating rather than loading a
|
|
285
|
+
* document. Only a renderer for React Server Components has one.
|
|
286
|
+
*/
|
|
287
|
+
readonly flight?: (
|
|
288
|
+
url: string,
|
|
289
|
+
options?: {| readonly onError?: (error: mixed) => void |},
|
|
290
|
+
) => Promise<FlightResponse>,
|
|
261
291
|
|};
|
|
262
292
|
|
|
263
293
|
export function createRenderer(options: {|
|
|
@@ -442,54 +472,6 @@ function renderFailure(resolved: ResolvedRoute): mixed {
|
|
|
442
472
|
};
|
|
443
473
|
}
|
|
444
474
|
|
|
445
|
-
function redirectDocument(error: RedirectError): RenderResult {
|
|
446
|
-
const target = escapeAttribute(error.to);
|
|
447
|
-
// A document rather than an empty body, because a redirect is still an answer
|
|
448
|
-
// a browser may be shown; it goes through the same three methods as a
|
|
449
|
-
// rendered one so that a host has one shape to write, not two.
|
|
450
|
-
const body = bodyOfText(
|
|
451
|
-
`<!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`,
|
|
452
|
-
);
|
|
453
|
-
return {
|
|
454
|
-
status: error.permanent ? 308 : 307,
|
|
455
|
-
headers: { Location: error.to },
|
|
456
|
-
pipe: body.pipe,
|
|
457
|
-
stream: body.stream,
|
|
458
|
-
text: body.text,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* The document uf writes around the app's markup.
|
|
464
|
-
*
|
|
465
|
-
* The same two shapes `assemble` chose between, decided from the same evidence
|
|
466
|
-
* — whether the markup opens with `<html>` — but stated up front instead of
|
|
467
|
-
* afterwards, because a stream has no "afterwards" in which to splice a head.
|
|
468
|
-
* An app whose root layout renders `<html>` owns the whole document and the
|
|
469
|
-
* client hydrates `document`, so uf contributes only the tags that go before
|
|
470
|
-
* `</head>`. An app that renders only content is wrapped in a minimal shell
|
|
471
|
-
* around `<div id="uf-root">`, which is what the client hydrates instead.
|
|
472
|
-
*
|
|
473
|
-
* `internal/stream.js` picks between them on the opening bytes React writes;
|
|
474
|
-
* everything either shape is made of is here, so what a uf document contains is
|
|
475
|
-
* still readable in one place.
|
|
476
|
-
*
|
|
477
|
-
* # Why the shell is three strings and not one
|
|
478
|
-
*
|
|
479
|
-
* Because uf's own `<head>` has to still be open when React's head tags arrive.
|
|
480
|
-
* React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
|
|
481
|
-
* itself, and here it wrote none — so with one string this shell closed its
|
|
482
|
-
* head before the app had rendered a byte, and every `og:` tag and the
|
|
483
|
-
* `<link rel="canonical">` landed in the body, where a crawler ignores them.
|
|
484
|
-
* `open` is uf's head up to that point, `body` is the rest of it and the
|
|
485
|
-
* wrapper, and what goes between them is whatever `assembled` lifts out of the
|
|
486
|
-
* app's own markup. See ubugeeei-prod/uf#547.
|
|
487
|
-
*
|
|
488
|
-
* That is also why no `<title>` is written here any more. It was, from
|
|
489
|
-
* `resolved.metadata.title` — the same string `Head` renders — so a document
|
|
490
|
-
* carried two of them, one in each place, and only one was where a browser
|
|
491
|
-
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
492
|
-
*/
|
|
493
475
|
/**
|
|
494
476
|
* The document a single-page build writes, and the only one it writes.
|
|
495
477
|
*
|
|
@@ -519,31 +501,3 @@ export function shellDocument(assets: RenderAssets): string {
|
|
|
519
501
|
const shell = shellFor(assets);
|
|
520
502
|
return `${shell.open}${shell.body}${shell.close}`;
|
|
521
503
|
}
|
|
522
|
-
|
|
523
|
-
function shellFor(assets: RenderAssets): DocumentShell {
|
|
524
|
-
const head = headTags(assets);
|
|
525
|
-
return {
|
|
526
|
-
head,
|
|
527
|
-
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
528
|
-
body: `${head}</head><body><div id="${ROOT_ID}">`,
|
|
529
|
-
close: `</div></body></html>\n`,
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function headTags(assets: RenderAssets): string {
|
|
534
|
-
let tags = "";
|
|
535
|
-
for (const href of assets.styles) {
|
|
536
|
-
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
537
|
-
}
|
|
538
|
-
for (const href of assets.preloads) {
|
|
539
|
-
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
540
|
-
}
|
|
541
|
-
for (const src of assets.scripts) {
|
|
542
|
-
tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
|
|
543
|
-
}
|
|
544
|
-
return tags;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function escapeAttribute(value: string): string {
|
|
548
|
-
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
549
|
-
}
|