@uniflowed/vite 0.0.0-alpha.11 → 0.0.0-alpha.13
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 +59 -9
- package/index.js +123 -2
- package/internal/routes.js +252 -22
- package/internal/rsc.js +256 -1
- package/internal/serve.js +22 -3
- package/package.json +3 -3
package/driver.js
CHANGED
|
@@ -246,6 +246,17 @@ async function dev() {
|
|
|
246
246
|
return true;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
// A server action next, below the guard and above the handlers. It
|
|
250
|
+
// declines every request that carries no action id, so this costs a
|
|
251
|
+
// page request one header lookup; and it answers every request that
|
|
252
|
+
// carries one, refusals included, so an action can never fall through
|
|
253
|
+
// to a route handler that happens to sit at the URL it was posted to.
|
|
254
|
+
const acted = await entry.callAction(asRequest);
|
|
255
|
+
if (acted != null) {
|
|
256
|
+
await send(response, acted);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
249
260
|
// Route handlers next, and for every method: a handler is the only
|
|
250
261
|
// thing that answers a POST, and it may also answer a GET for a path
|
|
251
262
|
// that has no page.
|
|
@@ -749,7 +760,7 @@ async function compile() {
|
|
|
749
760
|
const ADAPTERS = {
|
|
750
761
|
node: {
|
|
751
762
|
entries: (document, cache) => ({
|
|
752
|
-
handler: handlerEntrySource(document, cache),
|
|
763
|
+
handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
|
|
753
764
|
server: nodeEntrySource("./handler.js"),
|
|
754
765
|
}),
|
|
755
766
|
},
|
|
@@ -759,13 +770,13 @@ const ADAPTERS = {
|
|
|
759
770
|
// `commands::deploy`.
|
|
760
771
|
container: {
|
|
761
772
|
entries: (document, cache) => ({
|
|
762
|
-
handler: handlerEntrySource(document, cache),
|
|
773
|
+
handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
|
|
763
774
|
server: nodeEntrySource("./handler.js"),
|
|
764
775
|
}),
|
|
765
776
|
},
|
|
766
777
|
edge: {
|
|
767
778
|
entries: (document, cache) => ({
|
|
768
|
-
handler: handlerEntrySource(document, cache),
|
|
779
|
+
handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES),
|
|
769
780
|
worker: workerEntrySource("./handler.js"),
|
|
770
781
|
}),
|
|
771
782
|
// `workerd` first, so React resolves to the build that has
|
|
@@ -776,12 +787,33 @@ const ADAPTERS = {
|
|
|
776
787
|
},
|
|
777
788
|
serverless: {
|
|
778
789
|
entries: (document, cache) => ({
|
|
779
|
-
handler: handlerEntrySource(document, cache),
|
|
790
|
+
handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES),
|
|
780
791
|
lambda: lambdaEntrySource("./handler.js"),
|
|
781
792
|
}),
|
|
782
793
|
},
|
|
783
794
|
};
|
|
784
795
|
|
|
796
|
+
/**
|
|
797
|
+
* How each target says what it can do: the module, and the name to call.
|
|
798
|
+
*
|
|
799
|
+
* A pair of strings rather than a value, because this is the module that
|
|
800
|
+
* *writes* `handler.js` and never imports what it writes: the capabilities
|
|
801
|
+
* belong to the deployed application's copy of `@uniflowed/server`, not to the
|
|
802
|
+
* driver's. It is the same reason `beginRequest` is re-exported from the
|
|
803
|
+
* generated file rather than reached for here — see `handlerEntrySource`.
|
|
804
|
+
*
|
|
805
|
+
* Nothing is passed for `websocket` or `queue`, and that is not an oversight:
|
|
806
|
+
* uf defines both and implements neither, so a generated file that invented
|
|
807
|
+
* one would be inventing an upgrade for a runtime it cannot see. What the call
|
|
808
|
+
* does supply is the target's name and its two facts, which is what turns "an
|
|
809
|
+
* upgrade is not available" into "the serverless host cannot hold a socket
|
|
810
|
+
* open" and what lets `@uniflowed/server/lambda` refuse a queue that would be
|
|
811
|
+
* dropped.
|
|
812
|
+
*/
|
|
813
|
+
const NODE_CAPABILITIES = { module: "@uniflowed/server/node", name: "nodeCapabilities" };
|
|
814
|
+
const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
|
|
815
|
+
const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
|
|
816
|
+
|
|
785
817
|
/**
|
|
786
818
|
* Link the application into a directory that can be copied, for
|
|
787
819
|
* `uf build --adapter`.
|
|
@@ -952,7 +984,7 @@ async function deploy() {
|
|
|
952
984
|
* package a bundler happened to give it, and the copy that matters is the one
|
|
953
985
|
* the application resolved. See ubugeeei-prod/uf#277 and #389.
|
|
954
986
|
*/
|
|
955
|
-
function handlerEntrySource(document, cache) {
|
|
987
|
+
function handlerEntrySource(document, cache, capabilities) {
|
|
956
988
|
const route = cache?.route === true;
|
|
957
989
|
const fetchCache = cache?.fetch === true;
|
|
958
990
|
// Nothing at all when both switches are off, so a default project's
|
|
@@ -960,9 +992,19 @@ function handlerEntrySource(document, cache) {
|
|
|
960
992
|
// generated output nobody asked for is the second half of the complaint
|
|
961
993
|
// #277 makes about the first half.
|
|
962
994
|
const store = route || fetchCache;
|
|
995
|
+
const options = [
|
|
996
|
+
"app",
|
|
997
|
+
`document: ${JSON.stringify(document)}`,
|
|
998
|
+
...(store ? ["cache"] : []),
|
|
999
|
+
"capabilities",
|
|
1000
|
+
].join(", ");
|
|
1001
|
+
const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
|
|
1002
|
+
const from = JSON.stringify(capabilities.module);
|
|
1003
|
+
const capabilityImport = `import { ${capabilities.name} } from ${from};`;
|
|
963
1004
|
return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
|
|
964
1005
|
import { createFetchHandler } from "@uniflowed/server/fetch";
|
|
965
|
-
${
|
|
1006
|
+
${cacheImport}${capabilityImport}
|
|
1007
|
+
import * as app from ${JSON.stringify(VIRTUAL.server)};
|
|
966
1008
|
|
|
967
1009
|
${
|
|
968
1010
|
store
|
|
@@ -971,9 +1013,17 @@ ${
|
|
|
971
1013
|
// application. See ubugeeei-prod/uf#277.
|
|
972
1014
|
const cache = { store: createCacheStore(), route: ${String(route)}, fetch: ${String(fetchCache)} };
|
|
973
1015
|
|
|
974
|
-
|
|
975
|
-
:
|
|
976
|
-
}
|
|
1016
|
+
`
|
|
1017
|
+
: ""
|
|
1018
|
+
}// What this target can do, and it is not the same for all four: whether a
|
|
1019
|
+
// response body reaches the client as it is produced, and whether the process
|
|
1020
|
+
// is still there once it has. A route handler that streams events or takes a
|
|
1021
|
+
// socket asks through this rather than finding out in production. Nothing is
|
|
1022
|
+
// passed for the upgrade or the queue — uf defines both and implements
|
|
1023
|
+
// neither. See \`@uniflowed/server/socket\` and \`@uniflowed/server/queue\`.
|
|
1024
|
+
const capabilities = ${capabilities.name}();
|
|
1025
|
+
|
|
1026
|
+
export const fetch = createFetchHandler({ ${options} });
|
|
977
1027
|
export const beginRequest = app.beginRequest;
|
|
978
1028
|
|
|
979
1029
|
export default { fetch, beginRequest };
|
package/index.js
CHANGED
|
@@ -55,7 +55,17 @@ import {
|
|
|
55
55
|
preambleCode,
|
|
56
56
|
refreshRuntimeSource,
|
|
57
57
|
} from "./internal/refresh.js";
|
|
58
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
ACTION_HEADER,
|
|
60
|
+
RSC_MANIFEST_ENV,
|
|
61
|
+
actionReferenceSource,
|
|
62
|
+
actionsModuleSource,
|
|
63
|
+
clientRouteFilter,
|
|
64
|
+
readRscManifest,
|
|
65
|
+
rscManifestKey,
|
|
66
|
+
serverActionModules,
|
|
67
|
+
serverActionTable,
|
|
68
|
+
} from "./internal/rsc.js";
|
|
59
69
|
import {
|
|
60
70
|
RESERVED,
|
|
61
71
|
VIRTUAL,
|
|
@@ -188,6 +198,35 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
188
198
|
return routesModuleSource(table, { shipsPage: (route) => kept.has(route) });
|
|
189
199
|
};
|
|
190
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The manifest's two action tables, re-read only when the file changes.
|
|
203
|
+
*
|
|
204
|
+
* `load` runs for every module in the graph and has to ask "is this a
|
|
205
|
+
* `"use server"` module?" about each one, so parsing the manifest per call
|
|
206
|
+
* would put a JSON parse of the whole graph between Vite and every file it
|
|
207
|
+
* opens. Size and modification time are the identity — the same pair
|
|
208
|
+
* `.uf/cache/transform` keys on — and `uf dev` drops the memo outright when
|
|
209
|
+
* its watcher sees the file change, so a rewrite inside one millisecond is
|
|
210
|
+
* still seen.
|
|
211
|
+
*/
|
|
212
|
+
let actionMemo = null;
|
|
213
|
+
const forgetActions = () => {
|
|
214
|
+
actionMemo = null;
|
|
215
|
+
};
|
|
216
|
+
const actionTables = () => {
|
|
217
|
+
const file = process.env[RSC_MANIFEST_ENV];
|
|
218
|
+
const key = rscManifestKey(file);
|
|
219
|
+
if (actionMemo == null || actionMemo.key !== key) {
|
|
220
|
+
const manifest = readRscManifest(file);
|
|
221
|
+
actionMemo = {
|
|
222
|
+
key,
|
|
223
|
+
modules: serverActionModules(manifest, root),
|
|
224
|
+
table: serverActionTable(manifest, root),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return actionMemo;
|
|
228
|
+
};
|
|
229
|
+
|
|
191
230
|
return {
|
|
192
231
|
name: "uf:flow",
|
|
193
232
|
enforce: "pre",
|
|
@@ -256,7 +295,36 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
256
295
|
}
|
|
257
296
|
if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
|
|
258
297
|
if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
|
|
298
|
+
// Only `virtual:uf/server` imports this, so it is only ever asked for in
|
|
299
|
+
// the server environment — but the table it carries is every callable
|
|
300
|
+
// endpoint of the build, so it is worth saying that a browser asking for
|
|
301
|
+
// it gets nothing rather than getting the list.
|
|
302
|
+
if (id === resolved(VIRTUAL.actions)) {
|
|
303
|
+
if (!isSsr(this, loadOptions))
|
|
304
|
+
return "export const actions = [];\nexport default actions;\n";
|
|
305
|
+
return actionsModuleSource(actionTables().table);
|
|
306
|
+
}
|
|
259
307
|
if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
|
|
308
|
+
|
|
309
|
+
// A `"use server"` module, in the browser's graph only: what the client
|
|
310
|
+
// gets is one `createServerReference` per callable export, and never the
|
|
311
|
+
// file. This is where the second half of the RSC split actually happens
|
|
312
|
+
// — the route filter above decides which *pages* the browser is given,
|
|
313
|
+
// and this decides that an action module's body, its imports and
|
|
314
|
+
// everything only they reached are not the browser's business at all.
|
|
315
|
+
//
|
|
316
|
+
// Substituting the source rather than rewriting it: a transform that
|
|
317
|
+
// stripped the body would have to be right about every way a module can
|
|
318
|
+
// name something, and being wrong once means shipping a database handle.
|
|
319
|
+
// The exports the reference module declares come from the manifest, so
|
|
320
|
+
// they are exactly the exports `uf_rsc` decided are callable endpoints
|
|
321
|
+
// and an import of anything else is a build error rather than a silent
|
|
322
|
+
// `undefined`. `crates/uf_rsc/src/graph/build.rs` colours these modules
|
|
323
|
+
// server for the same reason, so the analysis and the bundle agree.
|
|
324
|
+
if (!isSsr(this, loadOptions)) {
|
|
325
|
+
const references = actionTables().modules.get(cleanId(id));
|
|
326
|
+
if (references != null) return actionReferenceSource(references);
|
|
327
|
+
}
|
|
260
328
|
return null;
|
|
261
329
|
},
|
|
262
330
|
|
|
@@ -378,8 +446,15 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
378
446
|
devServer.watcher.add(manifestPath);
|
|
379
447
|
const onManifest = (file) => {
|
|
380
448
|
if (path.resolve(file) !== manifestPath) return;
|
|
449
|
+
// The action tables are read from the same file and are memoised on
|
|
450
|
+
// its size and modification time, which is a pair two writes inside
|
|
451
|
+
// one millisecond can share. This is the answer that does not
|
|
452
|
+
// depend on a clock.
|
|
453
|
+
forgetActions();
|
|
381
454
|
const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
|
|
382
455
|
if (routes) devServer.moduleGraph.invalidateModule(routes);
|
|
456
|
+
const actions = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.actions));
|
|
457
|
+
if (actions) devServer.moduleGraph.invalidateModule(actions);
|
|
383
458
|
devServer.ws.send({ type: "full-reload", path: "*" });
|
|
384
459
|
};
|
|
385
460
|
devServer.watcher.on("add", onManifest);
|
|
@@ -391,7 +466,14 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
391
466
|
// the renderer.
|
|
392
467
|
return () => {
|
|
393
468
|
devServer.middlewares.use(async (request, response, next) => {
|
|
394
|
-
|
|
469
|
+
// Two kinds of request reach uf here, and the second one is why this
|
|
470
|
+
// is not `wantsDocument` alone: a server action is a `POST` carrying
|
|
471
|
+
// `uf-action`, which every gate below the renderer would refuse.
|
|
472
|
+
// `driver.js` claims every request and can afford to decide later;
|
|
473
|
+
// this middleware is mounted behind Vite's own and has to say up
|
|
474
|
+
// front which ones are uf's.
|
|
475
|
+
const document = wantsDocument(request);
|
|
476
|
+
if (!document && !isActionCall(request)) return next();
|
|
395
477
|
try {
|
|
396
478
|
const url = request.url ?? "/";
|
|
397
479
|
const entry = await importServerEntry(devServer);
|
|
@@ -418,6 +500,19 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
418
500
|
return;
|
|
419
501
|
}
|
|
420
502
|
|
|
503
|
+
// Then a server action, below the guard and above the handlers.
|
|
504
|
+
// It declines anything that carries no action id, so the two
|
|
505
|
+
// lines cost a document request one `headers.get`; and it never
|
|
506
|
+
// declines one that does, so an action call cannot reach a route
|
|
507
|
+
// handler that happens to share the URL it was posted to. The
|
|
508
|
+
// same two lines are in `driver.js`, in `fetch.js` for every
|
|
509
|
+
// deploy adapter, and in `standalone.js`.
|
|
510
|
+
const acted = await entry.callAction(asRequest);
|
|
511
|
+
if (acted != null) {
|
|
512
|
+
await send(response, acted);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
421
516
|
// Then the route handlers, above the renderer and for the same
|
|
422
517
|
// reason `driver.js` puts them there: a path that answers a
|
|
423
518
|
// request is not a document, whatever the client said it would
|
|
@@ -438,6 +533,18 @@ function flowPlugin({ routerRoot, appEntry, command }) {
|
|
|
438
533
|
return;
|
|
439
534
|
}
|
|
440
535
|
|
|
536
|
+
// A `POST` this middleware claimed because it named an action,
|
|
537
|
+
// that the endpoint then declined and no handler answered. It
|
|
538
|
+
// cannot happen — the endpoint answers every request carrying an
|
|
539
|
+
// id, including every refusal — and a page cannot answer a
|
|
540
|
+
// `POST` anyway, so the honest end is a 404 rather than a
|
|
541
|
+
// rendered document with a 200.
|
|
542
|
+
if (!document) {
|
|
543
|
+
response.statusCode = 404;
|
|
544
|
+
response.end();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
441
548
|
const result = await entry.render(
|
|
442
549
|
url,
|
|
443
550
|
{ scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
|
|
@@ -507,6 +614,20 @@ async function importServerEntry(devServer) {
|
|
|
507
614
|
return devServer.ssrLoadModule(VIRTUAL.server);
|
|
508
615
|
}
|
|
509
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Whether this request is a server action call.
|
|
619
|
+
*
|
|
620
|
+
* The header alone, and never the path: an action is posted to the page's own
|
|
621
|
+
* URL, so there is nothing about the URL to recognise. Deliberately *not* the
|
|
622
|
+
* whole set of checks the endpoint makes — the origin, the content type, the
|
|
623
|
+
* body — because those decide whether the call is *allowed*, and a call that
|
|
624
|
+
* is not allowed must be refused by the endpoint rather than handed on to
|
|
625
|
+
* Vite's chain as though nobody had claimed it.
|
|
626
|
+
*/
|
|
627
|
+
function isActionCall(request) {
|
|
628
|
+
return request.method === "POST" && request.headers[ACTION_HEADER] != null;
|
|
629
|
+
}
|
|
630
|
+
|
|
510
631
|
function wantsDocument(request) {
|
|
511
632
|
if (request.method !== "GET" && request.method !== "HEAD") return false;
|
|
512
633
|
const url = request.url ?? "/";
|
package/internal/routes.js
CHANGED
|
@@ -20,6 +20,7 @@ import path from "node:path";
|
|
|
20
20
|
/** The file names the router reserves inside the router root. */
|
|
21
21
|
export const RESERVED = Object.freeze({
|
|
22
22
|
layout: "_uf.layout",
|
|
23
|
+
template: "_uf.template",
|
|
23
24
|
page: "_uf.page",
|
|
24
25
|
middleware: "_uf.middleware",
|
|
25
26
|
notFound: "_uf.not-found",
|
|
@@ -28,6 +29,31 @@ export const RESERVED = Object.freeze({
|
|
|
28
29
|
route: "_uf.route",
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Directory names uf reserves inside the router root without serving them.
|
|
34
|
+
*
|
|
35
|
+
* One spelling each, and they are here so `crates/uf_router/tests/
|
|
36
|
+
* reserved_names.rs` can hold this router and `uf_router::RouteSegment` to the
|
|
37
|
+
* same list — the way it already holds the two to the same `_uf.*` roles. A
|
|
38
|
+
* spelling one router refuses and the other serves as a URL is exactly the
|
|
39
|
+
* disagreement that made this necessary.
|
|
40
|
+
*
|
|
41
|
+
* `@team` is Next.js's parallel-route slot and `(.)photo` its intercepting
|
|
42
|
+
* route. uf has neither, and until #267 both fell through to "a literal URL
|
|
43
|
+
* segment": `@team` became `/@team`, `(.)photo` became `/(.)photo` — the test
|
|
44
|
+
* for a `(group)` is that the segment *ends* in `)` — and the generated
|
|
45
|
+
* `RoutePath` union contained them, so `route("/@team", …)` type checked. A
|
|
46
|
+
* convention served as nonsense is worse than one that is refused, because the
|
|
47
|
+
* project looks like it works.
|
|
48
|
+
*/
|
|
49
|
+
export const UNSUPPORTED_SEGMENTS = Object.freeze([
|
|
50
|
+
"@team",
|
|
51
|
+
"(.)photo",
|
|
52
|
+
"(..)photo",
|
|
53
|
+
"(...)photo",
|
|
54
|
+
"(..)(..)photo",
|
|
55
|
+
]);
|
|
56
|
+
|
|
31
57
|
/** Extensions a page or layout may use; `.mdx` is a page written as content. */
|
|
32
58
|
const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
|
|
33
59
|
const MODULE_EXTENSIONS = [".js", ".jsx"];
|
|
@@ -47,6 +73,8 @@ const MAX_DEPTH = 32;
|
|
|
47
73
|
* @property {ReadonlyArray<{above: number, module: string}>} loading the
|
|
48
74
|
* `<Suspense>` boundaries in scope, root first; `above` is how many of
|
|
49
75
|
* `layouts` are outside each one
|
|
76
|
+
* @property {ReadonlyArray<{above: number, module: string}>} templates the
|
|
77
|
+
* `_uf.template.js` wrappers in scope, root first, with the same `above`
|
|
50
78
|
* @property {boolean} mdx whether the page is MDX content
|
|
51
79
|
*/
|
|
52
80
|
|
|
@@ -86,7 +114,9 @@ const MAX_DEPTH = 32;
|
|
|
86
114
|
*
|
|
87
115
|
* @typedef {object} NotFoundBoundary
|
|
88
116
|
* @property {string} path route path of the directory that declares it
|
|
89
|
-
* @property {string} page absolute path of the page module
|
|
117
|
+
* @property {?string} page absolute path of the page module, or `null` for the
|
|
118
|
+
* record the scan synthesises at the router root when a project declares
|
|
119
|
+
* none — see `scanRoutes`
|
|
90
120
|
* @property {ReadonlyArray<string>} layouts absolute paths, root first
|
|
91
121
|
* @property {boolean} mdx whether the page is MDX content
|
|
92
122
|
*/
|
|
@@ -103,7 +133,8 @@ const MAX_DEPTH = 32;
|
|
|
103
133
|
*
|
|
104
134
|
* @typedef {object} ErrorBoundary
|
|
105
135
|
* @property {string} path route path of the directory that declares it
|
|
106
|
-
* @property {string} module absolute path of the error module
|
|
136
|
+
* @property {?string} module absolute path of the error module, or `null` for
|
|
137
|
+
* the synthesised root record
|
|
107
138
|
* @property {ReadonlyArray<string>} layouts absolute paths, root first
|
|
108
139
|
*/
|
|
109
140
|
|
|
@@ -136,6 +167,10 @@ const MAX_DEPTH = 32;
|
|
|
136
167
|
* Directories that do not exist yield an empty table rather than an error: a
|
|
137
168
|
* library project has no router root, and that is not a mistake.
|
|
138
169
|
*
|
|
170
|
+
* Throws for a directory named the way a parallel route or an intercepting
|
|
171
|
+
* route is spelled: uf has neither, and both used to become literal URL
|
|
172
|
+
* segments. See {@link UNSUPPORTED_SEGMENTS}.
|
|
173
|
+
*
|
|
139
174
|
* @param {string} appRoot absolute path of the router root (`app/`)
|
|
140
175
|
* @returns {{
|
|
141
176
|
* routes: Route[],
|
|
@@ -153,7 +188,11 @@ export function scanRoutes(appRoot) {
|
|
|
153
188
|
const errors = [];
|
|
154
189
|
if (!isDirectory(appRoot)) return { routes, handlers, middleware, notFound, errors };
|
|
155
190
|
|
|
156
|
-
|
|
191
|
+
// The layouts in scope at the router root, kept because the two synthesised
|
|
192
|
+
// records below are made of them. See the note beside them.
|
|
193
|
+
let rootLayouts = [];
|
|
194
|
+
|
|
195
|
+
const walk = (directory, segments, layouts, loading, templates, depth) => {
|
|
157
196
|
if (depth > MAX_DEPTH) return;
|
|
158
197
|
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
159
198
|
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
@@ -161,6 +200,9 @@ export function scanRoutes(appRoot) {
|
|
|
161
200
|
|
|
162
201
|
const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
|
|
163
202
|
const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
|
|
203
|
+
if (depth === 0) {
|
|
204
|
+
rootLayouts = nextLayouts;
|
|
205
|
+
}
|
|
164
206
|
|
|
165
207
|
// Inside this directory's own layout, which is where Next.js puts it and
|
|
166
208
|
// the only placement that makes sense: the fallback is what shows *within*
|
|
@@ -173,6 +215,17 @@ export function scanRoutes(appRoot) {
|
|
|
173
215
|
? [...loading, { above: nextLayouts.length, module: ownLoading }]
|
|
174
216
|
: loading;
|
|
175
217
|
|
|
218
|
+
// A template accumulates the way a layout does, and is placed the way a
|
|
219
|
+
// loading file is: inside its own segment's layout and outside everything
|
|
220
|
+
// below, so `nextLayouts.length` is taken after the own layout is added.
|
|
221
|
+
// Every template above a route is on that route, one inside the next, for
|
|
222
|
+
// the reason every layout is — the difference between the two is a `key`,
|
|
223
|
+
// not a shape.
|
|
224
|
+
const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS);
|
|
225
|
+
const nextTemplates = ownTemplate
|
|
226
|
+
? [...templates, { above: nextLayouts.length, module: ownTemplate }]
|
|
227
|
+
: templates;
|
|
228
|
+
|
|
176
229
|
// A middleware guards this directory and everything below it, whether or
|
|
177
230
|
// not this directory is itself a route: `app/dashboard/_uf.middleware.js`
|
|
178
231
|
// with no `_uf.page.js` beside it still guards `/dashboard/settings`.
|
|
@@ -191,6 +244,7 @@ export function scanRoutes(appRoot) {
|
|
|
191
244
|
page,
|
|
192
245
|
layouts: nextLayouts,
|
|
193
246
|
loading: nextLoading,
|
|
247
|
+
templates: nextTemplates,
|
|
194
248
|
mdx: page.endsWith(".mdx"),
|
|
195
249
|
});
|
|
196
250
|
}
|
|
@@ -233,17 +287,49 @@ export function scanRoutes(appRoot) {
|
|
|
233
287
|
// A leading dot or underscore is private to the author: `_components/`
|
|
234
288
|
// beside a page is a place to put things, not a route.
|
|
235
289
|
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
290
|
+
// Checked before descending, and after the private-directory test for
|
|
291
|
+
// the same reason `uf_router` prunes them: `app/_drafts/@team/` is not a
|
|
292
|
+
// route uf would have served, so it is not one to refuse.
|
|
293
|
+
const refused = unsupportedSegmentReason(entry.name);
|
|
294
|
+
if (refused != null) {
|
|
295
|
+
throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
|
|
296
|
+
}
|
|
236
297
|
walk(
|
|
237
298
|
path.join(directory, entry.name),
|
|
238
299
|
[...segments, entry.name],
|
|
239
300
|
nextLayouts,
|
|
240
301
|
nextLoading,
|
|
302
|
+
nextTemplates,
|
|
241
303
|
depth + 1,
|
|
242
304
|
);
|
|
243
305
|
}
|
|
244
306
|
};
|
|
245
307
|
|
|
246
|
-
walk(appRoot, [], [], [], 0);
|
|
308
|
+
walk(appRoot, [], [], [], [], 0);
|
|
309
|
+
|
|
310
|
+
// A boundary at the router root for a project that declared none, carrying
|
|
311
|
+
// the root's layouts and no module of its own.
|
|
312
|
+
//
|
|
313
|
+
// Without it the router had no record to answer an unmatched URL with, so it
|
|
314
|
+
// answered with the framework's page and `layouts: []` — and a site whose
|
|
315
|
+
// root layout owns the masthead, the stylesheet and often `<html>` itself
|
|
316
|
+
// replied to a stale link with a white page saying 404, with no way to leave
|
|
317
|
+
// it. That was never the nearest-ancestor rule failing: the rule had nothing
|
|
318
|
+
// to find. `uf create` scaffolds neither boundary, so this is the state every
|
|
319
|
+
// new project is in until it writes one. See ubugeeei-prod/uf#351.
|
|
320
|
+
//
|
|
321
|
+
// Only when nothing is at `/` already. A `(group)` directory is not a URL
|
|
322
|
+
// segment, so `app/(marketing)/_uf.not-found.js` is a boundary at `/` too and
|
|
323
|
+
// adding a second one there would put a second answer at a path the URL
|
|
324
|
+
// cannot choose between.
|
|
325
|
+
const atRoot = (boundaries) => boundaries.some((boundary) => boundary.path === "/");
|
|
326
|
+
if (!atRoot(notFound)) {
|
|
327
|
+
notFound.push({ path: "/", page: null, layouts: rootLayouts, mdx: false });
|
|
328
|
+
}
|
|
329
|
+
if (!atRoot(errors)) {
|
|
330
|
+
errors.push({ path: "/", module: null, layouts: rootLayouts });
|
|
331
|
+
}
|
|
332
|
+
|
|
247
333
|
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
248
334
|
routes.sort(byPath);
|
|
249
335
|
handlers.sort(byPath);
|
|
@@ -287,27 +373,112 @@ function findModule(directory, stem, extensions) {
|
|
|
287
373
|
return null;
|
|
288
374
|
}
|
|
289
375
|
|
|
376
|
+
/**
|
|
377
|
+
* What one directory name means to the route path.
|
|
378
|
+
*
|
|
379
|
+
* Mirrors `uf_router::classify_route_segment`, which is the same six answers
|
|
380
|
+
* in the same order. The order is load-bearing in one place: an interception
|
|
381
|
+
* marker is a `(…)` *prefix* with a route after it, and a `(group)` is a
|
|
382
|
+
* segment that ends in `)`, so the interception test has to come first or
|
|
383
|
+
* every group would be read as one.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} segment one directory name
|
|
386
|
+
* @returns {{kind: "group"}
|
|
387
|
+
* | {kind: "param", name: string}
|
|
388
|
+
* | {kind: "catchAll", name: string}
|
|
389
|
+
* | {kind: "literal", name: string}
|
|
390
|
+
* | {kind: "slot", name: string}
|
|
391
|
+
* | {kind: "interception", marker: string, route: string}}
|
|
392
|
+
*/
|
|
393
|
+
export function classifyRouteSegment(segment) {
|
|
394
|
+
if (segment.startsWith("@")) return { kind: "slot", name: segment.slice(1) };
|
|
395
|
+
const intercepted = interceptionMarker(segment);
|
|
396
|
+
if (intercepted != null) return { kind: "interception", ...intercepted };
|
|
397
|
+
if (segment.startsWith("(") && segment.endsWith(")")) return { kind: "group" };
|
|
398
|
+
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
399
|
+
return { kind: "catchAll", name: segment.slice(4, -1) };
|
|
400
|
+
}
|
|
401
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
402
|
+
return { kind: "param", name: segment.slice(1, -1) };
|
|
403
|
+
}
|
|
404
|
+
return { kind: "literal", name: segment };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The `(.)`-style prefix of `segment` and the route after it, or `null`.
|
|
409
|
+
*
|
|
410
|
+
* One or more of `(.)`, `(..)` and `(...)` — every marker Next.js defines;
|
|
411
|
+
* `(..)(..)` is two of them rather than a fourth — followed by something for
|
|
412
|
+
* them to intercept. A marker with nothing after it names no route and is the
|
|
413
|
+
* `(group)` it has always been.
|
|
414
|
+
*/
|
|
415
|
+
function interceptionMarker(segment) {
|
|
416
|
+
let consumed = 0;
|
|
417
|
+
while (segment[consumed] === "(") {
|
|
418
|
+
const close = segment.indexOf(")", consumed);
|
|
419
|
+
if (close === -1) break;
|
|
420
|
+
const inner = segment.slice(consumed + 1, close);
|
|
421
|
+
if (inner.length === 0 || inner.length > 3 || /[^.]/.test(inner)) break;
|
|
422
|
+
consumed = close + 1;
|
|
423
|
+
}
|
|
424
|
+
if (consumed === 0 || consumed === segment.length) return null;
|
|
425
|
+
return { marker: segment.slice(0, consumed), route: segment.slice(consumed) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Why uf refuses a directory named `segment`, or `null` when it serves it.
|
|
430
|
+
*
|
|
431
|
+
* The message is this router's own rather than `uf_router`'s, because the two
|
|
432
|
+
* are reached differently: the Rust one fails `uf build` and `uf dev` through
|
|
433
|
+
* the route manifest, and this one fails a project driving Vite itself. Both
|
|
434
|
+
* say the same two things — which feature the spelling belongs to, and that it
|
|
435
|
+
* is refused rather than served as a URL.
|
|
436
|
+
*/
|
|
437
|
+
export function unsupportedSegmentReason(segment) {
|
|
438
|
+
const classified = classifyRouteSegment(segment);
|
|
439
|
+
if (classified.kind === "slot") {
|
|
440
|
+
return (
|
|
441
|
+
`\`${segment}\` is a parallel-route slot, and uf does not have parallel routes — a route ` +
|
|
442
|
+
"here renders in one place, so there is nothing for a slot to render into. It is refused " +
|
|
443
|
+
`rather than served as the URL segment \`/${segment}\`, which is what it used to become. ` +
|
|
444
|
+
"Rename the directory; a URL segment that really starts with `@` has no spelling in this " +
|
|
445
|
+
"grammar, so capture it with a `[param]`. https://github.com/ubugeeei-prod/uf/issues/267"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (classified.kind === "interception") {
|
|
449
|
+
return (
|
|
450
|
+
`\`${segment}\` is an intercepting route, and uf does not have interception — a navigation ` +
|
|
451
|
+
"carries where it is going and not where it came from, so nothing here could match " +
|
|
452
|
+
`\`${classified.route}\`. It is refused rather than served as the URL segment ` +
|
|
453
|
+
`\`/${segment}\`, which is what it used to become. Move the route to the path it belongs ` +
|
|
454
|
+
"at, or rename the directory. https://github.com/ubugeeei-prod/uf/issues/267"
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
290
460
|
/**
|
|
291
461
|
* Turn directory segments into a route path and its parameters.
|
|
292
462
|
*
|
|
293
463
|
* `(group)` segments organise files without appearing in the URL, `[name]`
|
|
294
|
-
* captures one segment, and `[...name]` captures the rest of the path.
|
|
464
|
+
* captures one segment, and `[...name]` captures the rest of the path. A slot
|
|
465
|
+
* or an interception never reaches here: {@link scanRoutes} refuses the
|
|
466
|
+
* directory before it walks into it.
|
|
295
467
|
*/
|
|
296
468
|
export function routeFromSegments(segments) {
|
|
297
469
|
const params = [];
|
|
298
470
|
const out = [];
|
|
299
471
|
for (const segment of segments) {
|
|
300
|
-
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
params.push({ name, catchAll: true });
|
|
304
|
-
out.push(`:${name}*`);
|
|
472
|
+
const classified = classifyRouteSegment(segment);
|
|
473
|
+
if (classified.kind === "group") continue;
|
|
474
|
+
if (classified.kind === "catchAll") {
|
|
475
|
+
params.push({ name: classified.name, catchAll: true });
|
|
476
|
+
out.push(`:${classified.name}*`);
|
|
305
477
|
continue;
|
|
306
478
|
}
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
out.push(`:${name}`);
|
|
479
|
+
if (classified.kind === "param") {
|
|
480
|
+
params.push({ name: classified.name, catchAll: false });
|
|
481
|
+
out.push(`:${classified.name}`);
|
|
311
482
|
continue;
|
|
312
483
|
}
|
|
313
484
|
out.push(segment);
|
|
@@ -316,11 +487,21 @@ export function routeFromSegments(segments) {
|
|
|
316
487
|
return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
|
|
317
488
|
}
|
|
318
489
|
|
|
319
|
-
/**
|
|
490
|
+
/**
|
|
491
|
+
* Virtual module ids the router plugin serves.
|
|
492
|
+
*
|
|
493
|
+
* `actions` is the one that does not come from this file's directory scan: it
|
|
494
|
+
* is generated from the RSC manifest by `internal/rsc.js`, because which
|
|
495
|
+
* `"use server"` exports are callable endpoints is an answer about the module
|
|
496
|
+
* graph and not about the filesystem. It is here because it is a virtual
|
|
497
|
+
* module id and this is where they are named, and because
|
|
498
|
+
* `serverModuleSource` below is the only thing that imports it.
|
|
499
|
+
*/
|
|
320
500
|
export const VIRTUAL = Object.freeze({
|
|
321
501
|
routes: "virtual:uf/routes",
|
|
322
502
|
client: "virtual:uf/client",
|
|
323
503
|
server: "virtual:uf/server",
|
|
504
|
+
actions: "virtual:uf/actions",
|
|
324
505
|
});
|
|
325
506
|
|
|
326
507
|
/**
|
|
@@ -412,6 +593,23 @@ export function routesModuleSource(table, options = {}) {
|
|
|
412
593
|
return id;
|
|
413
594
|
};
|
|
414
595
|
|
|
596
|
+
// Templates are deduplicated for the reason layouts are — one
|
|
597
|
+
// `app/_uf.template.js` wraps every route under it — and are lazy for the
|
|
598
|
+
// reason layouts are too: a template is part of the route's own tree rather
|
|
599
|
+
// than a fallback React has to have in hand at the moment something goes
|
|
600
|
+
// wrong, so it is awaited with the layouts before the first render.
|
|
601
|
+
const templateIds = new Map();
|
|
602
|
+
const templateImports = [];
|
|
603
|
+
const templateId = (file) => {
|
|
604
|
+
let id = templateIds.get(file);
|
|
605
|
+
if (id === undefined) {
|
|
606
|
+
id = `template${templateIds.size}`;
|
|
607
|
+
templateIds.set(file, id);
|
|
608
|
+
templateImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
|
|
609
|
+
}
|
|
610
|
+
return id;
|
|
611
|
+
};
|
|
612
|
+
|
|
415
613
|
const entries = table.routes.map((route) => {
|
|
416
614
|
if (!shipsPage(route)) {
|
|
417
615
|
return ` {
|
|
@@ -421,12 +619,16 @@ export function routesModuleSource(table, options = {}) {
|
|
|
421
619
|
file: ${JSON.stringify(route.page)},
|
|
422
620
|
layouts: [],
|
|
423
621
|
loading: [],
|
|
622
|
+
templates: [],
|
|
424
623
|
}`;
|
|
425
624
|
}
|
|
426
625
|
const layouts = route.layouts.map(layoutId);
|
|
427
626
|
const loading = (route.loading ?? []).map(
|
|
428
627
|
(boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`,
|
|
429
628
|
);
|
|
629
|
+
const templates = (route.templates ?? []).map(
|
|
630
|
+
(entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`,
|
|
631
|
+
);
|
|
430
632
|
return ` {
|
|
431
633
|
path: ${JSON.stringify(route.path)},
|
|
432
634
|
params: ${JSON.stringify(route.params)},
|
|
@@ -435,9 +637,19 @@ export function routesModuleSource(table, options = {}) {
|
|
|
435
637
|
page: () => import(${JSON.stringify(route.page)}),
|
|
436
638
|
layouts: [${layouts.join(", ")}],
|
|
437
639
|
loading: [${loading.join(", ")}],
|
|
640
|
+
templates: [${templates.join(", ")}],
|
|
438
641
|
}`;
|
|
439
642
|
});
|
|
440
643
|
|
|
644
|
+
// A boundary the scan synthesised has no module to import — the framework's
|
|
645
|
+
// own page renders in its place — so it emits `null` where a declared one
|
|
646
|
+
// emits a loader, and a name for `file` rather than a path nothing wrote.
|
|
647
|
+
// See the note in `scanRoutes` and ubugeeei-prod/uf#351.
|
|
648
|
+
const SYNTHESISED = JSON.stringify("@uniflowed/router");
|
|
649
|
+
const boundaryModule = (file) =>
|
|
650
|
+
file == null ? "null" : `() => import(${JSON.stringify(file)})`;
|
|
651
|
+
const boundaryFile = (file) => (file == null ? SYNTHESISED : JSON.stringify(file));
|
|
652
|
+
|
|
441
653
|
// A list, because a not-found is a segment file: every directory may declare
|
|
442
654
|
// one and the router takes the nearest above the path. `layoutId` is the
|
|
443
655
|
// same table the routes use, so a boundary that shares a layout with a page
|
|
@@ -446,8 +658,8 @@ export function routesModuleSource(table, options = {}) {
|
|
|
446
658
|
(boundary) => ` {
|
|
447
659
|
path: ${JSON.stringify(boundary.path)},
|
|
448
660
|
mdx: ${boundary.mdx},
|
|
449
|
-
file: ${
|
|
450
|
-
page:
|
|
661
|
+
file: ${boundaryFile(boundary.page)},
|
|
662
|
+
page: ${boundaryModule(boundary.page)},
|
|
451
663
|
layouts: [${boundary.layouts.map(layoutId).join(", ")}],
|
|
452
664
|
}`,
|
|
453
665
|
);
|
|
@@ -459,8 +671,8 @@ export function routesModuleSource(table, options = {}) {
|
|
|
459
671
|
const errorEntries = (table.errors ?? []).map(
|
|
460
672
|
(boundary) => ` {
|
|
461
673
|
path: ${JSON.stringify(boundary.path)},
|
|
462
|
-
file: ${
|
|
463
|
-
module:
|
|
674
|
+
file: ${boundaryFile(boundary.module)},
|
|
675
|
+
module: ${boundaryModule(boundary.module)},
|
|
464
676
|
layouts: [${boundary.layouts.map(layoutId).join(", ")}],
|
|
465
677
|
}`,
|
|
466
678
|
);
|
|
@@ -493,13 +705,18 @@ export function routesModuleSource(table, options = {}) {
|
|
|
493
705
|
// Last, because it is defined by what everything above did *not* import: a
|
|
494
706
|
// layout a kept route also uses is already in the graph as a lazy chunk, and
|
|
495
707
|
// importing it here as well would pull it into the entry chunk instead.
|
|
496
|
-
const carried = new Set([...layoutIds.keys(), ...loadingIds.keys()]);
|
|
708
|
+
const carried = new Set([...layoutIds.keys(), ...loadingIds.keys(), ...templateIds.keys()]);
|
|
497
709
|
const styleOnlyImports = [];
|
|
498
710
|
for (const route of table.routes) {
|
|
499
711
|
if (shipsPage(route)) {
|
|
500
712
|
continue;
|
|
501
713
|
}
|
|
502
|
-
const files = [
|
|
714
|
+
const files = [
|
|
715
|
+
route.page,
|
|
716
|
+
...route.layouts,
|
|
717
|
+
...(route.loading ?? []).map((it) => it.module),
|
|
718
|
+
...(route.templates ?? []).map((it) => it.module),
|
|
719
|
+
];
|
|
503
720
|
for (const file of files) {
|
|
504
721
|
if (carried.has(file)) {
|
|
505
722
|
continue;
|
|
@@ -509,7 +726,7 @@ export function routesModuleSource(table, options = {}) {
|
|
|
509
726
|
}
|
|
510
727
|
}
|
|
511
728
|
|
|
512
|
-
return `${[...styleOnlyImports, ...layoutImports, ...loadingImports].join("\n")}
|
|
729
|
+
return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports].join("\n")}
|
|
513
730
|
export const routes = [
|
|
514
731
|
${entries.join(",\n")}
|
|
515
732
|
];
|
|
@@ -558,6 +775,16 @@ hydrate({ App, routes, notFound, errors });
|
|
|
558
775
|
* request — one decides whether the router is reached at all, the others
|
|
559
776
|
* decide what the router renders when it is.
|
|
560
777
|
*
|
|
778
|
+
* `callAction` goes between the two, and its position is the same argument
|
|
779
|
+
* made twice. Below `runMiddleware`, because an action call is a request to a
|
|
780
|
+
* path and the guard on that path is owed the same say over it as over the
|
|
781
|
+
* page — which is why the call is a `POST` to the page's own URL rather than
|
|
782
|
+
* to a reserved one. Above `dispatch`, because a request that names an action
|
|
783
|
+
* has named it: letting it fall through to a route handler that happens to sit
|
|
784
|
+
* at the same path would answer somebody's action with somebody else's
|
|
785
|
+
* function. It declines every request that carries no action id, so a project
|
|
786
|
+
* with no actions pays one `headers.get` per request and nothing else.
|
|
787
|
+
*
|
|
561
788
|
* `internal/serve.js` and `driver.js` call them in that order, and
|
|
562
789
|
* `packages/vite/index.js` does the same for a project driving Vite itself.
|
|
563
790
|
*
|
|
@@ -583,11 +810,13 @@ hydrate({ App, routes, notFound, errors });
|
|
|
583
810
|
*/
|
|
584
811
|
export function serverModuleSource(appEntry) {
|
|
585
812
|
return `import {
|
|
813
|
+
createActionDispatcher,
|
|
586
814
|
createDispatcher,
|
|
587
815
|
createMiddlewareRunner,
|
|
588
816
|
createRenderer,
|
|
589
817
|
} from "@uniflowed/router/server";
|
|
590
818
|
import { routes, handlers, middleware, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
|
|
819
|
+
import { actions } from ${JSON.stringify(VIRTUAL.actions)};
|
|
591
820
|
import App from ${JSON.stringify(appEntry)};
|
|
592
821
|
export { routes, handlers, middleware, notFound, errors };
|
|
593
822
|
export { beginRequest } from "@uniflowed/router/server";
|
|
@@ -595,6 +824,7 @@ const renderer = createRenderer({ App, routes, notFound, errors });
|
|
|
595
824
|
export const render = renderer.render;
|
|
596
825
|
export const prerender = renderer.prerender;
|
|
597
826
|
export const dispatch = createDispatcher({ handlers });
|
|
827
|
+
export const callAction = createActionDispatcher({ actions });
|
|
598
828
|
export const runMiddleware = createMiddlewareRunner({ middleware });
|
|
599
829
|
`;
|
|
600
830
|
}
|
package/internal/rsc.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// route uf has positively decided needs no browser — and a manifest that is
|
|
36
36
|
// missing, unreadable, or written by an older uf removes nothing at all.
|
|
37
37
|
|
|
38
|
-
import { readFileSync } from "node:fs";
|
|
38
|
+
import { readFileSync, statSync } from "node:fs";
|
|
39
39
|
import path from "node:path";
|
|
40
40
|
|
|
41
41
|
/** Environment variable naming the manifest, set by `uf build` and `uf dev`. */
|
|
@@ -140,12 +140,267 @@ export function clientRouteFilter(manifest, root, boundaries = {}) {
|
|
|
140
140
|
if (needed(route.page)) return true;
|
|
141
141
|
if (route.layouts.some(needed)) return true;
|
|
142
142
|
if ((route.loading ?? []).some((entry) => needed(entry.module))) return true;
|
|
143
|
+
if ((route.templates ?? []).some((entry) => needed(entry.module))) return true;
|
|
144
|
+
// A boundary with no module of its own is the record the scan synthesises
|
|
145
|
+
// at the router root, and what renders there is the framework's own page —
|
|
146
|
+
// already in `@uniflowed/router`, reaching nothing this project wrote. It
|
|
147
|
+
// is skipped rather than left to `needed`, whose answer for a value that is
|
|
148
|
+
// not a file is "assume it is needed": that answer is right for a path the
|
|
149
|
+
// manifest has never heard of and wrong for the absence of a path, and
|
|
150
|
+
// taking it here would have kept every page of every project in the client
|
|
151
|
+
// bundle. See ubugeeei-prod/uf#351.
|
|
143
152
|
for (const boundary of notFound) {
|
|
153
|
+
if (boundary.page == null) continue;
|
|
144
154
|
if (covers(boundary.path, route.path) && needed(boundary.page)) return true;
|
|
145
155
|
}
|
|
146
156
|
for (const boundary of errors) {
|
|
157
|
+
if (boundary.module == null) continue;
|
|
147
158
|
if (covers(boundary.path, route.path) && needed(boundary.module)) return true;
|
|
148
159
|
}
|
|
149
160
|
return false;
|
|
150
161
|
};
|
|
151
162
|
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Server actions
|
|
166
|
+
//
|
|
167
|
+
// The other half of the split, and the one the manifest was already carrying
|
|
168
|
+
// an answer for. `serverActions` in the manifest is every action `uf_rsc`
|
|
169
|
+
// decided is a *callable endpoint* — an action some module that can hand it
|
|
170
|
+
// across a client boundary reaches — with the keyed id
|
|
171
|
+
// `crates/uf_rsc/src/action.rs` derived for it. An action nothing exposes is
|
|
172
|
+
// tracked in the registry and never written here, so a table built out of this
|
|
173
|
+
// file cannot contain a row that was not meant to be dialable.
|
|
174
|
+
//
|
|
175
|
+
// Two tables come out of it, for the two graphs:
|
|
176
|
+
//
|
|
177
|
+
// * `serverActionModules` is the browser's. It says, for each `"use server"`
|
|
178
|
+
// file, which exports become `createServerReference` calls — and the plugin
|
|
179
|
+
// answers that source *instead of the file*, so the module's body never
|
|
180
|
+
// enters the client graph and neither does anything only it imported.
|
|
181
|
+
// * `serverActionTable` is the server's. It is what `virtual:uf/actions`
|
|
182
|
+
// emits and what `createActionDispatcher` dials into.
|
|
183
|
+
//
|
|
184
|
+
// Both are keyed on the id and nothing else. No request-derived value ever
|
|
185
|
+
// becomes a path, a specifier or an export name here or downstream; see the
|
|
186
|
+
// header of `packages/router/internal/action-endpoint.js`.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The request header carrying an action id, lowercased as Node delivers it.
|
|
191
|
+
*
|
|
192
|
+
* A second spelling of `ACTION_HEADER` in
|
|
193
|
+
* `packages/router/internal/action-wire.js`, and it has to be one: this module
|
|
194
|
+
* is plain JavaScript the Vite host imports before any transform, and that one
|
|
195
|
+
* is Flow, which Node cannot import at all. `RSC_MANIFEST_ENV` above is the
|
|
196
|
+
* same situation with `crates/uf_rsc/src/manifest.rs`. What keeps a second
|
|
197
|
+
* spelling from becoming a second answer is
|
|
198
|
+
* `tests/library/server-actions.test.js`, which reads both and compares them.
|
|
199
|
+
*/
|
|
200
|
+
export const ACTION_HEADER = "uf-action";
|
|
201
|
+
|
|
202
|
+
/** The id an action row must carry: 64 lowercase hexadecimal characters. */
|
|
203
|
+
function isActionId(value) {
|
|
204
|
+
if (typeof value !== "string" || value.length !== 64) return false;
|
|
205
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
206
|
+
const code = value.charCodeAt(index);
|
|
207
|
+
const digit = code >= 0x30 && code <= 0x39;
|
|
208
|
+
const lower = code >= 0x61 && code <= 0x66;
|
|
209
|
+
if (!digit && !lower) return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether a name can be written as `export const <name>`.
|
|
216
|
+
*
|
|
217
|
+
* The scanner only ever produces identifiers, so this refuses nothing a real
|
|
218
|
+
* project has. It is here because the alternative to refusing is emitting a
|
|
219
|
+
* module that does not parse, and a generated file that does not parse fails a
|
|
220
|
+
* build somewhere far from the module that caused it. `default` is handled by
|
|
221
|
+
* the caller, which writes `export default`.
|
|
222
|
+
*/
|
|
223
|
+
function isExportableName(name) {
|
|
224
|
+
if (typeof name !== "string" || name.length === 0) return false;
|
|
225
|
+
const first = name.charCodeAt(0);
|
|
226
|
+
const startish = (code) =>
|
|
227
|
+
(code >= 0x41 && code <= 0x5a) ||
|
|
228
|
+
(code >= 0x61 && code <= 0x7a) ||
|
|
229
|
+
code === 0x24 ||
|
|
230
|
+
code === 0x5f;
|
|
231
|
+
if (!startish(first)) return false;
|
|
232
|
+
for (let index = 1; index < name.length; index += 1) {
|
|
233
|
+
const code = name.charCodeAt(index);
|
|
234
|
+
if (!startish(code) && !(code >= 0x30 && code <= 0x39)) return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Every callable action of the manifest, in the manifest's own order. */
|
|
240
|
+
function callableActions(manifest) {
|
|
241
|
+
if (manifest == null || !Array.isArray(manifest.serverActions)) return [];
|
|
242
|
+
return manifest.serverActions.filter(
|
|
243
|
+
(action) =>
|
|
244
|
+
action != null &&
|
|
245
|
+
isActionId(action.id) &&
|
|
246
|
+
typeof action.module === "string" &&
|
|
247
|
+
action.module !== "" &&
|
|
248
|
+
// An inline `"use server"` closure has no export name to import, so it
|
|
249
|
+
// has no reference in the client bundle and no row in the server's
|
|
250
|
+
// table. It is in the manifest, and reaching it needs the payload
|
|
251
|
+
// ubugeeei-prod/uf#252 is about.
|
|
252
|
+
action.kind === "module-export" &&
|
|
253
|
+
isExportableName(action.export === "default" ? "default_" : action.export),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The absolute path of a module the manifest names, or `null`.
|
|
259
|
+
*
|
|
260
|
+
* The manifest's paths are project-relative with forward slashes and were
|
|
261
|
+
* written by a walk that already refused anything outside the root; joined
|
|
262
|
+
* here and checked again, because a path that escapes the project is a path
|
|
263
|
+
* this plugin would otherwise hand to Rollup as a module to emit.
|
|
264
|
+
*/
|
|
265
|
+
function moduleFile(root, relative) {
|
|
266
|
+
const joined = path.resolve(root, relative);
|
|
267
|
+
const inside = path.relative(root, joined);
|
|
268
|
+
if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) return null;
|
|
269
|
+
return joined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Which exports of each `"use server"` file become references in the browser.
|
|
274
|
+
*
|
|
275
|
+
* Keyed by absolute path, because that is what Vite's `load` hook is given.
|
|
276
|
+
* A file with no callable action is absent rather than present-and-empty: the
|
|
277
|
+
* plugin substitutes a module only for a key it finds, and substituting an
|
|
278
|
+
* empty module for a file something imports would be a build error in place of
|
|
279
|
+
* a working import.
|
|
280
|
+
*
|
|
281
|
+
* @param {object | null} manifest from {@link readRscManifest}
|
|
282
|
+
* @param {string} root absolute project root
|
|
283
|
+
* @returns {Map<string, Array<{id: string, module: string, export: string}>>}
|
|
284
|
+
*/
|
|
285
|
+
export function serverActionModules(manifest, root) {
|
|
286
|
+
const modules = new Map();
|
|
287
|
+
for (const action of callableActions(manifest)) {
|
|
288
|
+
const file = moduleFile(root, action.module);
|
|
289
|
+
if (file == null) continue;
|
|
290
|
+
const rows = modules.get(file);
|
|
291
|
+
const row = { id: action.id, module: action.module, export: action.export };
|
|
292
|
+
if (rows === undefined) modules.set(file, [row]);
|
|
293
|
+
else rows.push(row);
|
|
294
|
+
}
|
|
295
|
+
return modules;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Every callable action, as the server's dispatcher table.
|
|
300
|
+
*
|
|
301
|
+
* @param {object | null} manifest from {@link readRscManifest}
|
|
302
|
+
* @param {string} root absolute project root
|
|
303
|
+
* @returns {Array<{id: string, module: string, export: string, file: string}>}
|
|
304
|
+
*/
|
|
305
|
+
export function serverActionTable(manifest, root) {
|
|
306
|
+
const rows = [];
|
|
307
|
+
for (const action of callableActions(manifest)) {
|
|
308
|
+
const file = moduleFile(root, action.module);
|
|
309
|
+
if (file == null) continue;
|
|
310
|
+
rows.push({ id: action.id, module: action.module, export: action.export, file });
|
|
311
|
+
}
|
|
312
|
+
return rows;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The client bundle's stand-in for one `"use server"` module.
|
|
317
|
+
*
|
|
318
|
+
* What the browser gets in place of the file: one `createServerReference` per
|
|
319
|
+
* callable export, an id each, and nothing the module itself imported. This is
|
|
320
|
+
* the whole of how a database handle reached only through an action stays on
|
|
321
|
+
* the server — `crates/uf_rsc/src/graph/build.rs` colours the module server for
|
|
322
|
+
* the same reason, so that the analysis and the bundle agree about it.
|
|
323
|
+
*
|
|
324
|
+
* @param {Array<{id: string, module: string, export: string}>} actions
|
|
325
|
+
*/
|
|
326
|
+
export function actionReferenceSource(actions) {
|
|
327
|
+
const lines = ['import { createServerReference } from "@uniflowed/router/action";', ""];
|
|
328
|
+
for (const action of actions) {
|
|
329
|
+
const reference = `createServerReference(${JSON.stringify(action.id)}, ${JSON.stringify(
|
|
330
|
+
`${action.module}#${action.export}`,
|
|
331
|
+
)})`;
|
|
332
|
+
lines.push(
|
|
333
|
+
action.export === "default"
|
|
334
|
+
? `export default ${reference};`
|
|
335
|
+
: `export const ${action.export} = ${reference};`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return `${lines.join("\n")}\n`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The source of `virtual:uf/actions`: the table the endpoint dials into.
|
|
343
|
+
*
|
|
344
|
+
* One `import()` thunk per file rather than one per action, so a module with
|
|
345
|
+
* four actions is one chunk of the server bundle and not four. Lazy for the
|
|
346
|
+
* reason the handler table is: an action module is loaded when an action in it
|
|
347
|
+
* is called, and a project's actions are not something every request should
|
|
348
|
+
* pay to import.
|
|
349
|
+
*
|
|
350
|
+
* With no manifest the table is empty and every action call is a `404` — the
|
|
351
|
+
* same answer a project driving Vite itself gets for the route split, and for
|
|
352
|
+
* the same reason: uf will not guess at an analysis it was not given.
|
|
353
|
+
*
|
|
354
|
+
* @param {Array<{id: string, module: string, export: string, file: string}>} actions
|
|
355
|
+
*/
|
|
356
|
+
export function actionsModuleSource(actions) {
|
|
357
|
+
const loaders = new Map();
|
|
358
|
+
const declarations = [];
|
|
359
|
+
const loaderId = (file) => {
|
|
360
|
+
let id = loaders.get(file);
|
|
361
|
+
if (id === undefined) {
|
|
362
|
+
id = `load${loaders.size}`;
|
|
363
|
+
loaders.set(file, id);
|
|
364
|
+
declarations.push(`const ${id} = () => import(${JSON.stringify(file)});`);
|
|
365
|
+
}
|
|
366
|
+
return id;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const entries = actions.map(
|
|
370
|
+
(action) => ` {
|
|
371
|
+
id: ${JSON.stringify(action.id)},
|
|
372
|
+
module: ${JSON.stringify(action.module)},
|
|
373
|
+
export: ${JSON.stringify(action.export)},
|
|
374
|
+
load: ${loaderId(action.file)},
|
|
375
|
+
}`,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
return `${declarations.join("\n")}
|
|
379
|
+
export const actions = [
|
|
380
|
+
${entries.join(",\n")}
|
|
381
|
+
];
|
|
382
|
+
export default actions;
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A cheap identity for the manifest file, so a reader can tell it has changed.
|
|
388
|
+
*
|
|
389
|
+
* The plugin's `load` hook runs for every module in the graph and cannot parse
|
|
390
|
+
* the manifest each time. Size and modification time together are what
|
|
391
|
+
* `.uf/cache/transform` already keys on for the binary that wrote it, and the
|
|
392
|
+
* same reasoning applies: a file that differs in neither is the file that was
|
|
393
|
+
* read. `uf dev` also clears the cache outright when its watcher sees the
|
|
394
|
+
* manifest change, so this is the build's answer rather than the only one.
|
|
395
|
+
*
|
|
396
|
+
* @param {string | undefined} file
|
|
397
|
+
*/
|
|
398
|
+
export function rscManifestKey(file) {
|
|
399
|
+
if (file == null || file === "") return "";
|
|
400
|
+
try {
|
|
401
|
+
const stats = statSync(file);
|
|
402
|
+
return `${String(stats.size)}:${String(stats.mtimeMs)}`;
|
|
403
|
+
} catch {
|
|
404
|
+
return "";
|
|
405
|
+
}
|
|
406
|
+
}
|
package/internal/serve.js
CHANGED
|
@@ -240,12 +240,26 @@ export function assetsFromManifest(manifest) {
|
|
|
240
240
|
* naming rather than papering over, and it is the failure path of a request
|
|
241
241
|
* that already went wrong — not the ordinary one this exists for.
|
|
242
242
|
*
|
|
243
|
-
* @param {{beginRequest: (request: Request) => {run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
|
|
243
|
+
* @param {{beginRequest: (request: Request) => {context: object, run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
|
|
244
244
|
* @param {Request} request
|
|
245
245
|
* @param {() => Promise<mixed>} body
|
|
246
246
|
*/
|
|
247
247
|
export async function withRequest(entry, request, body) {
|
|
248
|
-
const
|
|
248
|
+
const lifecycle = entry.beginRequest(request);
|
|
249
|
+
const { run, settle } = lifecycle;
|
|
250
|
+
// What this host can do, put on the request the way `createFetchHandler`
|
|
251
|
+
// puts it on the one it owns. `uf dev` and `uf build --compile` reach a
|
|
252
|
+
// route handler without going through that function, and a handler that
|
|
253
|
+
// streams events or queues work has to get the same answer from all four
|
|
254
|
+
// front doors — a capability that is present under `uf start` and absent
|
|
255
|
+
// under `uf dev` is the difference this whole seam exists to remove.
|
|
256
|
+
//
|
|
257
|
+
// `nodeCapabilities`, because both of those *are* a Node process with a
|
|
258
|
+
// socket: a body reaches the client as it is written, and the process is
|
|
259
|
+
// still there afterwards. Neither passes an upgrader or a queue, because uf
|
|
260
|
+
// defines both and implements neither.
|
|
261
|
+
const { nodeCapabilities } = await deployment();
|
|
262
|
+
lifecycle.context.capabilities ??= nodeCapabilities();
|
|
249
263
|
try {
|
|
250
264
|
return await run(body);
|
|
251
265
|
} finally {
|
|
@@ -274,11 +288,16 @@ export async function withRequest(entry, request, body) {
|
|
|
274
288
|
* @param {{entry: object, assets: object, cache?: object}} build
|
|
275
289
|
*/
|
|
276
290
|
export function createApplicationHandler({ entry, assets, cache }) {
|
|
277
|
-
const ready = deployment().then(({ createFetchHandler, createCacheStore }) =>
|
|
291
|
+
const ready = deployment().then(({ createFetchHandler, createCacheStore, nodeCapabilities }) =>
|
|
278
292
|
createFetchHandler({
|
|
279
293
|
app: entry,
|
|
280
294
|
document: assets,
|
|
281
295
|
cache: cacheFor(cache, createCacheStore),
|
|
296
|
+
// `uf preview` and `uf start` are a Node process with a socket, which is
|
|
297
|
+
// what a deployed `--adapter node` build is too — so a route handler
|
|
298
|
+
// that streams events answers the same way in the preview it is checked
|
|
299
|
+
// in and in the deployment it ends up as. See `withRequest` above.
|
|
300
|
+
capabilities: nodeCapabilities(),
|
|
282
301
|
}),
|
|
283
302
|
);
|
|
284
303
|
return async function handle(request) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/vite",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.13",
|
|
4
4
|
"description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@mdx-js/rollup": "^3.1.1",
|
|
27
27
|
"@shikijs/rehype": "^3.23.0",
|
|
28
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
29
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
28
|
+
"@uniflowed/host": "0.0.0-alpha.13",
|
|
29
|
+
"@uniflowed/server": "0.0.0-alpha.13",
|
|
30
30
|
"rehype-slug": "^6.0.0",
|
|
31
31
|
"remark-frontmatter": "^5.0.0",
|
|
32
32
|
"remark-gfm": "^4.0.1",
|