@uniflowed/vite 0.2.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
@@ -535,6 +535,8 @@ async function preview() {
535
535
  previewServer.middlewares.use((request, response, next) => {
536
536
  if ((request.url ?? "").split("?")[0].endsWith("/__uf.flight")) {
537
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");
538
540
  }
539
541
  if ((request.url ?? "").split("?")[0] === "/.well-known/apple-app-site-association") {
540
542
  response.setHeader("content-type", "application/json; charset=utf-8");
@@ -1512,6 +1514,28 @@ const ADAPTERS = {
1512
1514
  // partially is refused by name; see [`deploy`].
1513
1515
  streams: false,
1514
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
+ },
1515
1539
  };
1516
1540
 
1517
1541
  /**
@@ -1536,6 +1560,7 @@ const BUN_CAPABILITIES = { module: "@uniflowed/server/bun", name: "bunCapabiliti
1536
1560
  const DENO_CAPABILITIES = { module: "@uniflowed/server/deno", name: "denoCapabilities" };
1537
1561
  const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
1538
1562
  const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
1563
+ const VERCEL_CAPABILITIES = { module: "@uniflowed/server/vercel", name: "vercelCapabilities" };
1539
1564
 
1540
1565
  /**
1541
1566
  * Link the application into a directory that can be copied, for
@@ -2348,6 +2373,33 @@ export default {
2348
2373
  `;
2349
2374
  }
2350
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
+
2351
2403
  /**
2352
2404
  * The source of `lambda.js`: the AWS Lambda entry around that handler.
2353
2405
  *
package/index.js CHANGED
@@ -62,7 +62,8 @@ import {
62
62
  auditTag,
63
63
  } from "./internal/a11y.js";
64
64
  import { assetPlugin } from "./internal/assets.js";
65
- import { barrelImportsPlugin, namespaceViewOf } from "./internal/barrel-imports.js";
65
+ import { barrelImportsPlugin } from "./internal/barrel-imports.js";
66
+ import { refuseServerErrorBoundaries } from "./internal/error-boundaries.js";
66
67
  import { emit, reportRenderError, errorEvent } from "./internal/events.js";
67
68
  import remarkFrontmatterExport from "./internal/frontmatter.js";
68
69
  import { highlightPlugin } from "./internal/highlight.js";
@@ -579,6 +580,11 @@ function flowPlugin({
579
580
  // `react-server` for nothing. The ssr graph gets exactly those two,
580
581
  // because every route it renders reaches it as a payload.
581
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);
582
588
  return routesModuleSource({ ...table, handlers: [], middleware: [] });
583
589
  }
584
590
  if (flightState != null && isSsr(this, loadOptions)) {
@@ -620,17 +626,11 @@ function flowPlugin({
620
626
  if (id === resolved(VIRTUAL.server)) {
621
627
  return flightState == null
622
628
  ? serverModuleSource(entryPath, routing, instrumentationFile(appRoot))
623
- : flightServerSource(
624
- entryPath,
625
- VIRTUAL.routes,
626
- VIRTUAL.actions,
627
- routing,
628
- instrumentationFile(appRoot),
629
- );
629
+ : flightServerSource(entryPath, VIRTUAL.routes, routing, instrumentationFile(appRoot));
630
630
  }
631
631
  if (flightState != null) {
632
632
  if (id === resolved(FLIGHT_VIRTUAL.entry))
633
- return rscEntrySource(VIRTUAL.routes, routing, flightState.deployment);
633
+ return rscEntrySource(VIRTUAL.routes, routing, flightState.deployment, VIRTUAL.actions);
634
634
  if (id === resolved(FLIGHT_VIRTUAL.compilerRuntime)) return compilerRuntimeSource();
635
635
  if (id === resolved(FLIGHT_VIRTUAL.bridge)) {
636
636
  if (server != null) return devBridgeSource();
@@ -651,10 +651,11 @@ function flowPlugin({
651
651
  : builtReferencesSource(flightState.chunkUrls);
652
652
  }
653
653
  }
654
- // Only `virtual:uf/server` imports this, so it is only ever asked for in
655
- // the server environment — but the table it carries is every callable
656
- // endpoint of the build, so it is worth saying that a browser asking for
657
- // 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.
658
659
  if (id === resolved(VIRTUAL.actions)) {
659
660
  if (!isSsr(this, loadOptions))
660
661
  return "export const actions = [];\nexport default actions;\n";
@@ -705,9 +706,7 @@ function flowPlugin({
705
706
  },
706
707
 
707
708
  async transform(code, id, transformOptions) {
708
- // A view of a barrel's namespace has the barrel's path and none of its
709
- // source: `uf:barrel-imports` generates it as JavaScript.
710
- if (!isFlowModule(id) || namespaceViewOf(id) != null) return null;
709
+ if (!isFlowModule(id)) return null;
711
710
  // What an earlier pass of this build wrote under `.uf/build/` — the ssr
712
711
  // pass imports the rsc graph's bundle — is already this transform's
713
712
  // output. See `isCompiledOutput`.
@@ -950,12 +949,20 @@ function flowPlugin({
950
949
  // depend on a clock.
951
950
  forgetActions();
952
951
  const nextActions = actionTables().modules;
953
- invalidateActionModules(devServer.moduleGraph, previousActions);
954
- invalidateActionModules(devServer.moduleGraph, nextActions);
955
- const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
956
- if (routes) devServer.moduleGraph.invalidateModule(routes);
957
- const actions = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.actions));
958
- 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
+ }
959
966
  devServer.ws.send({ type: "full-reload", path: "*" });
960
967
  };
961
968
  devServer.watcher.on("add", onManifest);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.2.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.2.0",
39
- "@uniflowed/router": "0.2.0",
40
- "@uniflowed/server": "0.2.0",
41
- "@uniflowed/validator": "0.2.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",