@uniflowed/vite 0.1.0 → 0.3.0

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
@@ -256,7 +256,8 @@ async function viteConfig(config, mode) {
256
256
  base: basePathOf(config) === "" ? "/" : `${basePathOf(config)}/`,
257
257
  clearScreen: false,
258
258
  customLogger: eventLogger(argument("--log-level") ?? "info"),
259
- plugins: [uniflowed({ root, config, target: routeTarget })],
259
+ // One `uf transform` for every pass this process runs; see the option.
260
+ plugins: [uniflowed({ root, config, target: routeTarget, shareTransformAcrossBuilds: true })],
260
261
  server: {
261
262
  host,
262
263
  port,
@@ -534,6 +535,8 @@ async function preview() {
534
535
  previewServer.middlewares.use((request, response, next) => {
535
536
  if ((request.url ?? "").split("?")[0].endsWith("/__uf.flight")) {
536
537
  response.setHeader("content-type", "text/x-component");
538
+ // As every other door answers a payload: never sniffed into a document.
539
+ response.setHeader("x-content-type-options", "nosniff");
537
540
  }
538
541
  if ((request.url ?? "").split("?")[0] === "/.well-known/apple-app-site-association") {
539
542
  response.setHeader("content-type", "application/json; charset=utf-8");
@@ -1511,6 +1514,28 @@ const ADAPTERS = {
1511
1514
  // partially is refused by name; see [`deploy`].
1512
1515
  streams: false,
1513
1516
  },
1517
+ // Vercel's Build Output API. `uf` writes the directory layout and the two
1518
+ // JSON files (`config.json`, `.vc-config.json`); this links the function's
1519
+ // `index.js`, a Node.js function over the same `handler.js`. Node's export
1520
+ // conditions, like `node`: it is Node that runs it.
1521
+ vercel: {
1522
+ entries: (document, cache, build, _schedules, regeneration, images, partial) => ({
1523
+ handler: handlerEntrySource(
1524
+ document,
1525
+ cache,
1526
+ VERCEL_CAPABILITIES,
1527
+ build,
1528
+ regeneration,
1529
+ images,
1530
+ partial,
1531
+ ),
1532
+ index: vercelEntrySource("./handler.js"),
1533
+ }),
1534
+ // A function instance's memory and `/tmp` go with the instance, as a
1535
+ // Lambda's do, so a build that regenerates pages names a provider module
1536
+ // in `rendering.cache.store` or is refused by name; see [`deploy`].
1537
+ regenerationStore: null,
1538
+ },
1514
1539
  };
1515
1540
 
1516
1541
  /**
@@ -1535,6 +1560,7 @@ const BUN_CAPABILITIES = { module: "@uniflowed/server/bun", name: "bunCapabiliti
1535
1560
  const DENO_CAPABILITIES = { module: "@uniflowed/server/deno", name: "denoCapabilities" };
1536
1561
  const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
1537
1562
  const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
1563
+ const VERCEL_CAPABILITIES = { module: "@uniflowed/server/vercel", name: "vercelCapabilities" };
1538
1564
 
1539
1565
  /**
1540
1566
  * Link the application into a directory that can be copied, for
@@ -2347,6 +2373,33 @@ export default {
2347
2373
  `;
2348
2374
  }
2349
2375
 
2376
+ /**
2377
+ * The source of `index.js`: the Vercel Node.js function around that handler.
2378
+ *
2379
+ * Its default export is what Vercel's Node.js launcher calls with Node's
2380
+ * request and response; `@uniflowed/server/vercel` says what it does with
2381
+ * them. The static directory is resolved from this file, for the reason
2382
+ * [`nodeEntrySource`] gives.
2383
+ */
2384
+ function vercelEntrySource(handlerSpecifier) {
2385
+ return `// Generated by \`uf build --adapter vercel\`. Not checked in, not edited.
2386
+ import path from "node:path";
2387
+ import { fileURLToPath } from "node:url";
2388
+
2389
+ import { createVercelHandler } from "@uniflowed/server/vercel";
2390
+
2391
+ // \`beginRequest\` comes from the handler beside this file rather than from
2392
+ // \`@uniflowed/server/vercel\` above, because the request has to be established
2393
+ // in the storage the *application* reads, which is the copy bundled into
2394
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
2395
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
2396
+
2397
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
2398
+
2399
+ export default createVercelHandler({ handle, beginRequest, staticDir, routing });
2400
+ `;
2401
+ }
2402
+
2350
2403
  /**
2351
2404
  * The source of `lambda.js`: the AWS Lambda entry around that handler.
2352
2405
  *
package/index.js CHANGED
@@ -46,6 +46,7 @@
46
46
 
47
47
  import { instrumentationFile } from "./internal/instrumentation.js";
48
48
 
49
+ import { readFileSync } from "node:fs";
49
50
  import path from "node:path";
50
51
  import { nativeWebPlugin } from "./internal/native-web.js";
51
52
 
@@ -61,7 +62,8 @@ import {
61
62
  auditTag,
62
63
  } from "./internal/a11y.js";
63
64
  import { assetPlugin } from "./internal/assets.js";
64
- import { barrelImportsPlugin, namespaceViewOf } from "./internal/barrel-imports.js";
65
+ import { barrelImportsPlugin } from "./internal/barrel-imports.js";
66
+ import { refuseServerErrorBoundaries } from "./internal/error-boundaries.js";
65
67
  import { emit, reportRenderError, errorEvent } from "./internal/events.js";
66
68
  import remarkFrontmatterExport from "./internal/frontmatter.js";
67
69
  import { highlightPlugin } from "./internal/highlight.js";
@@ -79,6 +81,8 @@ import {
79
81
  import {
80
82
  RSC_MANIFEST_ENV,
81
83
  actionReferenceSource,
84
+ declaresDefaultExport,
85
+ serverActionSource,
82
86
  actionsModuleSource,
83
87
  clientRouteFilter,
84
88
  readRscManifest,
@@ -96,7 +100,12 @@ import {
96
100
  scanRoutes,
97
101
  serverModuleSource,
98
102
  } from "./internal/routes.js";
99
- import { TransformService, isFlowModule, uniflowedPackages } from "@uniflowed/host/transform";
103
+ import {
104
+ TransformService,
105
+ isCompiledOutput,
106
+ isFlowModule,
107
+ uniflowedPackages,
108
+ } from "@uniflowed/host/transform";
100
109
  import {
101
110
  DEV_RSC_HOOK,
102
111
  FLIGHT_BROWSER_DEPENDENCIES,
@@ -180,6 +189,12 @@ export function devUrlFor(id) {
180
189
  * @property {object} [config] the loaded `uf.config.js` object
181
190
  * @property {"web" | "native" | "ios" | "android"} [target] app target
182
191
  * @property {string} [command] the `uf` binary to transform through
192
+ * @property {boolean} [shareTransformAcrossBuilds] keep one `uf transform` for
193
+ * every build these plugins run, rather than closing it when each ends. For a
194
+ * caller that runs several builds with the same plugins and owns the process
195
+ * they run in — `driver.js` — which is what makes an idle service left open
196
+ * at the end nobody's problem. Off by default: a process that builds many
197
+ * projects in turn would otherwise keep one idle `uf` per project.
183
198
  */
184
199
 
185
200
  /**
@@ -252,6 +267,7 @@ export default function uniflowed(options = {}) {
252
267
  flightState,
253
268
  routing,
254
269
  command: options.command,
270
+ shareTransformAcrossBuilds: options.shareTransformAcrossBuilds === true,
255
271
  accessibility,
256
272
  relayEnabled: builtins.relay !== false,
257
273
  }),
@@ -282,6 +298,7 @@ function flowPlugin({
282
298
  flightState,
283
299
  routing,
284
300
  command,
301
+ shareTransformAcrossBuilds,
285
302
  accessibility,
286
303
  relayEnabled,
287
304
  }) {
@@ -331,8 +348,11 @@ function flowPlugin({
331
348
  /** Findings held back as a dependency's, waiting to be counted out loud. */
332
349
  let suppressed = [];
333
350
 
351
+ // A service that has lost its process is replaced rather than kept: under
352
+ // `shareTransformAcrossBuilds` one outlives a build, and a `uf transform`
353
+ // that died in one pass must not fail every pass after it.
334
354
  const ensureService = () => {
335
- service ??= new TransformService({ command, root });
355
+ if (service == null || !service.alive) service = new TransformService({ command, root });
336
356
  return service;
337
357
  };
338
358
 
@@ -560,6 +580,11 @@ function flowPlugin({
560
580
  // `react-server` for nothing. The ssr graph gets exactly those two,
561
581
  // because every route it renders reaches it as a payload.
562
582
  if (flightState != null && this.environment?.name === RSC_ENVIRONMENT) {
583
+ // The graph that renders routes is where a boundary has to be a
584
+ // client reference, so this is where one that is not fails — the
585
+ // build, and `uf dev`'s table — rather than the first page that
586
+ // throws in production. See `./internal/error-boundaries.js`.
587
+ refuseServerErrorBoundaries(table, root);
563
588
  return routesModuleSource({ ...table, handlers: [], middleware: [] });
564
589
  }
565
590
  if (flightState != null && isSsr(this, loadOptions)) {
@@ -601,17 +626,11 @@ function flowPlugin({
601
626
  if (id === resolved(VIRTUAL.server)) {
602
627
  return flightState == null
603
628
  ? serverModuleSource(entryPath, routing, instrumentationFile(appRoot))
604
- : flightServerSource(
605
- entryPath,
606
- VIRTUAL.routes,
607
- VIRTUAL.actions,
608
- routing,
609
- instrumentationFile(appRoot),
610
- );
629
+ : flightServerSource(entryPath, VIRTUAL.routes, routing, instrumentationFile(appRoot));
611
630
  }
612
631
  if (flightState != null) {
613
632
  if (id === resolved(FLIGHT_VIRTUAL.entry))
614
- return rscEntrySource(VIRTUAL.routes, routing, flightState.deployment);
633
+ return rscEntrySource(VIRTUAL.routes, routing, flightState.deployment, VIRTUAL.actions);
615
634
  if (id === resolved(FLIGHT_VIRTUAL.compilerRuntime)) return compilerRuntimeSource();
616
635
  if (id === resolved(FLIGHT_VIRTUAL.bridge)) {
617
636
  if (server != null) return devBridgeSource();
@@ -632,10 +651,11 @@ function flowPlugin({
632
651
  : builtReferencesSource(flightState.chunkUrls);
633
652
  }
634
653
  }
635
- // Only `virtual:uf/server` imports this, so it is only ever asked for in
636
- // the server environment — but the table it carries is every callable
637
- // endpoint of the build, so it is worth saying that a browser asking for
638
- // it gets nothing rather than getting the list.
654
+ // Only the server entries import this — `virtual:uf/rsc` under React
655
+ // Server Components, `virtual:uf/server` otherwise — so it is only ever
656
+ // asked for in a server environment. But the table it carries is every
657
+ // callable endpoint of the build, so it is worth saying that a browser
658
+ // asking for it gets nothing rather than getting the list.
639
659
  if (id === resolved(VIRTUAL.actions)) {
640
660
  if (!isSsr(this, loadOptions))
641
661
  return "export const actions = [];\nexport default actions;\n";
@@ -664,14 +684,33 @@ function flowPlugin({
664
684
  if (!isSsr(this, loadOptions) && this.environment?.name !== RSC_ENVIRONMENT) {
665
685
  const references = actionTables().modules.get(cleanId(id));
666
686
  if (references != null) return actionReferenceSource(references);
687
+ return null;
688
+ }
689
+ // And in the server graphs, the file itself behind a module that gives
690
+ // each callable export what React reads to write a form that posts before
691
+ // hydration. The file is imported under a query, so it is still one
692
+ // module; only the plain id is the wrapper. See `serverActionSource`.
693
+ if (!id.includes("?")) {
694
+ const exported = actionTables().modules.get(id);
695
+ if (exported != null) {
696
+ let source = "";
697
+ try {
698
+ source = readFileSync(id, "utf8");
699
+ } catch {
700
+ return null;
701
+ }
702
+ return serverActionSource(id, exported, declaresDefaultExport(source));
703
+ }
667
704
  }
668
705
  return null;
669
706
  },
670
707
 
671
708
  async transform(code, id, transformOptions) {
672
- // A view of a barrel's namespace has the barrel's path and none of its
673
- // source: `uf:barrel-imports` generates it as JavaScript.
674
- if (!isFlowModule(id) || namespaceViewOf(id) != null) return null;
709
+ if (!isFlowModule(id)) return null;
710
+ // What an earlier pass of this build wrote under `.uf/build/` — the ssr
711
+ // pass imports the rsc graph's bundle — is already this transform's
712
+ // output. See `isCompiledOutput`.
713
+ if (isCompiledOutput(id)) return null;
675
714
  // Both server graphs: neither gets a refresh wrapper, and the rsc graph's
676
715
  // findings are reported as that graph's.
677
716
  const rsc = this.environment?.name === RSC_ENVIRONMENT;
@@ -744,9 +783,15 @@ function flowPlugin({
744
783
  buildEnd() {
745
784
  summariseSuppressed(this, suppressed);
746
785
  suppressed = [];
747
- // A dev server keeps its service for the whole session; a build is
748
- // done with it here.
749
- if (server == null) {
786
+ // A dev server keeps its service for the whole session. A build is done
787
+ // with it here, unless the caller runs more builds with these plugins:
788
+ // `uf build`'s passes — the rsc graph, the client, the server — go over
789
+ // largely the same modules with the same options, and `uf transform`
790
+ // answers a request it has already answered from memory. A service per
791
+ // pass compiled `@uniflowed/router` and every shared module three times.
792
+ // An idle service does not hold the host open (see `TransformService`),
793
+ // and its child sees stdin close when the host exits.
794
+ if (server == null && !shareTransformAcrossBuilds) {
750
795
  service?.close();
751
796
  service = null;
752
797
  }
@@ -904,12 +949,20 @@ function flowPlugin({
904
949
  // depend on a clock.
905
950
  forgetActions();
906
951
  const nextActions = actionTables().modules;
907
- invalidateActionModules(devServer.moduleGraph, previousActions);
908
- invalidateActionModules(devServer.moduleGraph, nextActions);
909
- const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
910
- if (routes) devServer.moduleGraph.invalidateModule(routes);
911
- const actions = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.actions));
912
- if (actions) devServer.moduleGraph.invalidateModule(actions);
952
+ // The action table and its modules live in the rsc graph too, where
953
+ // the endpoint is built (#1469), so that graph forgets them as well.
954
+ const graphs = [
955
+ devServer.moduleGraph,
956
+ devServer.environments?.[RSC_ENVIRONMENT]?.moduleGraph,
957
+ ].filter((graph) => graph != null);
958
+ for (const graph of graphs) {
959
+ invalidateActionModules(graph, previousActions);
960
+ invalidateActionModules(graph, nextActions);
961
+ const routes = graph.getModuleById(resolved(VIRTUAL.routes));
962
+ if (routes) graph.invalidateModule(routes);
963
+ const actions = graph.getModuleById(resolved(VIRTUAL.actions));
964
+ if (actions) graph.invalidateModule(actions);
965
+ }
913
966
  devServer.ws.send({ type: "full-reload", path: "*" });
914
967
  };
915
968
  devServer.watcher.on("add", onManifest);
@@ -1029,7 +1082,34 @@ function flowPlugin({
1029
1082
  // to sit at the URL it was posted to. The same two lines are in
1030
1083
  // `@uniflowed/server`'s `fetch.js`, which is what every
1031
1084
  // deployment runs.
1032
- const acted = await entry.callAction(asRequest);
1085
+ //
1086
+ // A form posted before its page hydrated is answered with the
1087
+ // page, rendered with the action's result as the submitting
1088
+ // `useActionState`'s state — the render below, given `formState`.
1089
+ const acted = await entry.callAction(asRequest, {
1090
+ postback: async (formState) => {
1091
+ const result = await entry.render(
1092
+ url,
1093
+ { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
1094
+ {
1095
+ onError: (error) => reportRenderError(devServer, url, error),
1096
+ transformHead: (head) =>
1097
+ devServer.transformIndexHtml(
1098
+ url,
1099
+ flightState == null
1100
+ ? head
1101
+ : linkStylesheets(head, devStylesheets(devServer)),
1102
+ ),
1103
+ formState,
1104
+ },
1105
+ );
1106
+ if (result.error != null) reportRenderError(devServer, url, result.error);
1107
+ return new Response(result.stream(), {
1108
+ status: result.status ?? 200,
1109
+ headers: { ...result.headers, "content-type": "text/html; charset=utf-8" },
1110
+ });
1111
+ },
1112
+ });
1033
1113
  if (acted != null) {
1034
1114
  await send(response, acted);
1035
1115
  return true;
@@ -1049,10 +1129,16 @@ function flowPlugin({
1049
1129
  if (payloadFor != null && (request.method === "GET" || request.method === "HEAD")) {
1050
1130
  const query = url.includes("?") ? url.slice(url.indexOf("?")) : "";
1051
1131
  const target = payloadFor + query;
1052
- const fromHeader = request.headers[INTERCEPTED_FROM_HEADER];
1132
+ // Read off the request the guard handed back, never off the
1133
+ // one Node received: the middleware runner takes this header
1134
+ // off when the page it names is one the guards of that page
1135
+ // would not serve, and reading the original would render it
1136
+ // anyway. See "A payload that renders over another page" in
1137
+ // `@uniflowed/router/middleware`.
1138
+ const interceptedFrom = asRequest.headers.get(INTERCEPTED_FROM_HEADER) ?? undefined;
1053
1139
  const answered = await entry.flight(target, {
1054
1140
  onError: (error) => reportRenderError(devServer, target, error),
1055
- interceptedFrom: Array.isArray(fromHeader) ? fromHeader[0] : fromHeader,
1141
+ interceptedFrom,
1056
1142
  });
1057
1143
  if (answered.error != null) reportRenderError(devServer, target, answered.error);
1058
1144
  if (request.method === "HEAD") await answered.stream?.cancel();
@@ -30,9 +30,9 @@
30
30
  // # Read off the barrel the project resolves
31
31
  //
32
32
  // Which file defines a name is read from the barrel itself: its static
33
- // `import { A } from "./a.js"` and `export { A } from "./a.js"` statements, and
34
- // the object literals it builds its namespaces from. Not a table kept here,
35
- // because the barrel a project installed is the one whose names count.
33
+ // `import { A } from "./a.js"`, `export { A } from "./a.js"` and
34
+ // `export * as A from "./a.js"` statements. Not a table kept here, because the
35
+ // barrel a project installed is the one whose names count.
36
36
  //
37
37
  // A name that reading cannot place stays an import from the barrel, and so does
38
38
  // every form that binds no name — `import * as ui`, `export * from` and
@@ -51,12 +51,20 @@
51
51
  //
52
52
  // # Namespaces
53
53
  //
54
- // `Dialog` is not an export of `dialog.js`. It is an object the barrel builds
55
- // from `dialog.js`'s parts, and `ContextMenu`'s is built from two modules. An
56
- // import of one is served from a view of the barrel, `index.js?uf-namespace=Dialog`:
57
- // a module generated here that imports those parts and builds the same object.
58
- // It is the barrel's own path with a query, so its relative imports resolve the
59
- // way the barrel's do.
54
+ // `Dialog` is `dialog.js` itself: the barrel says `export * as Dialog from
55
+ // "./dialog.js"` (ubugeeei-prod/uf#1453), so `import { Dialog } from
56
+ // "@uniflowed/ui"` becomes `import * as Dialog from ".../dialog.js"` and
57
+ // `export { Dialog as Modal } from "@uniflowed/ui"` becomes
58
+ // `export * as Modal from ".../dialog.js"`. A module namespace is the same object
59
+ // however many importers ask for it, so nothing is generated to stand in for
60
+ // it. `ContextMenu` spans two modules and is still one: `context-menu.js`
61
+ // re-exports `Menu`'s parts, and importing it reaches `menu.js` through that.
62
+ //
63
+ // Until #1453 the barrel built each namespace as an object literal, and a
64
+ // generated `index.js?uf-namespace=Dialog` module rebuilt the object for an
65
+ // importer. That form is no longer read: `@uniflowed/vite` and `@uniflowed/ui`
66
+ // are released together, and an object literal the reading meets now is
67
+ // `OPAQUE` — left on the barrel, which costs size and never correctness.
60
68
 
61
69
  import { readFileSync, statSync } from "node:fs";
62
70
  import path from "node:path";
@@ -66,9 +74,6 @@ import { normalizePath, parseAst } from "vite";
66
74
  /** The packages whose barrel imports are rewritten. */
67
75
  export const BARREL_PACKAGES = Object.freeze(["@uniflowed/ui"]);
68
76
 
69
- /** The query that makes a barrel's path a view of one of its namespaces. */
70
- export const NAMESPACE_QUERY = "uf-namespace";
71
-
72
77
  /** The module kinds whose imports are read, once earlier plugins made them JavaScript. */
73
78
  const SCRIPT = /\.(?:[cm]?[jt]sx?|mdx)$/;
74
79
 
@@ -93,7 +98,7 @@ export function barrelImportsPlugin() {
93
98
  name: "uf:barrel-imports",
94
99
 
95
100
  async transform(code, id) {
96
- if (id.startsWith("\0") || namespaceViewOf(id) != null) return null;
101
+ if (id.startsWith("\0")) return null;
97
102
  if (!BARREL_PACKAGES.some((name) => code.includes(name))) return null;
98
103
  if (!SCRIPT.test(cleanId(id))) return null;
99
104
  let program;
@@ -134,29 +139,9 @@ export function barrelImportsPlugin() {
134
139
  if (edits.length === 0) return null;
135
140
  return { code: applyEdits(code, edits), map: null };
136
141
  },
137
-
138
- load(id) {
139
- const view = namespaceViewOf(id);
140
- if (view == null) return null;
141
- return namespaceViewSource(readBarrel(readings, view.file).exports, view.file, view.name);
142
- },
143
142
  };
144
143
  }
145
144
 
146
- /**
147
- * The barrel and the namespace a view module stands for, or `null` for any
148
- * other id.
149
- *
150
- * `uf:flow` asks too: a view has the barrel's path and extension, and is not
151
- * the barrel's Flow source.
152
- */
153
- export function namespaceViewOf(id) {
154
- const at = id.indexOf("?");
155
- if (at === -1 || id.startsWith("\0")) return null;
156
- const name = new URLSearchParams(id.slice(at + 1)).get(NAMESPACE_QUERY);
157
- return name == null || name === "" ? null : { file: id.slice(0, at), name };
158
- }
159
-
160
145
  /**
161
146
  * Where each name a barrel exports is defined, read from its source.
162
147
  *
@@ -169,8 +154,8 @@ export function namespaceViewOf(id) {
169
154
  *
170
155
  * * `{ kind: "binding", file, name }` — an export of another module, passed
171
156
  * through under this name;
172
- * * `{ kind: "namespace", parts }` — an object literal whose every property
173
- * is such a binding, each part `{ key, file, name }`;
157
+ * * `{ kind: "module", file }` — another module whole, re-exported as a
158
+ * namespace with `export * as Name from "./file.js"`;
174
159
  * * `OPAQUE` — anything else, which stays an import from the barrel.
175
160
  *
176
161
  * @param {string} source
@@ -185,10 +170,9 @@ export function barrelExports(source, file) {
185
170
  ? normalizePath(path.join(directory, node.value))
186
171
  : null;
187
172
 
188
- // Every binding an import made, and every `const` object, before any export
189
- // is read: `export { Dialog }` may come before the `const` it names.
173
+ // Every binding an import made, before any export is read: `export { A }`
174
+ // may come before the `import` it names.
190
175
  const bindings = new Map();
191
- const objects = new Map();
192
176
  for (const node of program.body) {
193
177
  if (node.type === "ImportDeclaration" && node.importKind !== "type") {
194
178
  const from = fileOf(node.source);
@@ -206,21 +190,11 @@ export function barrelExports(source, file) {
206
190
  );
207
191
  }
208
192
  }
209
- const declaration = node.type === "ExportNamedDeclaration" ? node.declaration : node;
210
- if (declaration?.type === "VariableDeclaration" && declaration.kind === "const") {
211
- for (const declarator of declaration.declarations) {
212
- if (declarator.id.type === "Identifier" && declarator.init?.type === "ObjectExpression") {
213
- objects.set(declarator.id.name, declarator.init);
214
- }
215
- }
216
- }
217
193
  }
218
194
 
219
195
  const local = (name) => {
220
196
  const binding = bindings.get(name);
221
- if (binding != null) return { kind: "binding", ...binding };
222
- const object = objects.get(name);
223
- return object == null ? OPAQUE : namespaceOf(object, bindings);
197
+ return binding == null ? OPAQUE : { kind: "binding", ...binding };
224
198
  };
225
199
 
226
200
  const exports = new Map();
@@ -228,6 +202,14 @@ export function barrelExports(source, file) {
228
202
  if (node.type === "ExportDefaultDeclaration") {
229
203
  exports.set("default", OPAQUE);
230
204
  }
205
+ // `export * as Dialog from "./dialog.js"`: the namespace is the module. A
206
+ // bare `export * from` names nothing, so it places nothing.
207
+ if (node.type === "ExportAllDeclaration" && node.exported != null) {
208
+ if (node.exportKind === "type") continue;
209
+ const from = fileOf(node.source);
210
+ exports.set(nameOf(node.exported), from == null ? OPAQUE : { kind: "module", file: from });
211
+ continue;
212
+ }
231
213
  if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue;
232
214
  const { declaration } = node;
233
215
  if (declaration != null) {
@@ -259,53 +241,6 @@ export function barrelExports(source, file) {
259
241
  return exports;
260
242
  }
261
243
 
262
- /**
263
- * The module a view of a barrel's namespace is: the parts, imported from their
264
- * files, and the object the barrel builds from them.
265
- *
266
- * A name that is not a namespace the barrel builds — the barrel changed under a
267
- * development server after an importer was rewritten — is re-exported from the
268
- * barrel itself, which answers correctly, if slowly, or with the bundler's own
269
- * error for a name that is gone.
270
- *
271
- * @param {Map<string, object>} exports what `barrelExports` read
272
- * @param {string} barrel the barrel's absolute path
273
- * @param {string} name the namespace
274
- */
275
- export function namespaceViewSource(exports, barrel, name) {
276
- const target = exports.get(name);
277
- const directory = path.dirname(barrel);
278
- const specifierOf = (file) => {
279
- const relative = normalizePath(path.relative(directory, file));
280
- return JSON.stringify(relative.startsWith("../") ? relative : `./${relative}`);
281
- };
282
- if (target?.kind !== "namespace") {
283
- return `export { ${printName(name)} } from ${specifierOf(barrel)};\n`;
284
- }
285
- const locals = new Map();
286
- const taken = new Set();
287
- const imports = new Map();
288
- for (const part of target.parts) {
289
- const key = `${part.file}\0${part.name}`;
290
- if (locals.has(key)) continue;
291
- let alias = IDENTIFIER.test(part.name) ? part.name : "part";
292
- while (taken.has(alias)) alias = `${alias}$`;
293
- taken.add(alias);
294
- locals.set(key, alias);
295
- const specifiers = imports.get(part.file) ?? [];
296
- specifiers.push(alias === part.name ? alias : `${printName(part.name)} as ${alias}`);
297
- imports.set(part.file, specifiers);
298
- }
299
- const lines = [...imports].map(
300
- ([file, specifiers]) => `import { ${specifiers.join(", ")} } from ${specifierOf(file)};`,
301
- );
302
- const properties = target.parts.map(
303
- (part) => ` ${printName(part.key)}: ${locals.get(`${part.file}\0${part.name}`)},`,
304
- );
305
- lines.push(`export const ${name} = {`, ...properties, "};");
306
- return `${lines.join("\n")}\n`;
307
- }
308
-
309
244
  /** An import or re-export whose source is one of `BARREL_PACKAGES`. */
310
245
  function importsFromBarrel(node) {
311
246
  if (node.type === "ImportDeclaration") {
@@ -339,6 +274,8 @@ function rewriteStatement(node, exports, barrel) {
339
274
  const kept = [];
340
275
  let keptDefault = null;
341
276
  const moved = new Map();
277
+ // One statement each: `import * as Dialog` binds a single name.
278
+ const namespaces = [];
342
279
  for (const specifier of specifiers) {
343
280
  if (specifier.type === "ImportDefaultSpecifier") {
344
281
  keptDefault = specifier.local.name;
@@ -353,15 +290,21 @@ function rewriteStatement(node, exports, barrel) {
353
290
  kept.push(printed(exported));
354
291
  continue;
355
292
  }
356
- const [source, name] =
357
- target.kind === "binding"
358
- ? [target.file, target.name]
359
- : [`${normalizePath(barrel)}?${NAMESPACE_QUERY}=${encodeURIComponent(exported)}`, exported];
360
- const list = moved.get(source) ?? [];
361
- list.push(printed(name));
362
- moved.set(source, list);
293
+ if (target.kind === "module") {
294
+ // A namespace binding has to be an identifier, which an import's local
295
+ // name always is and an export's exported name may not be.
296
+ if (!IDENTIFIER.test(binding)) {
297
+ kept.push(printed(exported));
298
+ continue;
299
+ }
300
+ namespaces.push({ file: target.file, binding });
301
+ continue;
302
+ }
303
+ const list = moved.get(target.file) ?? [];
304
+ list.push(printed(target.name));
305
+ moved.set(target.file, list);
363
306
  }
364
- if (moved.size === 0) return null;
307
+ if (moved.size === 0 && namespaces.length === 0) return null;
365
308
 
366
309
  const keyword = isImport ? "import" : "export";
367
310
  const statements = [];
@@ -374,6 +317,9 @@ function rewriteStatement(node, exports, barrel) {
374
317
  for (const [source, list] of moved) {
375
318
  statements.push(`${keyword} { ${list.join(", ")} } from ${JSON.stringify(source)};`);
376
319
  }
320
+ for (const { file, binding } of namespaces) {
321
+ statements.push(`${keyword} * as ${binding} from ${JSON.stringify(file)};`);
322
+ }
377
323
  return statements.join("\n");
378
324
  }
379
325
 
@@ -402,27 +348,6 @@ function applyEdits(code, edits) {
402
348
  return out + code.slice(at);
403
349
  }
404
350
 
405
- /** An object literal of imported bindings, as a namespace, or `OPAQUE`. */
406
- function namespaceOf(object, bindings) {
407
- const parts = [];
408
- for (const property of object.properties) {
409
- if (property.type !== "Property" || property.kind !== "init" || property.computed) {
410
- return OPAQUE;
411
- }
412
- if (property.method || property.value.type !== "Identifier") return OPAQUE;
413
- const key =
414
- property.key.type === "Identifier"
415
- ? property.key.name
416
- : typeof property.key.value === "string"
417
- ? property.key.value
418
- : null;
419
- const binding = bindings.get(property.value.name);
420
- if (key == null || binding == null) return OPAQUE;
421
- parts.push({ key, ...binding });
422
- }
423
- return { kind: "namespace", parts };
424
- }
425
-
426
351
  /** A barrel's reading, re-read only when its size or modification time changes. */
427
352
  function readBarrel(readings, file) {
428
353
  let stats;
@@ -0,0 +1,157 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // Every `$error.js` has to be a client module, said while the route table is
6
+ // generated rather than the first time a page throws.
7
+ //
8
+ // Under React Server Components an error boundary catches a throw while the
9
+ // *browser* renders, so its component runs in the browser and the module has
10
+ // to open with `"use client"`. `@uniflowed/router`'s `rsc.js` has always
11
+ // refused a boundary that is not a client reference — but only when it is
12
+ // needed, which is when a page throws. A project whose `$error.js` lacked the
13
+ // directive built, deployed, and answered the first exception in production
14
+ // with a bare `500` and the reason in the server log. The routing guide's own
15
+ // example had no directive, so copying the documentation produced exactly
16
+ // that.
17
+ //
18
+ // This is the same rule, asked of the files: the RSC graph loads the route
19
+ // table, the table names every boundary module, and a boundary whose source
20
+ // does not start with the directive fails the build — or the dev server's
21
+ // route table, which is the same moment for `uf dev` — naming every such file
22
+ // at once.
23
+ //
24
+ // What it is not: a parser. The directive prologue is the only part of a
25
+ // module this reads (comments, then string-literal statements), which is all
26
+ // the directive rule concerns; `crates/uf_rsc`'s scan decides the same thing
27
+ // from a real parse for the client/server split, and a module that satisfies
28
+ // this check but not that one is refused by the router at request time, as
29
+ // before.
30
+
31
+ import { readFileSync } from "node:fs";
32
+ import path from "node:path";
33
+
34
+ /**
35
+ * Whether `source` opens with a `"use client"` directive.
36
+ *
37
+ * Reads the directive prologue: leading whitespace, `//` and `/* *\/`
38
+ * comments (a `// @flow` header among them), then string-literal statements.
39
+ * `"use client"` anywhere in the prologue counts, as it does for React's own
40
+ * rule; the first thing that is not a comment or a string literal ends it.
41
+ *
42
+ * @param {string} source
43
+ * @returns {boolean}
44
+ */
45
+ export function hasUseClientDirective(source) {
46
+ let at = source.charCodeAt(0) === 0xfeff ? 1 : 0;
47
+ const length = source.length;
48
+ for (;;) {
49
+ // Whitespace and comments between statements.
50
+ for (;;) {
51
+ while (at < length && /\s/.test(source[at])) at++;
52
+ if (source.startsWith("//", at)) {
53
+ const end = source.indexOf("\n", at);
54
+ at = end === -1 ? length : end + 1;
55
+ } else if (source.startsWith("/*", at)) {
56
+ const end = source.indexOf("*/", at + 2);
57
+ if (end === -1) return false;
58
+ at = end + 2;
59
+ } else {
60
+ break;
61
+ }
62
+ }
63
+ const quote = source[at];
64
+ if (quote !== '"' && quote !== "'") return false;
65
+ const close = source.indexOf(quote, at + 1);
66
+ if (close === -1) return false;
67
+ const value = source.slice(at + 1, close);
68
+ if (value.includes("\n")) return false;
69
+ at = close + 1;
70
+ // A directive is a whole statement: `;`, a line end, a comment, or the
71
+ // end. Anything else — `"use client".length` — is an expression.
72
+ while (at < length && (source[at] === " " || source[at] === "\t")) at++;
73
+ if (source[at] === ";") {
74
+ at++;
75
+ } else if (
76
+ at < length &&
77
+ source[at] !== "\n" &&
78
+ source[at] !== "\r" &&
79
+ !source.startsWith("//", at) &&
80
+ !source.startsWith("/*", at)
81
+ ) {
82
+ return false;
83
+ }
84
+ if (value === "use client") return true;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Every error-boundary module a scanned route table names, once each.
90
+ *
91
+ * `table.errors` is one entry per directory holding an `$error.js`; a slot's
92
+ * boundaries are only on the routes that render the slot, as an
93
+ * `errorBoundary`. Both are collected by walking the table rather than by
94
+ * knowing where each kind lives, so a boundary a later change puts somewhere
95
+ * new is still asked about.
96
+ *
97
+ * @param {object} table what `scanRoutes` answered
98
+ * @returns {string[]} absolute paths, sorted
99
+ */
100
+ export function errorBoundaryModules(table) {
101
+ const found = new Set();
102
+ for (const entry of table.errors ?? []) {
103
+ if (typeof entry?.module === "string") found.add(entry.module);
104
+ }
105
+ const seen = new Set();
106
+ const walk = (value) => {
107
+ if (value == null || typeof value !== "object" || seen.has(value)) return;
108
+ seen.add(value);
109
+ if (Array.isArray(value)) {
110
+ for (const item of value) walk(item);
111
+ return;
112
+ }
113
+ for (const [key, child] of Object.entries(value)) {
114
+ if (key === "errorBoundary" && typeof child?.module === "string") found.add(child.module);
115
+ walk(child);
116
+ }
117
+ };
118
+ walk(table.routes ?? []);
119
+ return [...found].sort();
120
+ }
121
+
122
+ /**
123
+ * Fail when an error boundary in `table` is not a client module.
124
+ *
125
+ * Throws one error naming every such file, relative to `root`, with the fix;
126
+ * answers nothing otherwise. `read` is the file reader, for a test.
127
+ *
128
+ * @param {object} table what `scanRoutes` answered
129
+ * @param {string} root the project root, for the names in the message
130
+ * @param {(file: string) => string} [read]
131
+ */
132
+ export function refuseServerErrorBoundaries(
133
+ table,
134
+ root,
135
+ read = (file) => readFileSync(file, "utf8"),
136
+ ) {
137
+ const missing = errorBoundaryModules(table).filter((file) => {
138
+ let source;
139
+ try {
140
+ source = read(file);
141
+ } catch {
142
+ // A boundary the scan found and nobody can read is the bundler's to
143
+ // report, with the error it gets opening it.
144
+ return false;
145
+ }
146
+ return !hasUseClientDirective(source);
147
+ });
148
+ if (missing.length === 0) return;
149
+ const names = missing.map((file) => path.relative(root, file).split(path.sep).join("/"));
150
+ throw new Error(
151
+ `uf: ${names.length === 1 ? "an error boundary is" : `${names.length} error boundaries are`} not ` +
152
+ `a client module: ${names.join(", ")}. An \`$error.js\` catches a throw while the browser ` +
153
+ 'renders, so its component runs in the browser, and the module has to open with "use client" ' +
154
+ "as its first statement. Without it the page that throws is answered with a bare 500. Add " +
155
+ '"use client"; at the top of each. See docs/app/guide/routing.',
156
+ );
157
+ }
@@ -18,9 +18,14 @@
18
18
  // client directive is not evaluated here: it is replaced by a client
19
19
  // reference per export, naming the chunk the browser loads it from.
20
20
  // * **`ssr`**, Vite's own server environment. It holds the HTML renderer, the
21
- // route handlers, the middleware, the action table — and the *server copy*
22
- // of every client module, which is what renders a client component into
23
- // HTML. It reaches the rsc graph through one module, the bridge.
21
+ // route handlers, the middleware — and the *server copy* of every client
22
+ // module, which is what renders a client component into HTML. It reaches
23
+ // the rsc graph through one module, the bridge, for the payload and for
24
+ // the server-action endpoint: the action table is the rsc graph's, so an
25
+ // action shares every module instance with the pages that show what it
26
+ // wrote (ubugeeei-prod/uf#1469). A route handler and a middleware do not
27
+ // yet: a module one of them imports and a page imports is evaluated once in
28
+ // each graph.
24
29
  // * **`client`**, the browser's. Its entry hydrates from the payload the
25
30
  // document carries, and it holds no page, layout or loader — only the
26
31
  // client modules, each an entry of its own, loaded when a payload names it.
@@ -509,18 +514,30 @@ export function fsFileOf(pathname) {
509
514
  * build rendered it — a prerendered payload is a file, and nothing that serves
510
515
  * a file can say so for it. `null` under `uf dev`.
511
516
  */
512
- export function rscEntrySource(routesId, routing = {}, deployment = null) {
517
+ export function rscEntrySource(routesId, routing = {}, deployment = null, actionsId = null) {
513
518
  const settings = {
514
519
  basePath: routing.basePath ?? "",
515
520
  trailingSlash: routing.trailingSlash ?? "ignore",
516
521
  };
517
- return `import { createFlightRenderer, installRouting } from "@uniflowed/router/rsc";
522
+ // The action table is loaded here, in the graph the pages render in, and
523
+ // not in the ssr graph that answers the request. A module an action and a
524
+ // page both import is then one instance: a write the action makes is what
525
+ // the next render — the postback of a form posted before hydration
526
+ // included — reads. In the ssr graph it was a second copy, and the page
527
+ // never saw the write (ubugeeei-prod/uf#1469).
528
+ const actions =
529
+ actionsId == null
530
+ ? "export const callAction = createActionDispatcher({ actions: [] });"
531
+ : `import { actions } from ${JSON.stringify(actionsId)};
532
+ export const callAction = createActionDispatcher({ actions });`;
533
+ return `import { createActionDispatcher, createFlightRenderer, installRouting } from "@uniflowed/router/rsc";
518
534
  import { routes, notFound, errors } from ${JSON.stringify(routesId)};
519
535
  installRouting(${JSON.stringify(settings)});
520
536
  export { routes, notFound, errors };
521
537
  export const renderFlight = createFlightRenderer({ routes, notFound, errors, deployment: ${JSON.stringify(
522
538
  deployment ?? null,
523
539
  )} });
540
+ ${actions}
524
541
  `;
525
542
  }
526
543
 
@@ -543,13 +560,16 @@ if (typeof load !== "function") {
543
560
  export async function renderFlight(url, options) {
544
561
  return (await load()).renderFlight(url, options);
545
562
  }
563
+ export async function callAction(request, settings) {
564
+ return (await load()).callAction(request, settings);
565
+ }
546
566
  export const { routes, notFound, errors } = await load();
547
567
  `;
548
568
  }
549
569
 
550
570
  /** `virtual:uf/rsc-bridge` in a build: the rsc build's output, bundled in. */
551
571
  export function builtBridgeSource(rscOutput) {
552
- return `export { renderFlight, routes, notFound, errors } from ${JSON.stringify(rscOutput)};\n`;
572
+ return `export { renderFlight, callAction, routes, notFound, errors } from ${JSON.stringify(rscOutput)};\n`;
553
573
  }
554
574
 
555
575
  /**
@@ -697,12 +717,10 @@ ${clientInstrumentationSource(options.instrumentation)}hydrateFlight({ App${stri
697
717
  export function flightServerSource(
698
718
  appEntry,
699
719
  routesId,
700
- actionsId,
701
720
  routing = { redirects: [], rewrites: [], headers: [], basePath: "", trailingSlash: "ignore" },
702
721
  instrumentation = null,
703
722
  ) {
704
723
  return `import {
705
- createActionDispatcher,
706
724
  createInstrumentation,
707
725
  instrumentRender,
708
726
  traceRequestPhase,
@@ -712,8 +730,13 @@ export function flightServerSource(
712
730
  } from "@uniflowed/router/server";
713
731
  import { createDocumentRenderer } from "@uniflowed/router/rsc/ssr";
714
732
  import { handlers, middleware } from ${JSON.stringify(routesId)};
715
- import { actions } from ${JSON.stringify(actionsId)};
716
- import { renderFlight, routes, notFound, errors } from ${JSON.stringify(FLIGHT_VIRTUAL.bridge)};
733
+ import {
734
+ renderFlight,
735
+ callAction as callActionInRsc,
736
+ routes,
737
+ notFound,
738
+ errors,
739
+ } from ${JSON.stringify(FLIGHT_VIRTUAL.bridge)};
717
740
  import { loadClientModule } from ${JSON.stringify(FLIGHT_VIRTUAL.references)};
718
741
  import App from ${JSON.stringify(appEntry)};
719
742
  export const routing = ${JSON.stringify(routing)};
@@ -735,7 +758,8 @@ export const flight = (url, options = {}) => instrumentRender(
735
758
  export { shellDocument } from "@uniflowed/router/server";
736
759
  const dispatchRoute = createDispatcher({ handlers });
737
760
  export const dispatch = (request) => traceRequestPhase("route", () => dispatchRoute(request));
738
- export const callAction = createActionDispatcher({ actions });
761
+ // Built in the rsc graph and reached through the bridge; see \`rscEntrySource\`.
762
+ export const callAction = callActionInRsc;
739
763
  const guard = createMiddlewareRunner({ middleware });
740
764
  export const runMiddleware = (request) => traceRequestPhase("middleware", () => guard(request));
741
765
  `;
package/internal/http.js CHANGED
@@ -34,9 +34,12 @@
34
34
  * @param {string} [path]
35
35
  */
36
36
  export async function toRequest(incoming, config, path) {
37
- const host = incoming.headers.host ?? "localhost";
38
37
  const protocol = config?.server?.https == null ? "http" : "https";
39
- const url = new URL(path ?? incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
38
+ const url = requestUrl(
39
+ protocol,
40
+ incoming.headers.host,
41
+ path ?? incoming.originalUrl ?? incoming.url,
42
+ );
40
43
 
41
44
  const headers = new Headers();
42
45
  for (const [name, value] of Object.entries(incoming.headers)) {
@@ -67,8 +70,61 @@ export async function toRequest(incoming, config, path) {
67
70
  * @param {import("node:http").IncomingMessage} incoming
68
71
  */
69
72
  export function toAddressRequest(incoming) {
70
- const host = incoming.headers.host ?? "localhost";
71
- return new Request(new URL(incoming.originalUrl ?? incoming.url ?? "/", `http://${host}`));
73
+ return new Request(
74
+ requestUrl("http", incoming.headers.host, incoming.originalUrl ?? incoming.url),
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The URL a Node request was for: `Host`'s authority and the request-target's
80
+ * path and query, joined as text so a target of `//evil.example/x` (or
81
+ * `/\evil.example/x`) cannot become the request's host, and an absolute-form
82
+ * target keeps only its path. A `Host` holding a path, a user name, a query or
83
+ * a fragment is not believed.
84
+ *
85
+ * The same function as `requestUrl` in `@uniflowed/server/node`, which has the
86
+ * argument for why; spelled twice because this file runs before any Flow
87
+ * transform and cannot import that one. `packages/server/serve.test.js` holds
88
+ * both to one answer.
89
+ *
90
+ * @param {string} protocol
91
+ * @param {unknown} host the `Host` header as Node parsed it
92
+ * @param {string | undefined} target the request-target
93
+ * @returns {URL}
94
+ */
95
+ function requestUrl(protocol, host, target) {
96
+ const authority =
97
+ typeof host === "string" && host !== "" && isAuthority(host) ? host : "localhost";
98
+ let path = target ?? "/";
99
+ if (!path.startsWith("/")) {
100
+ try {
101
+ const absolute = new URL(path);
102
+ path = absolute.pathname + absolute.search;
103
+ } catch {
104
+ path = "/";
105
+ }
106
+ }
107
+ try {
108
+ return new URL(`${protocol}://${authority}${path}`);
109
+ } catch {
110
+ return new URL(`${protocol}://localhost/`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Whether `value` has none of the characters that end a URL's authority.
116
+ *
117
+ * @param {string} value
118
+ */
119
+ function isAuthority(value) {
120
+ for (let index = 0; index < value.length; index += 1) {
121
+ const code = value.charCodeAt(index);
122
+ // `/`, `\`, `?`, `#`, `@`, and every space and control character.
123
+ if (code === 47 || code === 92 || code === 63 || code === 35 || code === 64 || code <= 32) {
124
+ return false;
125
+ }
126
+ }
127
+ return true;
72
128
  }
73
129
 
74
130
  /**
package/internal/rsc.js CHANGED
@@ -407,6 +407,65 @@ export function actionReferenceSource(actions) {
407
407
  return `${lines.join("\n")}\n`;
408
408
  }
409
409
 
410
+ /**
411
+ * The query that names a `"use server"` module's own source in a server graph.
412
+ *
413
+ * In a server graph the plain id of such a module answers with
414
+ * {@link serverActionSource}, which imports the file under this query, so the
415
+ * file itself is still one module and the wrapper is another.
416
+ */
417
+ export const SERVER_ACTION_IMPL_QUERY = "?uf-server-impl";
418
+
419
+ /**
420
+ * Whether a module's source declares a default export.
421
+ *
422
+ * Read off the text because the wrapper below has to decide whether to
423
+ * re-export `default` before anything has parsed the file — and Flow's
424
+ * `component` syntax is not something Rollup's parser reads. A default export
425
+ * is `export default …` or a name exported `as default`; a false negative
426
+ * leaves a non-callable default export unreachable from the server graph, and
427
+ * the RSC graph already refuses a `"use server"` export that is not an async
428
+ * function, so what is at stake is small and written down.
429
+ *
430
+ * @param {string} source
431
+ */
432
+ export function declaresDefaultExport(source) {
433
+ return (
434
+ /(^|[\n;])\s*export\s+default\b/.test(source) ||
435
+ /export\s*\{[^}]*\b(as\s+default|default)\b[^}]*\}/.test(source)
436
+ );
437
+ }
438
+
439
+ /**
440
+ * A server graph's stand-in for one `"use server"` module.
441
+ *
442
+ * Re-exports the file's own module — every binding, the same functions — and
443
+ * on the way past gives each callable export the property React reads to write
444
+ * a form that posts before hydration (`registerServerAction` in
445
+ * `@uniflowed/router/action`). Nothing about calling the functions changes;
446
+ * the endpoint, a route handler and a server component call exactly what they
447
+ * called before. See ubugeeei-prod/uf#1358.
448
+ *
449
+ * @param {string} file absolute path of the module
450
+ * @param {Array<{id: string, module: string, export: string}>} actions its callable exports
451
+ * @param {boolean} hasDefault whether the file declares a default export
452
+ */
453
+ export function serverActionSource(file, actions, hasDefault) {
454
+ const impl = JSON.stringify(`${file}${SERVER_ACTION_IMPL_QUERY}`);
455
+ const lines = [
456
+ 'import { registerServerAction } from "@uniflowed/router/action";',
457
+ `import * as impl from ${impl};`,
458
+ `export * from ${impl};`,
459
+ ];
460
+ if (hasDefault) lines.push(`export { default } from ${impl};`);
461
+ for (const action of actions) {
462
+ lines.push(
463
+ `registerServerAction(impl[${JSON.stringify(action.export)}], ${JSON.stringify(action.id)});`,
464
+ );
465
+ }
466
+ return `${lines.join("\n")}\n`;
467
+ }
468
+
410
469
  /**
411
470
  * The source of `virtual:uf/actions`: the table the endpoint dials into.
412
471
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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",
@@ -35,10 +35,10 @@
35
35
  "@babel/core": "^7.29.7",
36
36
  "@mdx-js/rollup": "^3.1.1",
37
37
  "@shikijs/rehype": "^3.23.0",
38
- "@uniflowed/host": "0.1.0",
39
- "@uniflowed/router": "0.1.0",
40
- "@uniflowed/server": "0.1.0",
41
- "@uniflowed/validator": "0.1.0",
38
+ "@uniflowed/host": "0.3.0",
39
+ "@uniflowed/router": "0.3.0",
40
+ "@uniflowed/server": "0.3.0",
41
+ "@uniflowed/validator": "0.3.0",
42
42
  "babel-plugin-relay": "^21.0.1",
43
43
  "estree-util-value-to-estree": "^3.5.0",
44
44
  "rehype-slug": "^6.0.0",