@uniflowed/vite 0.0.0-alpha.6 → 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.
@@ -23,6 +23,8 @@ export const RESERVED = Object.freeze({
23
23
  page: "_uf.page",
24
24
  middleware: "_uf.middleware",
25
25
  notFound: "_uf.not-found",
26
+ error: "_uf.error",
27
+ loading: "_uf.loading",
26
28
  route: "_uf.route",
27
29
  });
28
30
 
@@ -42,10 +44,27 @@ const MAX_DEPTH = 32;
42
44
  * @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
43
45
  * @property {string} page absolute path of the page module
44
46
  * @property {ReadonlyArray<string>} layouts absolute paths, root first
45
- * @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
46
50
  * @property {boolean} mdx whether the page is MDX content
47
51
  */
48
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
+
49
68
  /**
50
69
  * One route handler — a path that answers a request instead of rendering.
51
70
  *
@@ -56,6 +75,60 @@ const MAX_DEPTH = 32;
56
75
  * @property {string} module absolute path of the handler module
57
76
  */
58
77
 
78
+ /**
79
+ * One not-found boundary — the page a path under `path` gets when nothing
80
+ * there matched.
81
+ *
82
+ * A `_uf.not-found.js` is a segment file like `_uf.layout.js`, so a directory
83
+ * declares the 404 for everything beneath it and the resolver takes the
84
+ * nearest one above the path. `layouts` are the layouts in scope *at that
85
+ * directory*, which is what wraps the boundary when it renders.
86
+ *
87
+ * @typedef {object} NotFoundBoundary
88
+ * @property {string} path route path of the directory that declares it
89
+ * @property {string} page absolute path of the page module
90
+ * @property {ReadonlyArray<string>} layouts absolute paths, root first
91
+ * @property {boolean} mdx whether the page is MDX content
92
+ */
93
+
94
+ /**
95
+ * One error boundary — what renders in place of the subtree under `path` when
96
+ * something in it throws.
97
+ *
98
+ * The same nearest-ancestor shape as a not-found boundary, and deliberately
99
+ * not the same extensions: an error module is handed an error and a `reset`,
100
+ * which is a component's contract. `.mdx` compiles to a component that takes
101
+ * no such thing, so a `_uf.error.mdx` would be a file the router loads and can
102
+ * never hand its arguments to.
103
+ *
104
+ * @typedef {object} ErrorBoundary
105
+ * @property {string} path route path of the directory that declares it
106
+ * @property {string} module absolute path of the error module
107
+ * @property {ReadonlyArray<string>} layouts absolute paths, root first
108
+ */
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
+
59
132
  /**
60
133
  * Scan `appRoot` for routes.
61
134
  *
@@ -64,24 +137,49 @@ const MAX_DEPTH = 32;
64
137
  * library project has no router root, and that is not a mistake.
65
138
  *
66
139
  * @param {string} appRoot absolute path of the router root (`app/`)
67
- * @returns {Route[]}
140
+ * @returns {{
141
+ * routes: Route[],
142
+ * handlers: Handler[],
143
+ * middleware: Middleware[],
144
+ * notFound: NotFoundBoundary[],
145
+ * errors: ErrorBoundary[],
146
+ * }}
68
147
  */
69
148
  export function scanRoutes(appRoot) {
70
149
  const routes = [];
71
150
  const handlers = [];
72
- let notFound = null;
73
- if (!isDirectory(appRoot)) return { routes, handlers, notFound };
151
+ const middleware = [];
152
+ const notFound = [];
153
+ const errors = [];
154
+ if (!isDirectory(appRoot)) return { routes, handlers, middleware, notFound, errors };
74
155
 
75
- const walk = (directory, segments, layouts, middleware, depth) => {
156
+ const walk = (directory, segments, layouts, loading, depth) => {
76
157
  if (depth > MAX_DEPTH) return;
77
158
  const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
78
159
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
79
160
  );
80
161
 
81
162
  const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
82
- const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
83
163
  const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
84
- 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
+ }
85
183
 
86
184
  const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS);
87
185
  if (page) {
@@ -92,7 +190,7 @@ export function scanRoutes(appRoot) {
92
190
  params,
93
191
  page,
94
192
  layouts: nextLayouts,
95
- middleware: nextMiddleware,
193
+ loading: nextLoading,
96
194
  mdx: page.endsWith(".mdx"),
97
195
  });
98
196
  }
@@ -105,9 +203,29 @@ export function scanRoutes(appRoot) {
105
203
  handlers.push({ path: routePath, pattern, params, module: handler });
106
204
  }
107
205
 
108
- if (depth === 0) {
109
- const own = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
110
- if (own) notFound = { page: own, layouts: nextLayouts, mdx: own.endsWith(".mdx") };
206
+ // At every depth, not only the root. This read `if (depth === 0)`, so
207
+ // `app/guide/_uf.not-found.js` was never looked for and a reader who
208
+ // followed a stale link into the manual was answered by the site's root
209
+ // 404, outside the manual's own layout. See ubugeeei-prod/uf#263.
210
+ const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
211
+ if (ownNotFound) {
212
+ notFound.push({
213
+ path: routeFromSegments(segments).path,
214
+ page: ownNotFound,
215
+ layouts: nextLayouts,
216
+ mdx: ownNotFound.endsWith(".mdx"),
217
+ });
218
+ }
219
+
220
+ // `errors` is the boundaries a project declares, not failures that
221
+ // happened: one entry per directory holding an `_uf.error.js`.
222
+ const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS);
223
+ if (ownError) {
224
+ errors.push({
225
+ path: routeFromSegments(segments).path,
226
+ module: ownError,
227
+ layouts: nextLayouts,
228
+ });
111
229
  }
112
230
 
113
231
  for (const entry of entries) {
@@ -119,7 +237,7 @@ export function scanRoutes(appRoot) {
119
237
  path.join(directory, entry.name),
120
238
  [...segments, entry.name],
121
239
  nextLayouts,
122
- nextMiddleware,
240
+ nextLoading,
123
241
  depth + 1,
124
242
  );
125
243
  }
@@ -129,7 +247,24 @@ export function scanRoutes(appRoot) {
129
247
  const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
130
248
  routes.sort(byPath);
131
249
  handlers.sort(byPath);
132
- return { routes, handlers, notFound };
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);
254
+ // Sorted by path, not by which is nearest: the resolver picks the longest
255
+ // path that covers the URL, so it does not depend on this order, and sorting
256
+ // by nearness would hide that.
257
+ //
258
+ // Two boundaries can share a path, because a `(group)` directory is not a URL
259
+ // segment — `app/_uf.not-found.js` and `app/(marketing)/_uf.not-found.js` are
260
+ // both at `/`, and the URL cannot say which tree it is in. The sort is stable
261
+ // and `walk` records a directory's own boundary before descending, so the
262
+ // shallower file wins, which is the one that is the site's own 404 rather
263
+ // than one section's idea of it. Letting each group own a boundary needs the
264
+ // parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
265
+ notFound.sort(byPath);
266
+ errors.sort(byPath);
267
+ return { routes, handlers, middleware, notFound, errors };
133
268
  }
134
269
 
135
270
  function isDirectory(candidate) {
@@ -193,11 +328,55 @@ export const VIRTUAL = Object.freeze({
193
328
  *
194
329
  * Each page and layout is a lazy `import()`, so a route is a chunk of its own.
195
330
  * Layouts are deduplicated into one table so a layout shared by fifty routes
196
- * 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.
197
343
  *
198
- * @param {{routes: Route[], notFound: object | null}} table
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.
347
+ *
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]
199
377
  */
200
- export function routesModuleSource(table) {
378
+ export function routesModuleSource(table, options = {}) {
379
+ const shipsPage = options.shipsPage ?? (() => true);
201
380
  const layoutIds = new Map();
202
381
  const layoutImports = [];
203
382
  const layoutId = (file) => {
@@ -210,8 +389,44 @@ export function routesModuleSource(table) {
210
389
  return id;
211
390
  };
212
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
+
213
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
+ }
214
426
  const layouts = route.layouts.map(layoutId);
427
+ const loading = (route.loading ?? []).map(
428
+ (boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`,
429
+ );
215
430
  return ` {
216
431
  path: ${JSON.stringify(route.path)},
217
432
  params: ${JSON.stringify(route.params)},
@@ -219,17 +434,36 @@ export function routesModuleSource(table) {
219
434
  file: ${JSON.stringify(route.page)},
220
435
  page: () => import(${JSON.stringify(route.page)}),
221
436
  layouts: [${layouts.join(", ")}],
437
+ loading: [${loading.join(", ")}],
222
438
  }`;
223
439
  });
224
440
 
225
- const notFound = table.notFound
226
- ? `{
227
- mdx: ${table.notFound.mdx},
228
- file: ${JSON.stringify(table.notFound.page)},
229
- page: () => import(${JSON.stringify(table.notFound.page)}),
230
- layouts: [${table.notFound.layouts.map(layoutId).join(", ")}],
231
- }`
232
- : "null";
441
+ // A list, because a not-found is a segment file: every directory may declare
442
+ // one and the router takes the nearest above the path. `layoutId` is the
443
+ // same table the routes use, so a boundary that shares a layout with a page
444
+ // shares its dynamic import too.
445
+ const notFoundEntries = (table.notFound ?? []).map(
446
+ (boundary) => ` {
447
+ path: ${JSON.stringify(boundary.path)},
448
+ mdx: ${boundary.mdx},
449
+ file: ${JSON.stringify(boundary.page)},
450
+ page: () => import(${JSON.stringify(boundary.page)}),
451
+ layouts: [${boundary.layouts.map(layoutId).join(", ")}],
452
+ }`,
453
+ );
454
+
455
+ // An error boundary is loaded with the route it guards rather than when it
456
+ // is needed: React decides to render a boundary's fallback synchronously,
457
+ // during the render that threw, so a module that still has to be imported is
458
+ // a module that is not there when the only chance to use it arrives.
459
+ const errorEntries = (table.errors ?? []).map(
460
+ (boundary) => ` {
461
+ path: ${JSON.stringify(boundary.path)},
462
+ file: ${JSON.stringify(boundary.module)},
463
+ module: () => import(${JSON.stringify(boundary.module)}),
464
+ layouts: [${boundary.layouts.map(layoutId).join(", ")}],
465
+ }`,
466
+ );
233
467
 
234
468
  // Handlers are a separate table because nothing on the client wants them:
235
469
  // a route handler answers a request, so shipping its module to the browser
@@ -243,14 +477,54 @@ export function routesModuleSource(table) {
243
477
  }`,
244
478
  );
245
479
 
246
- 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")}
247
513
  export const routes = [
248
514
  ${entries.join(",\n")}
249
515
  ];
250
516
  export const handlers = [
251
517
  ${handlerEntries.join(",\n")}
252
518
  ];
253
- export const notFound = ${notFound};
519
+ export const middleware = [
520
+ ${middlewareEntries.join(",\n")}
521
+ ];
522
+ export const notFound = [
523
+ ${notFoundEntries.join(",\n")}
524
+ ];
525
+ export const errors = [
526
+ ${errorEntries.join(",\n")}
527
+ ];
254
528
  export default routes;
255
529
  `;
256
530
  }
@@ -264,21 +538,63 @@ export default routes;
264
538
  */
265
539
  export function clientModuleSource(appEntry) {
266
540
  return `import { hydrate } from "@uniflowed/router/client";
267
- import { routes, notFound } from ${JSON.stringify(VIRTUAL.routes)};
541
+ import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
268
542
  import App from ${JSON.stringify(appEntry)};
269
- hydrate({ App, routes, notFound });
543
+ hydrate({ App, routes, notFound, errors });
270
544
  `;
271
545
  }
272
546
 
273
547
  /**
274
- * 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.
275
583
  */
276
584
  export function serverModuleSource(appEntry) {
277
- return `import { createDispatcher, createRenderer } from "@uniflowed/router/server";
278
- import { routes, handlers, notFound } 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)};
279
591
  import App from ${JSON.stringify(appEntry)};
280
- export { routes, handlers, notFound };
281
- export const render = createRenderer({ App, routes, notFound });
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;
282
597
  export const dispatch = createDispatcher({ handlers });
598
+ export const runMiddleware = createMiddlewareRunner({ middleware });
283
599
  `;
284
600
  }
@@ -0,0 +1,151 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // The server/client split, as the bundler applies it.
6
+ //
7
+ // `crates/uf_rsc` decides which modules a `"use client"` boundary is reachable
8
+ // from. That answer used to reach nothing: `virtual:uf/routes` emitted
9
+ // `page: () => import(<file>)` for every route, `virtual:uf/client` imported
10
+ // that table, and so every page in the application was a chunk of the *client*
11
+ // bundle whether or not a browser had anything to do with it.
12
+ //
13
+ // This module is the first thing that reads the answer. What is done with it
14
+ // is in `routesModuleSource`, including the one thing a dropped route keeps.
15
+ //
16
+ // # Why the unit is a route and not a module
17
+ //
18
+ // Dropping a single Server Component from the client bundle is what Next.js
19
+ // does, and it works there because the browser is handed a Flight payload
20
+ // describing the tree the server rendered. uf has no such payload yet:
21
+ // `packages/router/client.js` hydrates by re-rendering the matched tree from
22
+ // the same modules the server rendered it from, so a module missing from the
23
+ // client bundle is a module React cannot hydrate. What *can* be dropped is a
24
+ // route the browser never renders at all — one where no client boundary is
25
+ // reachable from the page, its layouts, its loading fallbacks or the
26
+ // boundaries that cover it. Nothing under it is ever re-rendered in the
27
+ // browser, so nothing under it has to be shipped. See ubugeeei-prod/uf#350.
28
+ //
29
+ // # Why "unknown" means "ship it"
30
+ //
31
+ // The analysis scans `.js`. A page written as `.mdx`, a `.jsx` module, a file
32
+ // past the scanner's size limit: none of them is in the manifest, and the
33
+ // honest reading of a module the analysis never saw is that it might reach a
34
+ // boundary. Every unknown answers `true`, so the split can only ever remove a
35
+ // route uf has positively decided needs no browser — and a manifest that is
36
+ // missing, unreadable, or written by an older uf removes nothing at all.
37
+
38
+ import { readFileSync } from "node:fs";
39
+ import path from "node:path";
40
+
41
+ /** Environment variable naming the manifest, set by `uf build` and `uf dev`. */
42
+ export const RSC_MANIFEST_ENV = "UF_RSC_MANIFEST";
43
+
44
+ /**
45
+ * The manifest schema this understands.
46
+ *
47
+ * Version 1 published the client boundaries and nothing that said which
48
+ * modules sat *above* one, so it cannot answer the question this module asks.
49
+ * An older manifest is therefore refused rather than read optimistically: a
50
+ * missing `proximity` would read as `undefined`, compare unequal to
51
+ * `"reaches-boundary"`, and quietly drop every route from the client bundle.
52
+ */
53
+ const SUPPORTED_VERSION = 2;
54
+
55
+ /**
56
+ * Read the RSC manifest, or `null` when there is nothing usable to read.
57
+ *
58
+ * Never throws. The split is an optimisation over a build that is already
59
+ * correct without it, so no failure here may be a failure of the build.
60
+ *
61
+ * @param {string | undefined} file absolute path, from the environment
62
+ */
63
+ export function readRscManifest(file) {
64
+ if (file == null || file === "") return null;
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(readFileSync(file, "utf8"));
68
+ } catch {
69
+ return null;
70
+ }
71
+ if (parsed == null || typeof parsed !== "object") return null;
72
+ if (parsed.version !== SUPPORTED_VERSION || !Array.isArray(parsed.modules)) return null;
73
+ return parsed;
74
+ }
75
+
76
+ /**
77
+ * Which modules the browser has to be able to evaluate, keyed by project path.
78
+ *
79
+ * A `"use client"` module is a client bundle root by definition, and
80
+ * `proximity` never says so about it — it is the far side of the boundary
81
+ * rather than a module above one — so both halves are asked. This mirrors
82
+ * `RscModule::requires_client_bundle` in `crates/uf_rsc/src/graph.rs`.
83
+ */
84
+ function clientModules(manifest) {
85
+ const modules = new Map();
86
+ for (const module of manifest.modules) {
87
+ if (module == null || typeof module.path !== "string") continue;
88
+ modules.set(
89
+ module.path,
90
+ module.environment === "client" || module.proximity === "reaches-boundary",
91
+ );
92
+ }
93
+ return modules;
94
+ }
95
+
96
+ /**
97
+ * Whether `boundary`'s route path covers `route`'s.
98
+ *
99
+ * Deliberately "covers" and not "is nearest to". `packages/router` picks the
100
+ * nearest not-found and error boundary above a path at render time; asking the
101
+ * same question here would be a second implementation of that rule, and the
102
+ * two would disagree the first time either moved. Every boundary that could
103
+ * apply is counted instead, which can only decide that more routes need the
104
+ * browser than strictly do.
105
+ */
106
+ function covers(boundaryPath, routePath) {
107
+ return (
108
+ boundaryPath === "/" || routePath === boundaryPath || routePath.startsWith(`${boundaryPath}/`)
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Build the predicate `routesModuleSource` asks about each route.
114
+ *
115
+ * Returns `(route) => boolean`: true when the route's page module belongs in
116
+ * the client bundle. With no manifest every route answers true, which is the
117
+ * whole table and exactly what the build emitted before this existed.
118
+ *
119
+ * @param {object | null} manifest from {@link readRscManifest}
120
+ * @param {string} root absolute project root
121
+ * @param {{notFound?: Array<object>, errors?: Array<object>}} [boundaries]
122
+ * the scanned table, so a boundary that needs the browser keeps the routes
123
+ * it covers in the client bundle
124
+ */
125
+ export function clientRouteFilter(manifest, root, boundaries = {}) {
126
+ if (manifest == null) return () => true;
127
+ const modules = clientModules(manifest);
128
+
129
+ const needed = (file) => {
130
+ if (typeof file !== "string") return true;
131
+ const relative = path.relative(root, file).split(path.sep).join("/");
132
+ const answer = modules.get(relative);
133
+ return answer === undefined ? true : answer;
134
+ };
135
+
136
+ const notFound = boundaries.notFound ?? [];
137
+ const errors = boundaries.errors ?? [];
138
+
139
+ return (route) => {
140
+ if (needed(route.page)) return true;
141
+ if (route.layouts.some(needed)) return true;
142
+ if ((route.loading ?? []).some((entry) => needed(entry.module))) return true;
143
+ for (const boundary of notFound) {
144
+ if (covers(boundary.path, route.path) && needed(boundary.page)) return true;
145
+ }
146
+ for (const boundary of errors) {
147
+ if (covers(boundary.path, route.path) && needed(boundary.module)) return true;
148
+ }
149
+ return false;
150
+ };
151
+ }