@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 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
- ${store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : ""}import * as app from ${JSON.stringify(VIRTUAL.server)};
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
- export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)}, cache });`
975
- : `export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)} });`
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 { RSC_MANIFEST_ENV, clientRouteFilter, readRscManifest } from "./internal/rsc.js";
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
- if (!wantsDocument(request)) return next();
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 ?? "/";
@@ -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
- const walk = (directory, segments, layouts, loading, depth) => {
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
- if (segment.startsWith("(") && segment.endsWith(")")) continue;
301
- if (segment.startsWith("[...") && segment.endsWith("]")) {
302
- const name = segment.slice(4, -1);
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 (segment.startsWith("[") && segment.endsWith("]")) {
308
- const name = segment.slice(1, -1);
309
- params.push({ name, catchAll: false });
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
- /** Virtual module ids the router plugin serves. */
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: ${JSON.stringify(boundary.page)},
450
- page: () => import(${JSON.stringify(boundary.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: ${JSON.stringify(boundary.module)},
463
- module: () => import(${JSON.stringify(boundary.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 = [route.page, ...route.layouts, ...(route.loading ?? []).map((it) => it.module)];
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 { run, settle } = entry.beginRequest(request);
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.11",
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.11",
29
- "@uniflowed/server": "0.0.0-alpha.11",
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",