@uniflowed/vite 0.0.0-alpha.7 → 0.0.0-alpha.8
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/driver.js +369 -38
- package/index.js +114 -16
- package/internal/http.js +79 -0
- package/internal/routes.js +244 -18
- package/internal/rsc.js +151 -0
- package/internal/serve.js +137 -215
- package/package.json +3 -2
package/index.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// that hydrates it, and the server entry that renders it. In
|
|
17
17
|
// development it also renders every HTML request on the
|
|
18
18
|
// server, so `uf dev` serves the same markup `uf build` writes.
|
|
19
|
+
// The client's copy of the route table is not the server's:
|
|
20
|
+
// `internal/rsc.js` reads the RSC analysis and leaves out the
|
|
21
|
+
// page of every route no client boundary reaches, so that
|
|
22
|
+
// route's modules never enter the browser bundle.
|
|
19
23
|
// * `uf:mdx` — `@mdx-js/rollup`, configured for React with GitHub-flavoured
|
|
20
24
|
// markdown, front matter, heading ids and build-time syntax
|
|
21
25
|
// highlighting, so `.mdx` works with
|
|
@@ -30,7 +34,7 @@ import path from "node:path";
|
|
|
30
34
|
import mdx from "@mdx-js/rollup";
|
|
31
35
|
import rehypeSlug from "rehype-slug";
|
|
32
36
|
|
|
33
|
-
import { reportRenderError } from "./internal/events.js";
|
|
37
|
+
import { emit, reportRenderError } from "./internal/events.js";
|
|
34
38
|
import { highlightPlugin } from "./internal/highlight.js";
|
|
35
39
|
import remarkFrontmatter from "remark-frontmatter";
|
|
36
40
|
import remarkGfm from "remark-gfm";
|
|
@@ -43,6 +47,7 @@ import {
|
|
|
43
47
|
preambleCode,
|
|
44
48
|
refreshRuntimeSource,
|
|
45
49
|
} from "./internal/refresh.js";
|
|
50
|
+
import { RSC_MANIFEST_ENV, clientRouteFilter, readRscManifest } from "./internal/rsc.js";
|
|
46
51
|
import {
|
|
47
52
|
RESERVED,
|
|
48
53
|
VIRTUAL,
|
|
@@ -52,6 +57,8 @@ import {
|
|
|
52
57
|
serverModuleSource,
|
|
53
58
|
} from "./internal/routes.js";
|
|
54
59
|
import { TransformService, isFlowModule } from "@uniflowed/host/transform";
|
|
60
|
+
import { send, toRequest } from "./internal/http.js";
|
|
61
|
+
import { withRequest } from "./internal/serve.js";
|
|
55
62
|
|
|
56
63
|
/** A resolved virtual id: Vite's convention is a leading NUL byte. */
|
|
57
64
|
const resolved = (id) => `\0${id}`;
|
|
@@ -138,6 +145,32 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
138
145
|
return service;
|
|
139
146
|
};
|
|
140
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The browser's copy of the route table.
|
|
150
|
+
*
|
|
151
|
+
* The manifest is read here rather than once at start-up because `uf dev`
|
|
152
|
+
* rewrites it whenever the graph moves, and this hook runs again when it
|
|
153
|
+
* does — a table built from a manifest read at start-up would be the answer
|
|
154
|
+
* for the project as it was when the server started.
|
|
155
|
+
*
|
|
156
|
+
* The count is emitted rather than computed on the Rust side, and that is
|
|
157
|
+
* the point of it: `uf build` prints what the table it just generated
|
|
158
|
+
* contains, not what a second implementation of this decision predicted it
|
|
159
|
+
* would. Only for a build — a dev server has no summary to be true in.
|
|
160
|
+
*/
|
|
161
|
+
const clientRoutesModule = (table) => {
|
|
162
|
+
const shipsPage = clientRouteFilter(
|
|
163
|
+
readRscManifest(process.env[RSC_MANIFEST_ENV]),
|
|
164
|
+
root,
|
|
165
|
+
table,
|
|
166
|
+
);
|
|
167
|
+
const kept = new Set(table.routes.filter(shipsPage));
|
|
168
|
+
if (server == null) {
|
|
169
|
+
emit("rsc-split", { pages: kept.size, routes: table.routes.length });
|
|
170
|
+
}
|
|
171
|
+
return routesModuleSource(table, { shipsPage: (route) => kept.has(route) });
|
|
172
|
+
};
|
|
173
|
+
|
|
141
174
|
return {
|
|
142
175
|
name: "uf:flow",
|
|
143
176
|
enforce: "pre",
|
|
@@ -194,9 +227,16 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
194
227
|
return null;
|
|
195
228
|
},
|
|
196
229
|
|
|
197
|
-
load(id) {
|
|
230
|
+
load(id, loadOptions) {
|
|
198
231
|
if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
|
|
199
|
-
if (id === resolved(VIRTUAL.routes))
|
|
232
|
+
if (id === resolved(VIRTUAL.routes)) {
|
|
233
|
+
const table = scanRoutes(appRoot);
|
|
234
|
+
// The server renders every route, so the server's table is the whole
|
|
235
|
+
// one and is generated with no filter at all. Only the browser's copy
|
|
236
|
+
// is split.
|
|
237
|
+
if (isSsr(this, loadOptions)) return routesModuleSource(table);
|
|
238
|
+
return clientRoutesModule(table);
|
|
239
|
+
}
|
|
200
240
|
if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
|
|
201
241
|
if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
|
|
202
242
|
if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
|
|
@@ -308,6 +348,27 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
308
348
|
devServer.watcher.on("add", onRouteFile);
|
|
309
349
|
devServer.watcher.on("unlink", onRouteFile);
|
|
310
350
|
|
|
351
|
+
// The same problem one level up. Adding `"use client"` to a module, or
|
|
352
|
+
// deleting the import that reached it, changes which routes the browser
|
|
353
|
+
// is given a page for — and touches no reserved file name, so nothing
|
|
354
|
+
// above notices. `uf dev` rewrites the RSC manifest when the analysis
|
|
355
|
+
// moves and only then, so this fires when the answer changed rather than
|
|
356
|
+
// on every keystroke. Watched explicitly because the file is uf's own
|
|
357
|
+
// artefact and is in no module graph.
|
|
358
|
+
const manifestFile = process.env[RSC_MANIFEST_ENV];
|
|
359
|
+
if (manifestFile != null && manifestFile !== "") {
|
|
360
|
+
const manifestPath = path.resolve(manifestFile);
|
|
361
|
+
devServer.watcher.add(manifestPath);
|
|
362
|
+
const onManifest = (file) => {
|
|
363
|
+
if (path.resolve(file) !== manifestPath) return;
|
|
364
|
+
const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
|
|
365
|
+
if (routes) devServer.moduleGraph.invalidateModule(routes);
|
|
366
|
+
devServer.ws.send({ type: "full-reload", path: "*" });
|
|
367
|
+
};
|
|
368
|
+
devServer.watcher.on("add", onManifest);
|
|
369
|
+
devServer.watcher.on("change", onManifest);
|
|
370
|
+
}
|
|
371
|
+
|
|
311
372
|
// After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
|
|
312
373
|
// static files are served first and only a document request reaches
|
|
313
374
|
// the renderer.
|
|
@@ -316,20 +377,47 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
316
377
|
if (!wantsDocument(request)) return next();
|
|
317
378
|
try {
|
|
318
379
|
const url = request.url ?? "/";
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
380
|
+
const entry = await importServerEntry(devServer);
|
|
381
|
+
const asRequest = await toRequest(request, devServer.config);
|
|
382
|
+
|
|
383
|
+
// One request, owned here and settled once the document has been
|
|
384
|
+
// written — the same lifecycle `driver.js` gives `uf dev` and
|
|
385
|
+
// `internal/serve.js` gives `uf preview` and `uf start`. A project
|
|
386
|
+
// driving Vite itself must not get a different answer about when
|
|
387
|
+
// `after()` runs than the same project run through `uf dev`; see
|
|
388
|
+
// `internal/serve.js` and ubugeeei-prod/uf#389.
|
|
389
|
+
//
|
|
390
|
+
// Only document requests reach here, so unlike `driver.js` there is
|
|
391
|
+
// no path where uf hands the response back to Vite's chain: what is
|
|
392
|
+
// below either writes it or throws.
|
|
393
|
+
await withRequest(entry, asRequest, async () => {
|
|
394
|
+
// Before the page: a middleware guards a subtree, and a page
|
|
395
|
+
// rendered while the guard on it had not run is the whole of
|
|
396
|
+
// ubugeeei-prod/uf#260. Only document requests reach here, so this
|
|
397
|
+
// is the page half of the guarantee; `driver.js` makes the same
|
|
398
|
+
// call above the route handlers, for every method.
|
|
399
|
+
const guarded = await entry.runMiddleware(asRequest);
|
|
400
|
+
if (guarded != null) {
|
|
401
|
+
await send(response, guarded);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const result = await entry.render(
|
|
406
|
+
url,
|
|
407
|
+
{ scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
|
|
408
|
+
{ onError: (error) => reportRenderError(devServer, url, error) },
|
|
409
|
+
);
|
|
410
|
+
if (result.error != null) reportRenderError(devServer, url, result.error);
|
|
411
|
+
// Collected rather than piped, for the reason `driver.js` gives at
|
|
412
|
+
// step 4: `transformIndexHtml` is a whole-document hook.
|
|
413
|
+
const html = await devServer.transformIndexHtml(url, await result.text());
|
|
414
|
+
response.statusCode = result.status;
|
|
415
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
416
|
+
for (const [name, value] of Object.entries(result.headers ?? {})) {
|
|
417
|
+
response.setHeader(name, value);
|
|
418
|
+
}
|
|
419
|
+
response.end(html);
|
|
324
420
|
});
|
|
325
|
-
if (result.error != null) reportRenderError(devServer, url, result.error);
|
|
326
|
-
const html = await devServer.transformIndexHtml(url, result.html);
|
|
327
|
-
response.statusCode = result.status;
|
|
328
|
-
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
329
|
-
for (const [name, value] of Object.entries(result.headers ?? {})) {
|
|
330
|
-
response.setHeader(name, value);
|
|
331
|
-
}
|
|
332
|
-
response.end(html);
|
|
333
421
|
} catch (error) {
|
|
334
422
|
devServer.ssrFixStacktrace(error);
|
|
335
423
|
next(error);
|
|
@@ -513,6 +601,16 @@ function packageOf(file) {
|
|
|
513
601
|
return up === -1 ? parts.slice(0, -1).join("/") || file : parts.slice(up, up + 2).join("/");
|
|
514
602
|
}
|
|
515
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Whether a hook is running for the server environment.
|
|
606
|
+
*
|
|
607
|
+
* Both spellings, for the reason `transform` above checks both: Vite 6 moved
|
|
608
|
+
* the answer onto the plugin context and the `ssr` option is the older one.
|
|
609
|
+
*/
|
|
610
|
+
function isSsr(context, options) {
|
|
611
|
+
return options?.ssr === true || context?.environment?.name === "ssr";
|
|
612
|
+
}
|
|
613
|
+
|
|
516
614
|
function cleanId(id) {
|
|
517
615
|
const at = id.indexOf("?");
|
|
518
616
|
return at === -1 ? id : id.slice(0, at);
|
package/internal/http.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: executed by the host that runs Vite, before any transform.
|
|
4
|
+
//
|
|
5
|
+
// Node's request and response objects on one side, the platform's `Request`
|
|
6
|
+
// and `Response` on the other.
|
|
7
|
+
//
|
|
8
|
+
// uf's server contracts are the platform's — a route handler and a middleware
|
|
9
|
+
// both take a `Request` and return a `Response`, because that is what runs
|
|
10
|
+
// unchanged on Node.js, Bun, Deno and a Cloudflare Worker. Node's dev server
|
|
11
|
+
// speaks `IncomingMessage` and `ServerResponse`, so exactly one place has to
|
|
12
|
+
// translate.
|
|
13
|
+
//
|
|
14
|
+
// It is a module rather than two functions in `driver.js` because there are
|
|
15
|
+
// two dev servers: `driver.js` is what `uf dev` spawns, and the `uf:flow`
|
|
16
|
+
// plugin's own `configureServer` is what a project using Vite directly gets.
|
|
17
|
+
// Both have to run the same middleware before the same request, and a second
|
|
18
|
+
// copy of this translation is how the two would come to disagree about, say,
|
|
19
|
+
// whether a repeated header is joined or appended.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A Node request as a `Request`.
|
|
23
|
+
*
|
|
24
|
+
* The body is read as a stream where the host supports it, because a handler
|
|
25
|
+
* that accepts an upload should not need the whole thing buffered before it
|
|
26
|
+
* starts.
|
|
27
|
+
*
|
|
28
|
+
* @param {import("node:http").IncomingMessage} incoming
|
|
29
|
+
* @param {{server?: {https?: unknown}} | undefined} config the resolved Vite config
|
|
30
|
+
*/
|
|
31
|
+
export async function toRequest(incoming, config) {
|
|
32
|
+
const host = incoming.headers.host ?? "localhost";
|
|
33
|
+
const protocol = config?.server?.https == null ? "http" : "https";
|
|
34
|
+
const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
|
|
35
|
+
|
|
36
|
+
const headers = new Headers();
|
|
37
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
38
|
+
if (value == null) continue;
|
|
39
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
40
|
+
headers.append(name, entry);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const method = (incoming.method ?? "GET").toUpperCase();
|
|
45
|
+
const init = { method, headers };
|
|
46
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
47
|
+
// `duplex` is required by the specification whenever a body is a stream,
|
|
48
|
+
// and Node throws without it.
|
|
49
|
+
init.body = incoming;
|
|
50
|
+
init.duplex = "half";
|
|
51
|
+
}
|
|
52
|
+
return new Request(url, init);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write a `Response` to a Node response.
|
|
57
|
+
*
|
|
58
|
+
* One implementation, reached late. This was a second copy of the loop in
|
|
59
|
+
* `@uniflowed/server`'s `node.js`, and the two drifted the moment the shared
|
|
60
|
+
* one moved: `uf start` and every adapter lost the socket pacing and the
|
|
61
|
+
* hang-up cancel while `uf dev` and `uf preview` kept them, which is a
|
|
62
|
+
* deployment whose memory profile differs from the one that was checked. See
|
|
63
|
+
* ubugeeei-prod/uf#400.
|
|
64
|
+
*
|
|
65
|
+
* The import is inside the function, and that is not a style choice.
|
|
66
|
+
* `driver.js` imports this module *statically* and registers the Flow loader
|
|
67
|
+
* hooks in its own body, so anything reachable from a static import here is
|
|
68
|
+
* read by Node before there is anything to compile Flow with —
|
|
69
|
+
* `@uniflowed/server/node` is Flow source, and a static re-export of it makes
|
|
70
|
+
* every `uf build` die on `import type` with a `SyntaxError`. `loadBuild` in
|
|
71
|
+
* `internal/serve.js` defers for the same reason and says so.
|
|
72
|
+
*
|
|
73
|
+
* @param {import("node:http").ServerResponse} outgoing
|
|
74
|
+
* @param {Response} result
|
|
75
|
+
*/
|
|
76
|
+
export async function send(outgoing, result) {
|
|
77
|
+
const { send: write } = await import("@uniflowed/server/node");
|
|
78
|
+
await write(outgoing, result);
|
|
79
|
+
}
|
package/internal/routes.js
CHANGED
|
@@ -24,6 +24,7 @@ export const RESERVED = Object.freeze({
|
|
|
24
24
|
middleware: "_uf.middleware",
|
|
25
25
|
notFound: "_uf.not-found",
|
|
26
26
|
error: "_uf.error",
|
|
27
|
+
loading: "_uf.loading",
|
|
27
28
|
route: "_uf.route",
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -43,10 +44,27 @@ const MAX_DEPTH = 32;
|
|
|
43
44
|
* @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
|
|
44
45
|
* @property {string} page absolute path of the page module
|
|
45
46
|
* @property {ReadonlyArray<string>} layouts absolute paths, root first
|
|
46
|
-
* @property {ReadonlyArray<string>}
|
|
47
|
+
* @property {ReadonlyArray<{above: number, module: string}>} loading the
|
|
48
|
+
* `<Suspense>` boundaries in scope, root first; `above` is how many of
|
|
49
|
+
* `layouts` are outside each one
|
|
47
50
|
* @property {boolean} mdx whether the page is MDX content
|
|
48
51
|
*/
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* One middleware — everything under a directory, guarded before it answers.
|
|
55
|
+
*
|
|
56
|
+
* A flat table keyed by the directory's route path, rather than an array on
|
|
57
|
+
* every route the way layouts are accumulated. That was the first shape and it
|
|
58
|
+
* left two holes: `/dashboard/typo` matches no route, so a per-route array
|
|
59
|
+
* would have rendered the 404 with the guard skipped, and a route handler is
|
|
60
|
+
* in a table of its own, so guarding pages would have guarded half of them.
|
|
61
|
+
* The path is the matcher, so the path is what the table carries.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {object} Middleware
|
|
64
|
+
* @property {string} path route path of the directory it guards, `/` at the root
|
|
65
|
+
* @property {string} module absolute path of the middleware module
|
|
66
|
+
*/
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* One route handler — a path that answers a request instead of rendering.
|
|
52
70
|
*
|
|
@@ -89,6 +107,28 @@ const MAX_DEPTH = 32;
|
|
|
89
107
|
* @property {ReadonlyArray<string>} layouts absolute paths, root first
|
|
90
108
|
*/
|
|
91
109
|
|
|
110
|
+
/**
|
|
111
|
+
* One loading boundary — the fallback for the segment that declares it.
|
|
112
|
+
*
|
|
113
|
+
* Not the nearest-ancestor shape the other two boundaries have, and the
|
|
114
|
+
* difference is the whole of what a fallback is. A not-found or an error
|
|
115
|
+
* boundary is *chosen*: one of them renders, and the resolver picks the
|
|
116
|
+
* nearest above the path. Loading boundaries *nest*: `app/_uf.loading.js` and
|
|
117
|
+
* `app/docs/_uf.loading.js` are two `<Suspense>` elements on one route, one
|
|
118
|
+
* inside the other, and both are in the tree at once. So they accumulate down
|
|
119
|
+
* the walk the way layouts do rather than being matched afterwards, and each
|
|
120
|
+
* route carries the list that applies to it.
|
|
121
|
+
*
|
|
122
|
+
* `above` is the count of the route's `layouts` that sit outside the boundary
|
|
123
|
+
* — the layouts that render immediately, which is what "the shell around a
|
|
124
|
+
* slow page" means. It is the same number, spelled the same way, as
|
|
125
|
+
* `ResolvedRoute["errorBoundary"].above` in the router runtime.
|
|
126
|
+
*
|
|
127
|
+
* @typedef {object} LoadingBoundary
|
|
128
|
+
* @property {number} above how many of the route's layouts are outside it
|
|
129
|
+
* @property {string} module absolute path of the loading module
|
|
130
|
+
*/
|
|
131
|
+
|
|
92
132
|
/**
|
|
93
133
|
* Scan `appRoot` for routes.
|
|
94
134
|
*
|
|
@@ -97,25 +137,49 @@ const MAX_DEPTH = 32;
|
|
|
97
137
|
* library project has no router root, and that is not a mistake.
|
|
98
138
|
*
|
|
99
139
|
* @param {string} appRoot absolute path of the router root (`app/`)
|
|
100
|
-
* @returns {{
|
|
140
|
+
* @returns {{
|
|
141
|
+
* routes: Route[],
|
|
142
|
+
* handlers: Handler[],
|
|
143
|
+
* middleware: Middleware[],
|
|
144
|
+
* notFound: NotFoundBoundary[],
|
|
145
|
+
* errors: ErrorBoundary[],
|
|
146
|
+
* }}
|
|
101
147
|
*/
|
|
102
148
|
export function scanRoutes(appRoot) {
|
|
103
149
|
const routes = [];
|
|
104
150
|
const handlers = [];
|
|
151
|
+
const middleware = [];
|
|
105
152
|
const notFound = [];
|
|
106
153
|
const errors = [];
|
|
107
|
-
if (!isDirectory(appRoot)) return { routes, handlers, notFound, errors };
|
|
154
|
+
if (!isDirectory(appRoot)) return { routes, handlers, middleware, notFound, errors };
|
|
108
155
|
|
|
109
|
-
const walk = (directory, segments, layouts,
|
|
156
|
+
const walk = (directory, segments, layouts, loading, depth) => {
|
|
110
157
|
if (depth > MAX_DEPTH) return;
|
|
111
158
|
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
112
159
|
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
113
160
|
);
|
|
114
161
|
|
|
115
162
|
const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
|
|
116
|
-
const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
|
|
117
163
|
const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
|
|
118
|
-
|
|
164
|
+
|
|
165
|
+
// Inside this directory's own layout, which is where Next.js puts it and
|
|
166
|
+
// the only placement that makes sense: the fallback is what shows *within*
|
|
167
|
+
// the frame this segment draws, so the frame has to be outside it.
|
|
168
|
+
// `nextLayouts.length` is therefore the count taken after the own layout is
|
|
169
|
+
// added, not before. A segment with a loading file and no layout of its own
|
|
170
|
+
// still gets a boundary — it just shares its parent's frame.
|
|
171
|
+
const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS);
|
|
172
|
+
const nextLoading = ownLoading
|
|
173
|
+
? [...loading, { above: nextLayouts.length, module: ownLoading }]
|
|
174
|
+
: loading;
|
|
175
|
+
|
|
176
|
+
// A middleware guards this directory and everything below it, whether or
|
|
177
|
+
// not this directory is itself a route: `app/dashboard/_uf.middleware.js`
|
|
178
|
+
// with no `_uf.page.js` beside it still guards `/dashboard/settings`.
|
|
179
|
+
const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
|
|
180
|
+
if (ownMiddleware) {
|
|
181
|
+
middleware.push({ path: routeFromSegments(segments).path, module: ownMiddleware });
|
|
182
|
+
}
|
|
119
183
|
|
|
120
184
|
const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS);
|
|
121
185
|
if (page) {
|
|
@@ -126,7 +190,7 @@ export function scanRoutes(appRoot) {
|
|
|
126
190
|
params,
|
|
127
191
|
page,
|
|
128
192
|
layouts: nextLayouts,
|
|
129
|
-
|
|
193
|
+
loading: nextLoading,
|
|
130
194
|
mdx: page.endsWith(".mdx"),
|
|
131
195
|
});
|
|
132
196
|
}
|
|
@@ -173,7 +237,7 @@ export function scanRoutes(appRoot) {
|
|
|
173
237
|
path.join(directory, entry.name),
|
|
174
238
|
[...segments, entry.name],
|
|
175
239
|
nextLayouts,
|
|
176
|
-
|
|
240
|
+
nextLoading,
|
|
177
241
|
depth + 1,
|
|
178
242
|
);
|
|
179
243
|
}
|
|
@@ -183,6 +247,10 @@ export function scanRoutes(appRoot) {
|
|
|
183
247
|
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
184
248
|
routes.sort(byPath);
|
|
185
249
|
handlers.sort(byPath);
|
|
250
|
+
// Sorted for a table that does not churn between builds, and for nothing
|
|
251
|
+
// else: `createMiddlewareRunner` re-orders the table root first, because
|
|
252
|
+
// what a chain of guards runs in is depth, not name.
|
|
253
|
+
middleware.sort(byPath);
|
|
186
254
|
// Sorted by path, not by which is nearest: the resolver picks the longest
|
|
187
255
|
// path that covers the URL, so it does not depend on this order, and sorting
|
|
188
256
|
// by nearness would hide that.
|
|
@@ -196,7 +264,7 @@ export function scanRoutes(appRoot) {
|
|
|
196
264
|
// parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
|
|
197
265
|
notFound.sort(byPath);
|
|
198
266
|
errors.sort(byPath);
|
|
199
|
-
return { routes, handlers, notFound, errors };
|
|
267
|
+
return { routes, handlers, middleware, notFound, errors };
|
|
200
268
|
}
|
|
201
269
|
|
|
202
270
|
function isDirectory(candidate) {
|
|
@@ -260,11 +328,55 @@ export const VIRTUAL = Object.freeze({
|
|
|
260
328
|
*
|
|
261
329
|
* Each page and layout is a lazy `import()`, so a route is a chunk of its own.
|
|
262
330
|
* Layouts are deduplicated into one table so a layout shared by fifty routes
|
|
263
|
-
* is one dynamic import, not fifty.
|
|
331
|
+
* is one dynamic import, not fifty. Middleware needs no deduplication: it is
|
|
332
|
+
* already one entry per file, keyed by the path it guards.
|
|
333
|
+
*
|
|
334
|
+
* # The client's copy is not the server's
|
|
335
|
+
*
|
|
336
|
+
* `shipsPage` is how the server/client split reaches the bundle. A route it
|
|
337
|
+
* answers `false` for keeps its path and its parameters — the router still has
|
|
338
|
+
* to *match* the URL, so that a link into it can hand the navigation back to
|
|
339
|
+
* the browser — and loses its `page`, its `layouts` and its `loading`
|
|
340
|
+
* boundaries, which are the only `import()` calls in this table. Nothing in
|
|
341
|
+
* the browser can then reach the module through the router, so Rollup emits no
|
|
342
|
+
* chunk for it and none for anything only it reached.
|
|
343
|
+
*
|
|
344
|
+
* Omitted by leaving the key out rather than by writing `page: null`, because
|
|
345
|
+
* the two say different things to a bundler: a property whose value is an
|
|
346
|
+
* `import()` is a chunk whether or not anything reads it.
|
|
264
347
|
*
|
|
265
|
-
*
|
|
348
|
+
* # Except for its styles
|
|
349
|
+
*
|
|
350
|
+
* A route that ships no JavaScript still has to *look* right, and a uf build
|
|
351
|
+
* takes its stylesheets from the client graph: `assetsFromManifest` walks the
|
|
352
|
+
* client entry's imports and links the CSS it finds, so a module removed from
|
|
353
|
+
* that graph takes its rules out of every page in the site. That is a silent
|
|
354
|
+
* visual break, and it is worse than shipping the module.
|
|
355
|
+
*
|
|
356
|
+
* So each module a dropped route was the only reader of comes back at the top
|
|
357
|
+
* of this file as a bare `import <file>;` — a side-effect import, with no
|
|
358
|
+
* binding read from it. Its stylesheet is a side effect and survives; its
|
|
359
|
+
* components, its helpers and everything only they referenced are unused
|
|
360
|
+
* exports and do not. A layout a *kept* route still uses is left out of that
|
|
361
|
+
* list: it is already here as a lazy import, and a static one as well would
|
|
362
|
+
* pull it into the entry chunk.
|
|
363
|
+
*
|
|
364
|
+
* The default answers `true` for every route, which is the whole table, no
|
|
365
|
+
* side-effect imports, and exactly what this emitted before the split existed.
|
|
366
|
+
* `virtual:uf/server` is generated with the default and always will be: the
|
|
367
|
+
* server renders every route, so its table is the complete one.
|
|
368
|
+
*
|
|
369
|
+
* @param {{
|
|
370
|
+
* routes: Route[],
|
|
371
|
+
* handlers?: Handler[],
|
|
372
|
+
* middleware?: Middleware[],
|
|
373
|
+
* notFound?: NotFoundBoundary[],
|
|
374
|
+
* errors?: ErrorBoundary[],
|
|
375
|
+
* }} table
|
|
376
|
+
* @param {{shipsPage?: (route: Route) => boolean}} [options]
|
|
266
377
|
*/
|
|
267
|
-
export function routesModuleSource(table) {
|
|
378
|
+
export function routesModuleSource(table, options = {}) {
|
|
379
|
+
const shipsPage = options.shipsPage ?? (() => true);
|
|
268
380
|
const layoutIds = new Map();
|
|
269
381
|
const layoutImports = [];
|
|
270
382
|
const layoutId = (file) => {
|
|
@@ -277,8 +389,44 @@ export function routesModuleSource(table) {
|
|
|
277
389
|
return id;
|
|
278
390
|
};
|
|
279
391
|
|
|
392
|
+
// Loading modules are deduplicated into a table of their own, for the reason
|
|
393
|
+
// layouts are: one `app/_uf.loading.js` is the fallback of every route under
|
|
394
|
+
// it, and fifty copies of the same `import()` would be fifty chunks of the
|
|
395
|
+
// same file.
|
|
396
|
+
//
|
|
397
|
+
// They are static imports rather than lazy ones, and that is not an
|
|
398
|
+
// oversight. React decides to show a fallback *synchronously*, during the
|
|
399
|
+
// render that suspended, so a fallback still waiting on its own `import()` is
|
|
400
|
+
// a fallback that is not there at the only moment it is wanted — the same
|
|
401
|
+
// reasoning as the error boundaries below, arrived at from the other
|
|
402
|
+
// direction. `resolveMatch` awaits them with the layouts, before it renders.
|
|
403
|
+
const loadingIds = new Map();
|
|
404
|
+
const loadingImports = [];
|
|
405
|
+
const loadingId = (file) => {
|
|
406
|
+
let id = loadingIds.get(file);
|
|
407
|
+
if (id === undefined) {
|
|
408
|
+
id = `loading${loadingIds.size}`;
|
|
409
|
+
loadingIds.set(file, id);
|
|
410
|
+
loadingImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
|
|
411
|
+
}
|
|
412
|
+
return id;
|
|
413
|
+
};
|
|
414
|
+
|
|
280
415
|
const entries = table.routes.map((route) => {
|
|
416
|
+
if (!shipsPage(route)) {
|
|
417
|
+
return ` {
|
|
418
|
+
path: ${JSON.stringify(route.path)},
|
|
419
|
+
params: ${JSON.stringify(route.params)},
|
|
420
|
+
mdx: ${route.mdx},
|
|
421
|
+
file: ${JSON.stringify(route.page)},
|
|
422
|
+
layouts: [],
|
|
423
|
+
loading: [],
|
|
424
|
+
}`;
|
|
425
|
+
}
|
|
281
426
|
const layouts = route.layouts.map(layoutId);
|
|
427
|
+
const loading = (route.loading ?? []).map(
|
|
428
|
+
(boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`,
|
|
429
|
+
);
|
|
282
430
|
return ` {
|
|
283
431
|
path: ${JSON.stringify(route.path)},
|
|
284
432
|
params: ${JSON.stringify(route.params)},
|
|
@@ -286,6 +434,7 @@ export function routesModuleSource(table) {
|
|
|
286
434
|
file: ${JSON.stringify(route.page)},
|
|
287
435
|
page: () => import(${JSON.stringify(route.page)}),
|
|
288
436
|
layouts: [${layouts.join(", ")}],
|
|
437
|
+
loading: [${loading.join(", ")}],
|
|
289
438
|
}`;
|
|
290
439
|
});
|
|
291
440
|
|
|
@@ -328,13 +477,48 @@ export function routesModuleSource(table) {
|
|
|
328
477
|
}`,
|
|
329
478
|
);
|
|
330
479
|
|
|
331
|
-
|
|
480
|
+
// Middleware is a table of its own for the same reason, and for a stronger
|
|
481
|
+
// one: it is where an application puts the check it does not want a user to
|
|
482
|
+
// read. `clientModuleSource` imports `routes`, `notFound` and `errors` and
|
|
483
|
+
// nothing else, so a middleware module is reachable from the server entry
|
|
484
|
+
// alone.
|
|
485
|
+
const middlewareEntries = (table.middleware ?? []).map(
|
|
486
|
+
(entry) => ` {
|
|
487
|
+
path: ${JSON.stringify(entry.path)},
|
|
488
|
+
file: ${JSON.stringify(entry.module)},
|
|
489
|
+
load: () => import(${JSON.stringify(entry.module)}),
|
|
490
|
+
}`,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Last, because it is defined by what everything above did *not* import: a
|
|
494
|
+
// layout a kept route also uses is already in the graph as a lazy chunk, and
|
|
495
|
+
// importing it here as well would pull it into the entry chunk instead.
|
|
496
|
+
const carried = new Set([...layoutIds.keys(), ...loadingIds.keys()]);
|
|
497
|
+
const styleOnlyImports = [];
|
|
498
|
+
for (const route of table.routes) {
|
|
499
|
+
if (shipsPage(route)) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const files = [route.page, ...route.layouts, ...(route.loading ?? []).map((it) => it.module)];
|
|
503
|
+
for (const file of files) {
|
|
504
|
+
if (carried.has(file)) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
carried.add(file);
|
|
508
|
+
styleOnlyImports.push(`import ${JSON.stringify(file)};`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return `${[...styleOnlyImports, ...layoutImports, ...loadingImports].join("\n")}
|
|
332
513
|
export const routes = [
|
|
333
514
|
${entries.join(",\n")}
|
|
334
515
|
];
|
|
335
516
|
export const handlers = [
|
|
336
517
|
${handlerEntries.join(",\n")}
|
|
337
518
|
];
|
|
519
|
+
export const middleware = [
|
|
520
|
+
${middlewareEntries.join(",\n")}
|
|
521
|
+
];
|
|
338
522
|
export const notFound = [
|
|
339
523
|
${notFoundEntries.join(",\n")}
|
|
340
524
|
];
|
|
@@ -361,14 +545,56 @@ hydrate({ App, routes, notFound, errors });
|
|
|
361
545
|
}
|
|
362
546
|
|
|
363
547
|
/**
|
|
364
|
-
* The source of `virtual:uf/server`:
|
|
548
|
+
* The source of `virtual:uf/server`: answer one request.
|
|
549
|
+
*
|
|
550
|
+
* Three exports, and the order a host calls them in is the whole of how the
|
|
551
|
+
* two halves of the table compose. `runMiddleware` first, because a middleware
|
|
552
|
+
* guards a *path* — it has to run for a page, for a route handler, and for a
|
|
553
|
+
* path under it that matches neither, so it belongs above route resolution
|
|
554
|
+
* rather than inside it. `notFound` and `errors` go the other way: they are
|
|
555
|
+
* boundaries chosen *during* a render, once resolution knows which route was
|
|
556
|
+
* asked for and whether it threw, which is why they are `createRenderer`'s
|
|
557
|
+
* arguments and not a step of their own. The two never compete for the same
|
|
558
|
+
* request — one decides whether the router is reached at all, the others
|
|
559
|
+
* decide what the router renders when it is.
|
|
560
|
+
*
|
|
561
|
+
* `internal/serve.js` and `driver.js` call them in that order, and
|
|
562
|
+
* `packages/vite/index.js` does the same for a project driving Vite itself.
|
|
563
|
+
*
|
|
564
|
+
* `render` and `prerender` are two exports rather than one with a flag, because
|
|
565
|
+
* a host is one or the other: a server streams, a build writes files. See the
|
|
566
|
+
* header of `packages/router/server.js` for why React needs both told apart.
|
|
567
|
+
*
|
|
568
|
+
* `beginRequest` is the fourth, and it is re-exported rather than imported by
|
|
569
|
+
* the host for a reason that is easy to get wrong: `@uniflowed/server` keeps
|
|
570
|
+
* the request in an `AsyncLocalStorage` held by *its module*, and a bundled
|
|
571
|
+
* application has its own copy of that module inlined. A host that imported
|
|
572
|
+
* `beginRequest` from its own `node_modules` would establish a request in a
|
|
573
|
+
* second storage, and every `cookies()` in the application would still be
|
|
574
|
+
* outside one. So the bundle hands the host the entry point that belongs to
|
|
575
|
+
* the bundle. `uf preview`, `uf start`, `uf dev` and the compiled binary all
|
|
576
|
+
* take it from here; see ubugeeei-prod/uf#389.
|
|
577
|
+
*
|
|
578
|
+
* Through `@uniflowed/router/server` rather than `@uniflowed/server/host`,
|
|
579
|
+
* because this source is resolved from the *project's* directory and a project
|
|
580
|
+
* depends on the router, not on the router's own dependency. It is also the
|
|
581
|
+
* shorter proof of the paragraph above: the copy the router dispatches and
|
|
582
|
+
* renders with is by construction the copy the host is handed.
|
|
365
583
|
*/
|
|
366
584
|
export function serverModuleSource(appEntry) {
|
|
367
|
-
return `import {
|
|
368
|
-
|
|
585
|
+
return `import {
|
|
586
|
+
createDispatcher,
|
|
587
|
+
createMiddlewareRunner,
|
|
588
|
+
createRenderer,
|
|
589
|
+
} from "@uniflowed/router/server";
|
|
590
|
+
import { routes, handlers, middleware, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
|
|
369
591
|
import App from ${JSON.stringify(appEntry)};
|
|
370
|
-
export { routes, handlers, notFound, errors };
|
|
371
|
-
export
|
|
592
|
+
export { routes, handlers, middleware, notFound, errors };
|
|
593
|
+
export { beginRequest } from "@uniflowed/router/server";
|
|
594
|
+
const renderer = createRenderer({ App, routes, notFound, errors });
|
|
595
|
+
export const render = renderer.render;
|
|
596
|
+
export const prerender = renderer.prerender;
|
|
372
597
|
export const dispatch = createDispatcher({ handlers });
|
|
598
|
+
export const runMiddleware = createMiddlewareRunner({ middleware });
|
|
373
599
|
`;
|
|
374
600
|
}
|