@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.
- package/driver.js +704 -142
- package/index.js +289 -22
- package/internal/events.js +20 -0
- package/internal/http.js +79 -0
- package/internal/routes.js +349 -33
- package/internal/rsc.js +151 -0
- package/internal/serve.js +308 -0
- package/package.json +3 -2
package/internal/routes.js
CHANGED
|
@@ -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>}
|
|
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 {
|
|
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
|
-
|
|
73
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
:
|
|
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
|
-
|
|
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
|
|
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`:
|
|
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 {
|
|
278
|
-
|
|
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
|
|
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
|
}
|
package/internal/rsc.js
ADDED
|
@@ -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
|
+
}
|