@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/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)) return routesModuleSource(scanRoutes(appRoot));
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 { render } = await importServerEntry(devServer);
320
- const result = await render(url, {
321
- scripts: [devUrlFor(VIRTUAL.client)],
322
- styles: [],
323
- preloads: [],
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);
@@ -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
+ }
@@ -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>} middleware absolute paths, root first
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 {{routes: Route[], handlers: Handler[], notFound: NotFoundBoundary[], errors: ErrorBoundary[]}}
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, middleware, depth) => {
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
- const nextMiddleware = ownMiddleware ? [...middleware, ownMiddleware] : middleware;
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
- middleware: nextMiddleware,
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
- nextMiddleware,
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
- * @param {{routes: Route[], handlers?: Handler[], notFound?: NotFoundBoundary[], errors?: ErrorBoundary[]}} table
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
- return `${layoutImports.join("\n")}
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`: render one URL to HTML.
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 { createDispatcher, createRenderer } from "@uniflowed/router/server";
368
- import { routes, handlers, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
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 const render = createRenderer({ App, routes, notFound, errors });
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
  }