@uniflowed/vite 0.0.0-alpha.40 → 0.0.0-alpha.42

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
@@ -42,6 +42,7 @@
42
42
  import { randomUUID } from "node:crypto";
43
43
  import { createServer as createHttpServer } from "node:http";
44
44
  import { builtinModules, register } from "node:module";
45
+ import { esmExternalRequirePlugin } from "rolldown/plugins";
45
46
  import { installFlowHooks } from "@uniflowed/host/internal/sync-hooks.js";
46
47
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
47
48
  import path from "node:path";
@@ -524,6 +525,9 @@ async function preview() {
524
525
  if ((request.url ?? "").split("?")[0].endsWith("/__uf.flight")) {
525
526
  response.setHeader("content-type", "text/x-component");
526
527
  }
528
+ if ((request.url ?? "").split("?")[0] === "/.well-known/apple-app-site-association") {
529
+ response.setHeader("content-type", "application/json; charset=utf-8");
530
+ }
527
531
  next();
528
532
  });
529
533
  if (handle == null) return;
@@ -1576,7 +1580,12 @@ async function deploy() {
1576
1580
  plugins: [
1577
1581
  ...inline.plugins,
1578
1582
  nativeAddonGuard(),
1579
- ...(shape.workerBuiltins === true ? [workerBuiltinGuard()] : []),
1583
+ ...(shape.workerBuiltins === true
1584
+ ? [
1585
+ workerBuiltinGuard(),
1586
+ esmExternalRequirePlugin({ external: [...builtinModules, /^node:/] }),
1587
+ ]
1588
+ : []),
1580
1589
  ],
1581
1590
  // Fixed, because this bundle inlines every dependency and so both of each
1582
1591
  // React package's builds, and a runtime lookup of `NODE_ENV` in a worker
@@ -1607,6 +1616,10 @@ async function deploy() {
1607
1616
  emptyOutDir: false,
1608
1617
  rollupOptions: {
1609
1618
  input,
1619
+ // Workers provide the selected built-ins, but cannot use Node's
1620
+ // createRequire(import.meta.url) runtime helper. External requires
1621
+ // above become ESM imports; the remaining helpers are platform neutral.
1622
+ ...(shape.workerBuiltins === true ? { platform: "neutral" } : {}),
1610
1623
  output: {
1611
1624
  entryFileNames: "[name].js",
1612
1625
  // Route modules are lazy `import()`s, so the server bundle splits
@@ -1677,11 +1690,12 @@ async function deploy() {
1677
1690
  function handlerEntrySource(document, cache, capabilities, build, regeneration) {
1678
1691
  const route = cache?.route === true;
1679
1692
  const fetchCache = cache?.fetch === true;
1693
+ const dataCache = cache?.data === true;
1680
1694
  // Nothing at all when both switches are off, so a default project's
1681
1695
  // `handler.js` is the file it has always been. A cache that appears in
1682
1696
  // generated output nobody asked for is the second half of the complaint
1683
1697
  // #277 makes about the first half.
1684
- const store = route || fetchCache;
1698
+ const store = route || fetchCache || dataCache;
1685
1699
  const durable = store ? durableStoreSource(root, cache, build) : null;
1686
1700
  const options = [
1687
1701
  "app",
@@ -1716,7 +1730,7 @@ ${
1716
1730
  } See ubugeeei-prod/uf#277.
1717
1731
  const cache = { store: createCacheStore(${
1718
1732
  durable == null ? "" : `{ provider: ${durable.provider}, build: ${JSON.stringify(build)} }`
1719
- }), route: ${String(route)}, fetch: ${String(fetchCache)} };
1733
+ }), route: ${String(route)}, fetch: ${String(fetchCache)}, data: ${String(dataCache)} };
1720
1734
 
1721
1735
  `
1722
1736
  : ""
package/index.js CHANGED
@@ -44,11 +44,14 @@
44
44
  // `uniflowed(options)` returns the array; a project that wants to add a plugin
45
45
  // declares it in `uf.config.js` and the driver appends it after these.
46
46
 
47
- import { readdirSync } from "node:fs";
47
+ import { instrumentationFile } from "./internal/instrumentation.js";
48
+
48
49
  import path from "node:path";
50
+ import { nativeWebPlugin } from "./internal/native-web.js";
49
51
 
50
52
  import mdx from "@mdx-js/rollup";
51
53
  import rehypeSlug from "rehype-slug";
54
+ import { relayConfig, relayDependencies, transformRelay } from "./internal/relay.js";
52
55
 
53
56
  import {
54
57
  AUDIT_PUBLIC_PATH,
@@ -59,7 +62,7 @@ import {
59
62
  } from "./internal/a11y.js";
60
63
  import { assetPlugin } from "./internal/assets.js";
61
64
  import { barrelImportsPlugin, namespaceViewOf } from "./internal/barrel-imports.js";
62
- import { emit, reportRenderError } from "./internal/events.js";
65
+ import { emit, reportRenderError, errorEvent } from "./internal/events.js";
63
66
  import remarkFrontmatterExport from "./internal/frontmatter.js";
64
67
  import { highlightPlugin } from "./internal/highlight.js";
65
68
  import { moduleId } from "./internal/module-graph.js";
@@ -93,11 +96,12 @@ import {
93
96
  scanRoutes,
94
97
  serverModuleSource,
95
98
  } from "./internal/routes.js";
96
- import { TransformService, isFlowModule } from "@uniflowed/host/transform";
99
+ import { TransformService, isFlowModule, uniflowedPackages } from "@uniflowed/host/transform";
97
100
  import {
98
101
  DEV_RSC_HOOK,
99
102
  FLIGHT_BROWSER_DEPENDENCIES,
100
103
  FLIGHT_VIRTUAL,
104
+ INTERCEPTED_FROM_HEADER,
101
105
  RSC_ENVIRONMENT,
102
106
  builtBridgeSource,
103
107
  builtReferencesSource,
@@ -118,6 +122,7 @@ import {
118
122
  rscEnvironment,
119
123
  } from "./internal/flight.js";
120
124
  import { createChannelMiddleware } from "./internal/diagnostics.js";
125
+ import { startDevState } from "./internal/dev-state.js";
121
126
  import { devtoolsPreamble } from "./internal/devtools.js";
122
127
  import { send, toAddressRequest, toRequest } from "./internal/http.js";
123
128
  import {
@@ -133,6 +138,25 @@ import { serverComponentsProblem } from "./internal/server-components.js";
133
138
  const resolved = (id) => `\0${id}`;
134
139
  const VIRTUAL_IDS = new Set([...Object.values(VIRTUAL), ...Object.values(FLIGHT_VIRTUAL)]);
135
140
 
141
+ /**
142
+ * CommonJS packages the browser can reach only through an installed
143
+ * `@uniflowed/*` package.
144
+ *
145
+ * uf excludes its own packages from Vite's dependency optimizer because they
146
+ * ship Flow, so Vite will not discover a CommonJS peer through them once they
147
+ * live under a project's `node_modules`. React Testing's `happy-dom` edge is
148
+ * ESM and browser-mapped to a local stub, and the optional `axe-core` edge is
149
+ * named separately below only when the project actually installed it.
150
+ */
151
+ const CLIENT_COMMONJS_DEPENDENCIES = Object.freeze([
152
+ "react",
153
+ "react/jsx-runtime",
154
+ "react/jsx-dev-runtime",
155
+ "react/compiler-runtime",
156
+ "react-dom",
157
+ "react-dom/client",
158
+ ]);
159
+
136
160
  /**
137
161
  * Prefix of the virtual module that carries one source module's StyleX rules.
138
162
  *
@@ -229,6 +253,7 @@ export default function uniflowed(options = {}) {
229
253
  routing,
230
254
  command: options.command,
231
255
  accessibility,
256
+ relayEnabled: builtins.relay !== false,
232
257
  }),
233
258
  ...(flightState == null ? [] : [clientReferencePlugin(flightState), clientModuleUrlPlugin()]),
234
259
  // After the references, so a client module the rsc graph has already
@@ -242,6 +267,7 @@ export default function uniflowed(options = {}) {
242
267
  og: builtins.og ?? {},
243
268
  command: options.command,
244
269
  }),
270
+ nativeWebPlugin(routeTarget),
245
271
  ];
246
272
  }
247
273
 
@@ -257,6 +283,7 @@ function flowPlugin({
257
283
  routing,
258
284
  command,
259
285
  accessibility,
286
+ relayEnabled,
260
287
  }) {
261
288
  let root = process.cwd();
262
289
  let isProduction = false;
@@ -276,6 +303,7 @@ function flowPlugin({
276
303
  let server = null;
277
304
  /** @type {TransformService | null} */
278
305
  let service = null;
306
+ let relayOptions = null;
279
307
  /**
280
308
  * Each module's compiled stylesheet, keyed by the virtual id serving it.
281
309
  *
@@ -413,16 +441,12 @@ function flowPlugin({
413
441
  // uf serves HTML itself; there is no index.html to fall back to.
414
442
  appType: "custom",
415
443
  resolve: {
416
- dedupe: ["react", "react-dom"],
444
+ dedupe: ["react", "react-dom", "react-relay", "relay-runtime"],
417
445
  },
418
446
  optimizeDeps: {
419
447
  include: [
420
- "react",
421
- "react/jsx-runtime",
422
- "react/jsx-dev-runtime",
423
- "react/compiler-runtime",
424
- "react-dom",
425
- "react-dom/client",
448
+ ...CLIENT_COMMONJS_DEPENDENCIES,
449
+ ...relayDependencies(projectRoot),
426
450
  // React's Flight client, for an application whose routes render as
427
451
  // Server Components; `FLIGHT_BROWSER_DEPENDENCIES` says why.
428
452
  ...(flightState == null ? [] : FLIGHT_BROWSER_DEPENDENCIES),
@@ -455,6 +479,7 @@ function flowPlugin({
455
479
  : {
456
480
  environments: {
457
481
  [RSC_ENVIRONMENT]: rscEnvironment({
482
+ root: projectRoot,
458
483
  production: env.command === "build",
459
484
  exclude: uniflowedPackages(projectRoot),
460
485
  }),
@@ -556,6 +581,7 @@ function flowPlugin({
556
581
  // looking at. See `clientModuleSource`.
557
582
  if (id === resolved(VIRTUAL.client) && flightState != null) {
558
583
  return flightClientSource(entryPath, {
584
+ instrumentation: instrumentationFile(appRoot, true),
559
585
  strictMode: strictMode && !isProduction,
560
586
  navigation,
561
587
  staleTime,
@@ -564,6 +590,7 @@ function flowPlugin({
564
590
  }
565
591
  if (id === resolved(VIRTUAL.client)) {
566
592
  return clientModuleSource(entryPath, {
593
+ instrumentation: instrumentationFile(appRoot, true),
567
594
  strictMode: strictMode && !isProduction,
568
595
  navigation,
569
596
  staleTime,
@@ -573,8 +600,14 @@ function flowPlugin({
573
600
  }
574
601
  if (id === resolved(VIRTUAL.server)) {
575
602
  return flightState == null
576
- ? serverModuleSource(entryPath, routing)
577
- : flightServerSource(entryPath, VIRTUAL.routes, VIRTUAL.actions, routing);
603
+ ? serverModuleSource(entryPath, routing, instrumentationFile(appRoot))
604
+ : flightServerSource(
605
+ entryPath,
606
+ VIRTUAL.routes,
607
+ VIRTUAL.actions,
608
+ routing,
609
+ instrumentationFile(appRoot),
610
+ );
578
611
  }
579
612
  if (flightState != null) {
580
613
  if (id === resolved(FLIGHT_VIRTUAL.entry)) return rscEntrySource(VIRTUAL.routes, routing);
@@ -657,7 +690,16 @@ function flowPlugin({
657
690
  reported,
658
691
  suppressed,
659
692
  });
660
- const map = out.map == null ? null : JSON.parse(out.map);
693
+ let map = out.map == null ? null : JSON.parse(out.map);
694
+ let output = out.code;
695
+ if (relayEnabled && output.includes("graphql")) {
696
+ relayOptions ??= relayConfig(root);
697
+ const transformed = await transformRelay(output, cleanId(id), map, await relayOptions);
698
+ if (transformed != null) {
699
+ output = transformed.code;
700
+ map = transformed.map;
701
+ }
702
+ }
661
703
  // StyleX. `uf transform` compiled the module's `stylex.create` calls into
662
704
  // class names and handed back the rules they declared; the rules become a
663
705
  // module of their own that this one imports.
@@ -675,7 +717,6 @@ function flowPlugin({
675
717
  // and an absolute name would publish where the machine that built it
676
718
  // keeps its files. A module outside the root climbs out with `../`.
677
719
  const styled = out.css != null && out.css !== "";
678
- let output = out.code;
679
720
  if (styled) {
680
721
  const styleId = `${STYLE_PREFIX}${moduleId(root, cleanId(id))}.css`;
681
722
  styles.set(styleId, out.css);
@@ -763,6 +804,16 @@ function flowPlugin({
763
804
 
764
805
  configureServer(devServer) {
765
806
  server = devServer;
807
+ const diagnostics = startDevState(root, () => ({
808
+ routes: scanRoutes(appRoot, { target: routeTarget }).routes.map((route) => ({
809
+ path: route.path,
810
+ page: route.page,
811
+ layouts: route.layouts,
812
+ })),
813
+ actions: actionTables().table,
814
+ }));
815
+ devServer.middlewares.use(diagnostics.middleware);
816
+ devServer.httpServer?.once("close", diagnostics.close);
766
817
  // The ssr graph's way into the rsc graph; see `devBridgeSource`.
767
818
  if (flightState != null) {
768
819
  globalThis[Symbol.for(DEV_RSC_HOOK)] = () =>
@@ -823,8 +874,10 @@ function flowPlugin({
823
874
  const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
824
875
  const onRouteFile = (file) => {
825
876
  if (!reserved.test(file) || !file.startsWith(appRoot)) return;
826
- const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
827
- if (routes) devServer.moduleGraph.invalidateModule(routes);
877
+ for (const id of [VIRTUAL.routes, VIRTUAL.server, VIRTUAL.client]) {
878
+ const module = devServer.moduleGraph.getModuleById(resolved(id));
879
+ if (module) devServer.moduleGraph.invalidateModule(module);
880
+ }
828
881
  devServer.ws.send({ type: "full-reload", path: "*" });
829
882
  };
830
883
  devServer.watcher.on("add", onRouteFile);
@@ -995,8 +1048,10 @@ function flowPlugin({
995
1048
  if (payloadFor != null && (request.method === "GET" || request.method === "HEAD")) {
996
1049
  const query = url.includes("?") ? url.slice(url.indexOf("?")) : "";
997
1050
  const target = payloadFor + query;
1051
+ const fromHeader = request.headers[INTERCEPTED_FROM_HEADER];
998
1052
  const answered = await entry.flight(target, {
999
1053
  onError: (error) => reportRenderError(devServer, target, error),
1054
+ interceptedFrom: Array.isArray(fromHeader) ? fromHeader[0] : fromHeader,
1000
1055
  });
1001
1056
  if (answered.error != null) reportRenderError(devServer, target, answered.error);
1002
1057
  if (request.method === "HEAD") await answered.stream?.cancel();
@@ -1148,6 +1203,12 @@ function flowPlugin({
1148
1203
  // Map the stack back onto the Flow source before it reaches the
1149
1204
  // overlay.
1150
1205
  if (error instanceof Error) devServer.ssrFixStacktrace(error);
1206
+ emit("diagnostic", {
1207
+ severity: "error",
1208
+ kind: "runtime",
1209
+ origin: url,
1210
+ ...errorEvent(error),
1211
+ });
1151
1212
  next(error);
1152
1213
  }
1153
1214
  });
@@ -1420,24 +1481,3 @@ function cleanId(id) {
1420
1481
  const at = id.indexOf("?");
1421
1482
  return at === -1 ? id : id.slice(0, at);
1422
1483
  }
1423
-
1424
- /**
1425
- * Every `@uniflowed/*` package the project can resolve, for
1426
- * `optimizeDeps.exclude`, which takes names rather than patterns.
1427
- */
1428
- function uniflowedPackages(root) {
1429
- const names = new Set();
1430
- let directory = root;
1431
- for (let depth = 0; depth < 16; depth += 1) {
1432
- const scope = path.join(directory, "node_modules", "@uniflowed");
1433
- try {
1434
- for (const entry of readdirSync(scope)) names.add(`@uniflowed/${entry}`);
1435
- } catch {
1436
- // no packages at this level
1437
- }
1438
- const parent = path.dirname(directory);
1439
- if (parent === directory) break;
1440
- directory = parent;
1441
- }
1442
- return [...names].sort();
1443
- }
@@ -0,0 +1,161 @@
1
+ // @noflow
2
+ // A local, bounded snapshot of the same diagnostic channel the terminal reads.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { format } from "node:util";
8
+
9
+ const requests = new AsyncLocalStorage();
10
+ let receive = null;
11
+
12
+ export function recordDevEvent(event) {
13
+ const request = requests.getStore();
14
+ receive?.({ ...event, ...(request == null ? {} : request), time: Date.now() });
15
+ }
16
+
17
+ export function startDevState(root, metadata) {
18
+ const directory = path.join(root, ".uf");
19
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink())
20
+ throw new Error("uf dev: .uf must not be a symlink for diagnostics");
21
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
22
+ const file = path.join(directory, "dev-state.json");
23
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink())
24
+ throw new Error("uf dev: diagnostic state must not be a symlink");
25
+ const session = randomUUID();
26
+ const temporary = path.join(directory, "dev-state-" + session + ".tmp");
27
+ const state = {
28
+ schema: 1,
29
+ session,
30
+ pid: process.pid,
31
+ startedAt: Date.now(),
32
+ updatedAt: 0,
33
+ generation: 0,
34
+ errors: [],
35
+ logs: [],
36
+ routes: [],
37
+ actions: [],
38
+ };
39
+ let closed = false;
40
+ const persist = (refresh = false) => {
41
+ if (closed) return;
42
+ if (refresh || state.updatedAt === 0) {
43
+ let current;
44
+ try {
45
+ current = metadata();
46
+ state.metadataError = null;
47
+ } catch (error) {
48
+ state.metadataError = String(error.message ?? error).slice(0, 4000);
49
+ current = { routes: state.routes, actions: state.actions };
50
+ }
51
+ state.routes = current.routes.slice(0, 1000);
52
+ state.actions = current.actions.slice(0, 1000);
53
+ state.truncated = current.routes.length > 1000 || current.actions.length > 1000;
54
+ }
55
+ state.updatedAt = Date.now();
56
+ let serialized = JSON.stringify(state);
57
+ while (Buffer.byteLength(serialized) > 3 * 1024 * 1024) {
58
+ const rows = state.logs.length
59
+ ? state.logs
60
+ : state.errors.length
61
+ ? state.errors
62
+ : state.routes.length
63
+ ? state.routes
64
+ : state.actions;
65
+ if (rows.length === 0) break;
66
+ rows.shift();
67
+ state.truncated = true;
68
+ serialized = JSON.stringify(state);
69
+ }
70
+ fs.writeFileSync(temporary, serialized, { mode: 0o600 });
71
+ fs.renameSync(temporary, file);
72
+ };
73
+ const observer = (event) => {
74
+ const safe = bound(event);
75
+ if (event.event === "source-changed") {
76
+ state.generation += 1;
77
+ state.errors = [];
78
+ }
79
+ if (
80
+ event.event === "error" ||
81
+ (event.event === "log" && event.level === "error") ||
82
+ (event.event === "diagnostic" && event.severity === "error")
83
+ ) {
84
+ safe.kind ??= /hydrat/i.test(event.message ?? "")
85
+ ? "hydration"
86
+ : event.event === "diagnostic"
87
+ ? "runtime"
88
+ : "build";
89
+ state.errors.push(safe);
90
+ state.errors = state.errors.slice(-100);
91
+ }
92
+ if (["log", "diagnostic", "error", "request"].includes(event.event)) {
93
+ state.logs.push(safe);
94
+ state.logs = state.logs.slice(-150);
95
+ }
96
+ persist(event.event === "source-changed");
97
+ };
98
+ receive = observer;
99
+ persist();
100
+ // The terminal sees application console output too; keep its original behavior.
101
+ const originals = new Map();
102
+ for (const level of ["log", "info", "warn", "error", "debug"]) {
103
+ const original = console[level];
104
+ const wrapper = (...args) => {
105
+ recordDevEvent({ event: "log", level, kind: "runtime", message: format(...args) });
106
+ original.apply(console, args);
107
+ };
108
+ originals.set(level, { original, wrapper });
109
+ console[level] = wrapper;
110
+ }
111
+ const timer = setInterval(() => {
112
+ try {
113
+ persist(true);
114
+ } catch {
115
+ close();
116
+ }
117
+ }, 5000);
118
+ timer.unref?.();
119
+ const close = () => {
120
+ if (closed) return;
121
+ closed = true;
122
+ clearInterval(timer);
123
+ if (receive === observer) receive = null;
124
+ for (const [level, { original, wrapper }] of originals) {
125
+ if (console[level] === wrapper) console[level] = original;
126
+ }
127
+ try {
128
+ if (JSON.parse(fs.readFileSync(file, "utf8")).session === session) fs.unlinkSync(file);
129
+ } catch {
130
+ /* A missing or unreadable channel is reported by the MCP reader. */
131
+ }
132
+ process.off("exit", close);
133
+ };
134
+ process.once("exit", close);
135
+ return {
136
+ close,
137
+ middleware(request, response, next) {
138
+ const requestId = randomUUID();
139
+ const url = String(request.url ?? "/")
140
+ .split("?")[0]
141
+ .slice(0, 1000);
142
+ response.setHeader("x-uf-request-id", requestId);
143
+ requests.run({ requestId, url }, () => {
144
+ observer({ event: "request", requestId, url, method: request.method, time: Date.now() });
145
+ next();
146
+ });
147
+ },
148
+ };
149
+ }
150
+
151
+ function bound(value, depth = 0) {
152
+ if (typeof value === "string") return value.slice(0, 4000);
153
+ if (value == null || typeof value !== "object") return value;
154
+ if (depth > 4) return "[truncated]";
155
+ if (Array.isArray(value)) return value.slice(0, 40).map((v) => bound(v, depth + 1));
156
+ return Object.fromEntries(
157
+ Object.entries(value)
158
+ .slice(0, 32)
159
+ .map(([key, v]) => [key, bound(v, depth + 1)]),
160
+ );
161
+ }
@@ -11,6 +11,7 @@
11
11
  // logger, which is redirected here as well so nothing bypasses the channel.
12
12
 
13
13
  import { createLogger } from "vite";
14
+ import { recordDevEvent } from "./dev-state.js";
14
15
 
15
16
  /**
16
17
  * Emit one event.
@@ -19,6 +20,7 @@ import { createLogger } from "vite";
19
20
  * `done` is on the pipe before the process exits.
20
21
  */
21
22
  export function emit(event, fields = {}) {
23
+ recordDevEvent({ event, ...fields });
22
24
  process.stdout.write(`${JSON.stringify({ event, ...fields })}\n`);
23
25
  }
24
26
 
@@ -67,6 +69,7 @@ export function reportRenderError(server, url, error) {
67
69
  if (error instanceof Error) {
68
70
  server.ssrFixStacktrace(error);
69
71
  }
72
+ emit("diagnostic", { severity: "error", kind: "runtime", origin: url, ...errorEvent(error) });
70
73
  const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
71
74
  server.config.logger.error(`${url} rendered its error boundary\n${stripAnsi(detail)}`);
72
75
  }
@@ -49,7 +49,10 @@
49
49
  // In development the rsc environment's module graph is read instead, once the
50
50
  // route's modules have been imported.
51
51
 
52
+ import { clientInstrumentationSource } from "./instrumentation.js";
53
+
52
54
  import path from "node:path";
55
+ import { relayDependencies } from "./relay.js";
53
56
 
54
57
  import {
55
58
  createRunnableDevEnvironment,
@@ -120,6 +123,9 @@ export const DEV_RSC_HOOK = "uf.dev.rsc";
120
123
  */
121
124
  export const FLIGHT_SEGMENT = "__uf.flight";
122
125
 
126
+ /** The header a browser sends when a Flight payload should render an interception. */
127
+ export const INTERCEPTED_FROM_HEADER = "uf-intercepted-from";
128
+
123
129
  /** The document a payload path is for, or `null` for any other path. */
124
130
  export function flightDocumentPath(pathname) {
125
131
  const suffix = `/${FLIGHT_SEGMENT}`;
@@ -166,7 +172,7 @@ export function rendersFlight(app, { mount, routeTarget }) {
166
172
  *
167
173
  * @param {{ production: boolean, exclude: Array<string> }} options
168
174
  */
169
- export function rscEnvironment({ production, exclude }) {
175
+ export function rscEnvironment({ production, exclude, root = process.cwd() }) {
170
176
  return {
171
177
  consumer: "server",
172
178
  resolve: {
@@ -180,6 +186,7 @@ export function rscEnvironment({ production, exclude }) {
180
186
  "react/jsx-runtime",
181
187
  "react/jsx-dev-runtime",
182
188
  "react-server-dom-parcel/server",
189
+ ...relayDependencies(root, true),
183
190
  ],
184
191
  exclude,
185
192
  },
@@ -661,7 +668,7 @@ export function flightClientSource(appEntry, options = {}) {
661
668
  options.staleTime > 0 ? `, staleTime: ${JSON.stringify(options.staleTime)}` : "";
662
669
  return `import { hydrateFlight } from "@uniflowed/router/rsc/client";
663
670
  import App from ${JSON.stringify(appEntry)};
664
- hydrateFlight({ App${strictMode}${navigation}${staleTime}${routing} });
671
+ ${clientInstrumentationSource(options.instrumentation)}hydrateFlight({ App${strictMode}${navigation}${staleTime}${routing} });
665
672
  `;
666
673
  }
667
674
 
@@ -682,9 +689,13 @@ export function flightServerSource(
682
689
  routesId,
683
690
  actionsId,
684
691
  routing = { redirects: [], rewrites: [], headers: [], basePath: "", trailingSlash: "ignore" },
692
+ instrumentation = null,
685
693
  ) {
686
694
  return `import {
687
695
  createActionDispatcher,
696
+ createInstrumentation,
697
+ instrumentRender,
698
+ traceRequestPhase,
688
699
  createDispatcher,
689
700
  createMiddlewareRunner,
690
701
  installRouting,
@@ -698,15 +709,22 @@ import App from ${JSON.stringify(appEntry)};
698
709
  export const routing = ${JSON.stringify(routing)};
699
710
  installRouting(routing);
700
711
  export { routes, handlers, middleware, notFound, errors };
701
- export { beginRequest } from "@uniflowed/router/server";
712
+ ${instrumentation == null ? "" : `import * as hooks from ${JSON.stringify(instrumentation)};`}
713
+ export const { beginRequest } = createInstrumentation(${instrumentation == null ? "" : "hooks"});
702
714
  const renderer = createDocumentRenderer({ App, renderFlight, loadClientModule });
703
- export const render = renderer.render;
715
+ export const render = (url, assets, options = {}) => instrumentRender(
716
+ (onError) => renderer.render(url, assets, { ...options, onError }), options.onError,
717
+ );
704
718
  export const prerender = renderer.prerender;
705
- export const flight = renderer.flight;
719
+ export const flight = (url, options = {}) => instrumentRender(
720
+ (onError) => renderer.flight(url, { ...options, onError }), options.onError,
721
+ );
706
722
  export { shellDocument } from "@uniflowed/router/server";
707
- export const dispatch = createDispatcher({ handlers });
723
+ const dispatchRoute = createDispatcher({ handlers });
724
+ export const dispatch = (request) => traceRequestPhase("route", () => dispatchRoute(request));
708
725
  export const callAction = createActionDispatcher({ actions });
709
- export const runMiddleware = createMiddlewareRunner({ middleware });
726
+ const guard = createMiddlewareRunner({ middleware });
727
+ export const runMiddleware = (request) => traceRequestPhase("middleware", () => guard(request));
710
728
  `;
711
729
  }
712
730
 
@@ -731,7 +749,7 @@ export function devStylesheets(server) {
731
749
  // package's, which Vite serves under `/@fs/` — and the same rule a client
732
750
  // reference's URL follows. A stylesheet with no file is a virtual one.
733
751
  urls.push(
734
- typeof module.file === "string" && module.file !== ""
752
+ typeof module.file === "string" && path.isAbsolute(module.file)
735
753
  ? devUrlOf(root, base, module.file)
736
754
  : `${base.replace(/\/$/, "")}/@id/${id.replace(/\0/g, "__x00__")}`,
737
755
  );
@@ -0,0 +1,23 @@
1
+ // @noflow
2
+
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export function instrumentationFile(root, client = false) {
7
+ const stem = client ? "$instrumentation.client" : "$instrumentation";
8
+ const files = [".js", ".jsx"]
9
+ .map((extension) => path.join(root, stem + extension))
10
+ .filter(existsSync);
11
+ if (files.length > 1)
12
+ throw new Error(`uf: choose one instrumentation module: ${files.join(", ")}`);
13
+ return files[0] ?? null;
14
+ }
15
+
16
+ export function clientInstrumentationSource(file) {
17
+ if (file == null) return "";
18
+ return `import * as instrumentation from ${JSON.stringify(file)};
19
+ import { installClientInstrumentation } from "@uniflowed/router/instrumentation";
20
+ const disposeInstrumentation = await installClientInstrumentation(instrumentation);
21
+ if (import.meta.hot) import.meta.hot.dispose(disposeInstrumentation);
22
+ `;
23
+ }
@@ -0,0 +1,26 @@
1
+ // @noflow
2
+
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+
6
+ /** React Native Web is supplied by the application, including its version. */
7
+ export function nativeWebPlugin(target) {
8
+ let root = process.cwd();
9
+ return {
10
+ name: "uf:react-native-web",
11
+ enforce: "pre",
12
+ configResolved(config) {
13
+ root = config.root;
14
+ },
15
+ resolveId(source) {
16
+ if (target !== "web" || source !== "react-native") return null;
17
+ try {
18
+ return createRequire(path.join(root, "package.json")).resolve("react-native-web");
19
+ } catch {
20
+ throw new Error(
21
+ "A shared React Native page needs the application's react-native-web dependency: run `uf add react-native-web`.",
22
+ );
23
+ }
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,74 @@
1
+ // @noflow
2
+ import { createRequire } from "node:module";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ /** CommonJS hidden behind uf's excluded Flow packages still needs prebundling. */
8
+ export function relayDependencies(root, server = false) {
9
+ const require = createRequire(path.join(root, "package.json"));
10
+ const entries = server
11
+ ? ["relay-runtime", "relay-runtime/experimental", "react-relay/rsc_EXPERIMENTAL.js"]
12
+ : ["relay-runtime", "react-relay", "react-relay/rsc-client_EXPERIMENTAL.js"];
13
+ return entries.filter((entry) => {
14
+ try {
15
+ require.resolve(entry);
16
+ return true;
17
+ } catch (error) {
18
+ if (error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED")
19
+ return false;
20
+ throw error;
21
+ }
22
+ });
23
+ }
24
+
25
+ /** Resolve config from the application, independently of the process's cwd. */
26
+ export async function relayConfig(root) {
27
+ for (const name of ["relay.config.json", "relay.config.js", "relay.config.mjs"]) {
28
+ const filename = path.join(root, name);
29
+ if (!fs.existsSync(filename)) continue;
30
+ const config = name.endsWith(".json")
31
+ ? JSON.parse(fs.readFileSync(filename, "utf8"))
32
+ : (await import(pathToFileURL(filename).href)).default;
33
+ return artifactOptions(root, config);
34
+ }
35
+ const filename = path.join(root, "package.json");
36
+ const config = fs.existsSync(filename)
37
+ ? JSON.parse(fs.readFileSync(filename, "utf8")).relay
38
+ : null;
39
+ return artifactOptions(root, config);
40
+ }
41
+
42
+ function artifactOptions(root, config) {
43
+ if (config?.projects != null) {
44
+ throw new Error("uf's Relay integration currently needs a single-project Relay config.");
45
+ }
46
+ return {
47
+ artifactDirectory:
48
+ config?.artifactDirectory == null ? null : path.resolve(root, config.artifactDirectory),
49
+ eagerEsModules: true,
50
+ jsModuleFormat: "commonjs",
51
+ codegenCommand: "uf run relay",
52
+ };
53
+ }
54
+
55
+ /** Flow has already lowered components/hooks; Relay owns the GraphQL transform. */
56
+ export async function transformRelay(code, filename, map, options) {
57
+ if (
58
+ !code.includes("graphql") ||
59
+ !/["'](?:@uniflowed\/relay|react-relay|relay-runtime)["']/.test(code)
60
+ )
61
+ return null;
62
+ const [{ transformAsync }, { default: relay }] = await Promise.all([
63
+ import("@babel/core"),
64
+ import("babel-plugin-relay"),
65
+ ]);
66
+ return transformAsync(code, {
67
+ filename,
68
+ babelrc: false,
69
+ configFile: false,
70
+ sourceMaps: true,
71
+ inputSourceMap: map ?? undefined,
72
+ plugins: [[relay, options]],
73
+ });
74
+ }
@@ -14,6 +14,8 @@
14
14
  // route table imports every page and layout lazily, so a route is a chunk of
15
15
  // its own and the client only downloads what it navigates to.
16
16
 
17
+ import { clientInstrumentationSource } from "./instrumentation.js";
18
+
17
19
  import { readdirSync, statSync } from "node:fs";
18
20
  import path from "node:path";
19
21
 
@@ -28,6 +30,7 @@ export const RESERVED = Object.freeze({
28
30
  error: "$error",
29
31
  loading: "$loading",
30
32
  route: "$route",
33
+ instrumentation: "$instrumentation",
31
34
  });
32
35
 
33
36
  /**
@@ -381,6 +384,15 @@ export function scanRoutes(appRoot, options = {}) {
381
384
  );
382
385
 
383
386
  refuseUnsupportedTemplateFiles(directory, entries);
387
+ for (const entry of entries) {
388
+ if (entry.name.startsWith("$instrumentation") && !/\.test\.jsx?$/.test(entry.name)) {
389
+ if (depth !== 0 || !/^\$instrumentation(?:\.client)?\.jsx?$/.test(entry.name)) {
390
+ throw new Error(
391
+ `uf: ${path.join(directory, entry.name)} must be $instrumentation.js or $instrumentation.client.js at the router root`,
392
+ );
393
+ }
394
+ }
395
+ }
384
396
 
385
397
  // A `$default.js` answers one question — what a slot renders when the
386
398
  // URL says nothing about it — and this walk is everywhere a slot is not,
@@ -1660,7 +1672,7 @@ export function clientModuleSource(appEntry, options = {}) {
1660
1672
  return `import { ${mount} } from "@uniflowed/router/client";
1661
1673
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
1662
1674
  import App from ${JSON.stringify(appEntry)};
1663
- ${mount}({ App, routes, notFound, errors${strictMode}${navigation}${staleTime}${routing} });
1675
+ ${clientInstrumentationSource(options.instrumentation)}${mount}({ App, routes, notFound, errors${strictMode}${navigation}${staleTime}${routing} });
1664
1676
  `;
1665
1677
  }
1666
1678
 
@@ -1718,9 +1730,12 @@ ${mount}({ App, routes, notFound, errors${strictMode}${navigation}${staleTime}${
1718
1730
  * shorter proof of the paragraph above: the copy the router dispatches and
1719
1731
  * renders with is by construction the copy the host is handed.
1720
1732
  */
1721
- export function serverModuleSource(appEntry, routing = routingRulesOf({})) {
1733
+ export function serverModuleSource(appEntry, routing = routingRulesOf({}), instrumentation = null) {
1722
1734
  return `import {
1723
1735
  createActionDispatcher,
1736
+ createInstrumentation,
1737
+ instrumentRender,
1738
+ traceRequestPhase,
1724
1739
  createDispatcher,
1725
1740
  createMiddlewareRunner,
1726
1741
  createRenderer,
@@ -1731,14 +1746,19 @@ import { actions } from ${JSON.stringify(VIRTUAL.actions)};
1731
1746
  import App from ${JSON.stringify(appEntry)};
1732
1747
  ${routingExportSource(routing)}installRouting(routing);
1733
1748
  export { routes, handlers, middleware, notFound, errors };
1734
- export { beginRequest } from "@uniflowed/router/server";
1749
+ ${instrumentation == null ? "" : `import * as hooks from ${JSON.stringify(instrumentation)};`}
1750
+ export const { beginRequest } = createInstrumentation(${instrumentation == null ? "" : "hooks"});
1735
1751
  const renderer = createRenderer({ App, routes, notFound, errors });
1736
- export const render = renderer.render;
1752
+ export const render = (url, assets, options = {}) => instrumentRender(
1753
+ (onError) => renderer.render(url, assets, { ...options, onError }), options.onError,
1754
+ );
1737
1755
  export const prerender = renderer.prerender;
1738
1756
  export { shellDocument } from "@uniflowed/router/server";
1739
- export const dispatch = createDispatcher({ handlers });
1757
+ const dispatchRoute = createDispatcher({ handlers });
1758
+ export const dispatch = (request) => traceRequestPhase("route", () => dispatchRoute(request));
1740
1759
  export const callAction = createActionDispatcher({ actions });
1741
- export const runMiddleware = createMiddlewareRunner({ middleware });
1760
+ const guard = createMiddlewareRunner({ middleware });
1761
+ export const runMiddleware = (request) => traceRequestPhase("middleware", () => guard(request));
1742
1762
  `;
1743
1763
  }
1744
1764
 
package/internal/serve.js CHANGED
@@ -112,18 +112,19 @@ function deployment() {
112
112
  * a setting nobody can set would be the shape of configurability with none of
113
113
  * the substance.
114
114
  *
115
- * @param {{route?: boolean, fetch?: boolean, store?: string, storeDir?: string} | undefined} declared
115
+ * @param {{route?: boolean, fetch?: boolean, data?: boolean, store?: string, storeDir?: string} | undefined} declared
116
116
  * @param {(options?: object) => object} createCacheStore
117
117
  * @param {{root: string, build: string | null}} where
118
118
  */
119
119
  async function cacheFor(declared, createCacheStore, where) {
120
120
  const route = declared?.route === true;
121
121
  const fetchCache = declared?.fetch === true;
122
- if (!route && !fetchCache) return undefined;
122
+ const data = declared?.data === true;
123
+ if (!route && !fetchCache && !data) return undefined;
123
124
  const provider = await providerFor(declared, where);
124
125
  const store =
125
126
  provider == null ? createCacheStore() : createCacheStore({ provider, build: where.build });
126
- return { store, route, fetch: fetchCache };
127
+ return { store, route, fetch: fetchCache, data };
127
128
  }
128
129
 
129
130
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.40",
3
+ "version": "0.0.0-alpha.42",
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",
@@ -32,12 +32,14 @@
32
32
  "!*.test.js"
33
33
  ],
34
34
  "dependencies": {
35
+ "@babel/core": "^7.29.7",
35
36
  "@mdx-js/rollup": "^3.1.1",
36
37
  "@shikijs/rehype": "^3.23.0",
37
- "@uniflowed/host": "0.0.0-alpha.40",
38
- "@uniflowed/router": "0.0.0-alpha.40",
39
- "@uniflowed/server": "0.0.0-alpha.40",
40
- "@uniflowed/validator": "0.0.0-alpha.40",
38
+ "@uniflowed/host": "0.0.0-alpha.42",
39
+ "@uniflowed/router": "0.0.0-alpha.42",
40
+ "@uniflowed/server": "0.0.0-alpha.42",
41
+ "@uniflowed/validator": "0.0.0-alpha.42",
42
+ "babel-plugin-relay": "^21.0.1",
41
43
  "estree-util-value-to-estree": "^3.5.0",
42
44
  "rehype-slug": "^6.0.0",
43
45
  "remark-frontmatter": "^5.0.0",
@@ -45,12 +47,13 @@
45
47
  "shiki": "^3.23.0",
46
48
  "unist-util-mdx-define": "^1.1.2",
47
49
  "vite": "^8.2.2",
48
- "yaml": "^2.9.1"
50
+ "yaml": "^2.9.1",
51
+ "rolldown": "^1.2.7"
49
52
  },
50
53
  "peerDependencies": {
54
+ "axe-core": ">=4",
51
55
  "react": ">=19",
52
- "react-dom": ">=19",
53
- "axe-core": ">=4"
56
+ "react-dom": ">=19"
54
57
  },
55
58
  "peerDependenciesMeta": {
56
59
  "axe-core": {