@uniflowed/vite 0.5.0 → 0.7.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
@@ -767,6 +767,9 @@ async function build() {
767
767
  await vite.build({
768
768
  ...inline,
769
769
  customLogger: eventLogger("warn"),
770
+ // It bundles the rsc graph's output, and whatever addon that left on disk
771
+ // stays there; see `nativeAddonsStayOnDisk`.
772
+ plugins: [...(inline.plugins ?? []), nativeAddonsStayOnDisk()],
770
773
  build: {
771
774
  ...inline.build,
772
775
  manifest: false,
@@ -2504,6 +2507,39 @@ function nativeAddonGuard() {
2504
2507
  };
2505
2508
  }
2506
2509
 
2510
+ /**
2511
+ * Leave a native addon out of the rsc graph, as an import of the file on disk.
2512
+ *
2513
+ * The rsc graph bundles every dependency (`noExternal: true`, so each resolves
2514
+ * under `react-server`), and since the route handlers and the middleware
2515
+ * moved into it (#1487) that includes whatever a handler imports. A `.node`
2516
+ * file is a shared object, not a module Rolldown can read, so a handler that
2517
+ * reached one failed `uf build` with `"default" is not exported by
2518
+ * "….node"`, where the server build had always left the package to Node.
2519
+ *
2520
+ * External, then, by its absolute path: the output imports the file where it
2521
+ * is, which is what loading the package from `node_modules` did. Used by the
2522
+ * rsc build and by the server build that bundles its output. A build that has
2523
+ * to stand alone (`--compile`, `--adapter`) still refuses it by name, because
2524
+ * it bundles that output too and [`nativeAddonGuard`] sees the import there.
2525
+ */
2526
+ function nativeAddonsStayOnDisk() {
2527
+ return {
2528
+ name: "uf:native-addons-stay-on-disk",
2529
+ enforce: "pre",
2530
+ async resolveId(source, importer, options) {
2531
+ if (!source.endsWith(".node")) return null;
2532
+ const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
2533
+ const id =
2534
+ resolved?.id ??
2535
+ (importer != null && source.startsWith(".")
2536
+ ? path.resolve(path.dirname(importer), source)
2537
+ : source);
2538
+ return { id, external: "absolute" };
2539
+ },
2540
+ };
2541
+ }
2542
+
2507
2543
  /**
2508
2544
  * Say which Node built-ins the Worker being linked reaches and does not have.
2509
2545
  *
@@ -2591,6 +2627,7 @@ async function buildRscGraph(vite, inline, state, { outDir, conditions }) {
2591
2627
  const builder = await vite.createBuilder({
2592
2628
  ...inline,
2593
2629
  customLogger: eventLogger("warn"),
2630
+ plugins: [...(inline.plugins ?? []), nativeAddonsStayOnDisk()],
2594
2631
  environments: { [RSC_ENVIRONMENT]: environment },
2595
2632
  });
2596
2633
  await builder.build(builder.environments[RSC_ENVIRONMENT]);
@@ -2781,6 +2818,16 @@ async function renderingPlan(server, prerender) {
2781
2818
  });
2782
2819
  continue;
2783
2820
  }
2821
+ // A page that declares a schema for its query renders what the query
2822
+ // says, and a prerendered file is one answer for every query: `/search`
2823
+ // written once would be served for `/search?q=anything`. ubugeeei-prod/uf#1362.
2824
+ if (module.searchParams != null) {
2825
+ perRequest.push({
2826
+ path: route.path,
2827
+ why: "its page exports a `searchParams` schema, so what it renders depends on the query",
2828
+ });
2829
+ continue;
2830
+ }
2784
2831
  if (route.params.length === 0) {
2785
2832
  urls.push(route.path);
2786
2833
  continue;
@@ -2834,11 +2881,11 @@ async function renderingPlan(server, prerender) {
2834
2881
  }
2835
2882
 
2836
2883
  function fillParams(routePath, params) {
2837
- return routePath
2884
+ const filled = routePath
2838
2885
  .split("/")
2839
2886
  .map((segment) => {
2840
- if (segment.endsWith("*")) {
2841
- const value = params[segment.slice(1, -1)];
2887
+ if (segment.startsWith(":") && (segment.endsWith("*") || segment.endsWith("*?"))) {
2888
+ const value = params[segment.slice(1, segment.endsWith("?") ? -2 : -1)];
2842
2889
  return Array.isArray(value)
2843
2890
  ? value.map(encodeURIComponent).join("/")
2844
2891
  : encodeURIComponent(String(value ?? ""));
@@ -2848,6 +2895,9 @@ function fillParams(routePath, params) {
2848
2895
  return segment;
2849
2896
  })
2850
2897
  .join("/");
2898
+ // An optional catch-all given no segments is its parent path, which is
2899
+ // `/docs` rather than `/docs/`, and `/` rather than an empty string.
2900
+ return filled.length > 1 && filled.endsWith("/") ? filled.slice(0, -1) : filled || "/";
2851
2901
  }
2852
2902
 
2853
2903
  /**
package/index.js CHANGED
@@ -111,6 +111,7 @@ import {
111
111
  FLIGHT_BROWSER_DEPENDENCIES,
112
112
  FLIGHT_VIRTUAL,
113
113
  INTERCEPTED_FROM_HEADER,
114
+ NOT_FOUND_HEADER,
114
115
  RSC_ENVIRONMENT,
115
116
  builtBridgeSource,
116
117
  builtReferencesSource,
@@ -573,22 +574,30 @@ function flowPlugin({
573
574
  if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
574
575
  if (id === resolved(VIRTUAL.routes)) {
575
576
  const table = scanRoutes(appRoot, { target: routeTarget });
576
- // Under React Server Components the table is split by graph rather than
577
- // filtered. The rsc graph renders routes, so it gets every route and
578
- // boundary and no handler or middleware: those answer a request, and
579
- // importing one here would resolve its dependencies under
580
- // `react-server` for nothing. The ssr graph gets exactly those two,
581
- // because every route it renders reaches it as a payload.
577
+ // Under React Server Components the whole table is the rsc graph's:
578
+ // every route and boundary, and the route handlers and the middleware
579
+ // too. A handler used to be the ssr graph's, so a module it and a page
580
+ // both imported was evaluated once in each, and a `POST` the handler
581
+ // answered wrote to a copy the page never read (ubugeeei-prod/uf#1487).
582
+ // The ssr graph reaches all of it through the bridge and imports none
583
+ // of it itself; see `rscEntrySource`.
582
584
  if (flightState != null && this.environment?.name === RSC_ENVIRONMENT) {
583
585
  // The graph that renders routes is where a boundary has to be a
584
586
  // client reference, so this is where one that is not fails — the
585
587
  // build, and `uf dev`'s table — rather than the first page that
586
588
  // throws in production. See `./internal/error-boundaries.js`.
587
589
  refuseServerErrorBoundaries(table, root);
588
- return routesModuleSource({ ...table, handlers: [], middleware: [] });
590
+ return routesModuleSource(table);
589
591
  }
590
592
  if (flightState != null && isSsr(this, loadOptions)) {
591
- return routesModuleSource({ ...table, routes: [], notFound: [], errors: [] });
593
+ return routesModuleSource({
594
+ ...table,
595
+ routes: [],
596
+ notFound: [],
597
+ errors: [],
598
+ handlers: [],
599
+ middleware: [],
600
+ });
592
601
  }
593
602
  // The server renders every route, so the server's table is the whole
594
603
  // one and is generated with no filter at all. Only the browser's copy
@@ -699,7 +708,12 @@ function flowPlugin({
699
708
  } catch {
700
709
  return null;
701
710
  }
702
- return serverActionSource(id, exported, declaresDefaultExport(source));
711
+ return serverActionSource(
712
+ id,
713
+ exported,
714
+ declaresDefaultExport(source),
715
+ flightState != null && this.environment?.name === RSC_ENVIRONMENT,
716
+ );
703
717
  }
704
718
  }
705
719
  return null;
@@ -920,9 +934,18 @@ function flowPlugin({
920
934
  const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
921
935
  const onRouteFile = (file) => {
922
936
  if (!reserved.test(file) || !file.startsWith(appRoot)) return;
923
- for (const id of [VIRTUAL.routes, VIRTUAL.server, VIRTUAL.client]) {
924
- const module = devServer.moduleGraph.getModuleById(resolved(id));
925
- if (module) devServer.moduleGraph.invalidateModule(module);
937
+ // The rsc graph's table too, where the pages, the route handlers and
938
+ // the middleware of an application React Server Components render
939
+ // live (ubugeeei-prod/uf#1487).
940
+ const graphs = [
941
+ devServer.moduleGraph,
942
+ devServer.environments?.[RSC_ENVIRONMENT]?.moduleGraph,
943
+ ].filter((graph) => graph != null);
944
+ for (const graph of graphs) {
945
+ for (const id of [VIRTUAL.routes, VIRTUAL.server, VIRTUAL.client]) {
946
+ const module = graph.getModuleById(resolved(id));
947
+ if (module) graph.invalidateModule(module);
948
+ }
926
949
  }
927
950
  devServer.ws.send({ type: "full-reload", path: "*" });
928
951
  };
@@ -1139,6 +1162,7 @@ function flowPlugin({
1139
1162
  const answered = await entry.flight(target, {
1140
1163
  onError: (error) => reportRenderError(devServer, target, error),
1141
1164
  interceptedFrom,
1165
+ notFound: asRequest.headers.get(NOT_FOUND_HEADER) === "1",
1142
1166
  });
1143
1167
  if (answered.error != null) reportRenderError(devServer, target, answered.error);
1144
1168
  if (request.method === "HEAD") await answered.stream?.cancel();
@@ -17,15 +17,14 @@
17
17
  // renderer (`@uniflowed/router/rsc`). A module that opens with the use
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
- // * **`ssr`**, Vite's own server environment. It holds the HTML renderer, the
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.
20
+ // * **`ssr`**, Vite's own server environment. It holds the HTML renderer and
21
+ // the *server copy* of every client module, which is what renders a client
22
+ // component into HTML. It reaches the rsc graph through one module, the
23
+ // bridge, for the payload and for everything else that answers a request
24
+ // with the application's own code: the server-action endpoint
25
+ // (ubugeeei-prod/uf#1469), the route handlers and the middleware
26
+ // (ubugeeei-prod/uf#1487). All three are the rsc graph's, so each shares
27
+ // every module instance with the pages that show what it wrote.
29
28
  // * **`client`**, the browser's. Its entry hydrates from the payload the
30
29
  // document carries, and it holds no page, layout or loader — only the
31
30
  // client modules, each an entry of its own, loaded when a payload names it.
@@ -131,6 +130,9 @@ export const FLIGHT_SEGMENT = "__uf.flight";
131
130
  /** The header a browser sends when a Flight payload should render an interception. */
132
131
  export const INTERCEPTED_FROM_HEADER = "uf-intercepted-from";
133
132
 
133
+ /** The header a browser sends, as `1`, for a URL's not-found payload rather than its route. */
134
+ export const NOT_FOUND_HEADER = "uf-not-found";
135
+
134
136
  /** The document a payload path is for, or `null` for any other path. */
135
137
  export function flightDocumentPath(pathname) {
136
138
  const suffix = `/${FLIGHT_SEGMENT}`;
@@ -530,14 +532,28 @@ export function rscEntrySource(routesId, routing = {}, deployment = null, action
530
532
  ? "export const callAction = createActionDispatcher({ actions: [] });"
531
533
  : `import { actions } from ${JSON.stringify(actionsId)};
532
534
  export const callAction = createActionDispatcher({ actions });`;
533
- return `import { createActionDispatcher, createFlightRenderer, installRouting } from "@uniflowed/router/rsc";
534
- import { routes, notFound, errors } from ${JSON.stringify(routesId)};
535
+ // The route handlers and the middleware are built here for the same reason
536
+ // (ubugeeei-prod/uf#1487): a `POST` a handler answers writes to the module
537
+ // the page reads, and a rate limiter a middleware keeps is the one every
538
+ // request is counted against. They resolve their imports under
539
+ // `react-server`, as the pages do; the server-components guide says what that
540
+ // asks of a handler.
541
+ return `import {
542
+ createActionDispatcher,
543
+ createDispatcher,
544
+ createFlightRenderer,
545
+ createMiddlewareRunner,
546
+ installRouting,
547
+ } from "@uniflowed/router/rsc";
548
+ import { routes, notFound, errors, handlers, middleware } from ${JSON.stringify(routesId)};
535
549
  installRouting(${JSON.stringify(settings)});
536
- export { routes, notFound, errors };
550
+ export { routes, notFound, errors, handlers, middleware };
537
551
  export const renderFlight = createFlightRenderer({ routes, notFound, errors, deployment: ${JSON.stringify(
538
552
  deployment ?? null,
539
553
  )} });
540
554
  ${actions}
555
+ export const dispatch = createDispatcher({ handlers });
556
+ export const runMiddleware = createMiddlewareRunner({ middleware });
541
557
  `;
542
558
  }
543
559
 
@@ -563,13 +579,19 @@ export async function renderFlight(url, options) {
563
579
  export async function callAction(request, settings) {
564
580
  return (await load()).callAction(request, settings);
565
581
  }
566
- export const { routes, notFound, errors } = await load();
582
+ export async function dispatch(request) {
583
+ return (await load()).dispatch(request);
584
+ }
585
+ export async function runMiddleware(request) {
586
+ return (await load()).runMiddleware(request);
587
+ }
588
+ export const { routes, notFound, errors, handlers, middleware } = await load();
567
589
  `;
568
590
  }
569
591
 
570
592
  /** `virtual:uf/rsc-bridge` in a build: the rsc build's output, bundled in. */
571
593
  export function builtBridgeSource(rscOutput) {
572
- return `export { renderFlight, callAction, routes, notFound, errors } from ${JSON.stringify(rscOutput)};\n`;
594
+ return `export { renderFlight, callAction, dispatch, runMiddleware, routes, notFound, errors, handlers, middleware } from ${JSON.stringify(rscOutput)};\n`;
573
595
  }
574
596
 
575
597
  /**
@@ -710,9 +732,10 @@ ${clientInstrumentationSource(options.instrumentation)}hydrateFlight({ App${stri
710
732
  * `createDocumentRenderer` from `@uniflowed/router/rsc/ssr`, an entry of its own
711
733
  * for the reason `flightClientSource` gives. It renders the payload the rsc graph
712
734
  * writes rather than the route's modules, and adds `flight` for a browser that
713
- * is navigating. And `routes`, `notFound` and `errors` come through the bridge,
714
- * because the page modules they import are the rsc graph's: the driver reads a
715
- * page's `generateStaticParams` from the graph that renders it.
735
+ * is navigating. And the route table comes through the bridge, because the
736
+ * modules it imports are the rsc graph's: the driver reads a page's
737
+ * `generateStaticParams` from the graph that renders it, and the route handlers
738
+ * and the middleware run there too (see `rscEntrySource`).
716
739
  */
717
740
  export function flightServerSource(
718
741
  appEntry,
@@ -724,18 +747,19 @@ export function flightServerSource(
724
747
  createInstrumentation,
725
748
  instrumentRender,
726
749
  traceRequestPhase,
727
- createDispatcher,
728
- createMiddlewareRunner,
729
750
  installRouting,
730
751
  } from "@uniflowed/router/server";
731
752
  import { createDocumentRenderer } from "@uniflowed/router/rsc/ssr";
732
- import { handlers, middleware } from ${JSON.stringify(routesId)};
733
753
  import {
734
754
  renderFlight,
735
755
  callAction as callActionInRsc,
756
+ dispatch as dispatchInRsc,
757
+ runMiddleware as runMiddlewareInRsc,
736
758
  routes,
737
759
  notFound,
738
760
  errors,
761
+ handlers,
762
+ middleware,
739
763
  } from ${JSON.stringify(FLIGHT_VIRTUAL.bridge)};
740
764
  import { loadClientModule } from ${JSON.stringify(FLIGHT_VIRTUAL.references)};
741
765
  import App from ${JSON.stringify(appEntry)};
@@ -756,12 +780,12 @@ export const flight = (url, options = {}) => instrumentRender(
756
780
  (onError) => renderer.flight(url, { ...options, onError }), options.onError,
757
781
  );
758
782
  export { shellDocument } from "@uniflowed/router/server";
759
- const dispatchRoute = createDispatcher({ handlers });
760
- export const dispatch = (request) => traceRequestPhase("route", () => dispatchRoute(request));
761
- // Built in the rsc graph and reached through the bridge; see \`rscEntrySource\`.
783
+ // The route handlers, the action endpoint and the middleware are built in the
784
+ // rsc graph and reached through the bridge; see \`rscEntrySource\`.
785
+ export const dispatch = (request) => traceRequestPhase("route", () => dispatchInRsc(request));
762
786
  export const callAction = callActionInRsc;
763
- const guard = createMiddlewareRunner({ middleware });
764
- export const runMiddleware = (request) => traceRequestPhase("middleware", () => guard(request));
787
+ export const runMiddleware = (request) =>
788
+ traceRequestPhase("middleware", () => runMiddlewareInRsc(request));
765
789
  `;
766
790
  }
767
791
 
@@ -165,7 +165,7 @@ function openApiPath(routePath) {
165
165
  .split("/")
166
166
  .map((segment) => {
167
167
  if (!segment.startsWith(":")) return segment;
168
- return `{${segment.endsWith("*") ? segment.slice(1, -1) : segment.slice(1)}}`;
168
+ return `{${paramName(segment)}}`;
169
169
  })
170
170
  .join("/");
171
171
  }
@@ -191,11 +191,16 @@ function paramsFromPath(routePath) {
191
191
  .split("/")
192
192
  .filter((segment) => segment.startsWith(":"))
193
193
  .map((segment) => ({
194
- name: segment.endsWith("*") ? segment.slice(1, -1) : segment.slice(1),
195
- catchAll: segment.endsWith("*"),
194
+ name: paramName(segment),
195
+ catchAll: segment.endsWith("*") || segment.endsWith("*?"),
196
196
  }));
197
197
  }
198
198
 
199
+ /** `slug` for `:slug`, `:slug*` (`[...slug]`) and `:slug*?` (`[[...slug]]`). */
200
+ function paramName(segment) {
201
+ return segment.slice(1).replace(/\*\??$/, "");
202
+ }
203
+
199
204
  function operationId(record, method) {
200
205
  const name = `${method.toLowerCase()} ${record.path}`;
201
206
  return name
@@ -593,9 +593,40 @@ export function scanRoutes(appRoot, options = {}) {
593
593
  // intercepting page is only as good as the ordinary page that serves its URL
594
594
  // to everybody the interception does not.
595
595
  refuseInterceptionsWithoutPages(appRoot, routes);
596
+ refuseOptionalCatchAllCollisions(routes);
596
597
  return { routes, handlers, middleware, notFound, errors };
597
598
  }
598
599
 
600
+ /**
601
+ * Refuse a `[[...param]]` page that shares its parent path with a page.
602
+ *
603
+ * An optional catch-all serves the directory it sits in as well as every path
604
+ * below it, so `app/docs/[[...slug]]/$page.js` and `app/docs/$page.js` are two
605
+ * answers to `/docs`. Compared by the path's shape rather than by directory,
606
+ * because a `(group)` or a differently named parameter does not change which
607
+ * URLs a page serves. Mirrors `uf_router`'s
608
+ * `refuse_optional_catch_all_collisions`.
609
+ */
610
+ function refuseOptionalCatchAllCollisions(routes) {
611
+ const shape = (routePath) => routePath.replace(/:[^/*?]+/g, ":");
612
+ for (const route of routes) {
613
+ const last = route.params.at(-1);
614
+ if (last == null || !route.path.endsWith(`:${last.name}*?`)) continue;
615
+ const parent = route.path.slice(0, route.path.lastIndexOf("/")) || "/";
616
+ const other = routes.find((candidate) => shape(candidate.path) === shape(parent));
617
+ if (other != null) {
618
+ throw new Error(
619
+ `${route.page}: \`[[...${last.name}]]\` is an optional catch-all, so it serves ` +
620
+ `\`${parent}\` itself as well as every path below it, and \`${other.page}\` serves ` +
621
+ `\`${parent}\` too — one URL with two pages, and nothing in the URL says which. ` +
622
+ `Remove \`${other.page}\` and render \`${parent}\` here, where \`${last.name}\` is ` +
623
+ `an empty list, or make it \`[...${last.name}]\`, which leaves \`${parent}\` to ` +
624
+ `\`${other.page}\`.`,
625
+ );
626
+ }
627
+ }
628
+ }
629
+
599
630
  /**
600
631
  * Refuse an intercepting route whose URL no page serves.
601
632
  *
@@ -620,11 +651,13 @@ function refuseInterceptionsWithoutPages(appRoot, routes) {
620
651
  .split("/")
621
652
  .filter((part) => part !== "")
622
653
  .map((part) =>
623
- part.startsWith(":") && part.endsWith("*")
624
- ? `[...${part.slice(1, -1)}]`
625
- : part.startsWith(":")
626
- ? `[${part.slice(1)}]`
627
- : part,
654
+ part.startsWith(":") && part.endsWith("*?")
655
+ ? `[[...${part.slice(1, -2)}]]`
656
+ : part.startsWith(":") && part.endsWith("*")
657
+ ? `[...${part.slice(1, -1)}]`
658
+ : part.startsWith(":")
659
+ ? `[${part.slice(1)}]`
660
+ : part,
628
661
  );
629
662
  const ordinary = path.join(appRoot, ...directories, `${RESERVED.page}.js`);
630
663
  throw new Error(
@@ -651,18 +684,23 @@ function refuseInterceptionsWithoutPages(appRoot, routes) {
651
684
  * Whether every URL the route path `intercepted` matches is one `ordinary`
652
685
  * serves: segment by segment, the way the runtime's matcher reads both. A
653
686
  * static segment serves only itself, a parameter any one segment but not a
654
- * catch-all's many, and a catch-all whatever is left as long as something is.
655
- * Mirrors `uf_router`'s `serves_every_url_of`.
687
+ * catch-all's many, a catch-all whatever is left as long as something is, and
688
+ * an optional catch-all whatever is left. Mirrors `uf_router`'s
689
+ * `serves_every_url_of`.
656
690
  */
657
691
  function servesEveryUrlOf(ordinary, intercepted) {
658
692
  const theirs = ordinary.split("/").filter((part) => part !== "");
659
693
  const ours = intercepted.split("/").filter((part) => part !== "");
694
+ const optional = (part) => part.startsWith(":") && part.endsWith("*?");
660
695
  for (let index = 0; index < theirs.length; index += 1) {
661
696
  const segment = theirs[index];
662
- if (segment.startsWith(":") && segment.endsWith("*")) return ours.length > index;
697
+ if (optional(segment)) return true;
698
+ if (segment.startsWith(":") && segment.endsWith("*")) {
699
+ return ours.length > index && !optional(ours[index]);
700
+ }
663
701
  const other = ours[index];
664
702
  if (other === undefined) return false;
665
- const otherIsCatchAll = other.startsWith(":") && other.endsWith("*");
703
+ const otherIsCatchAll = other.startsWith(":") && (other.endsWith("*") || optional(other));
666
704
  const serves = segment.startsWith(":")
667
705
  ? !otherIsCatchAll
668
706
  : !other.startsWith(":") && other === segment;
@@ -965,7 +1003,7 @@ function unsupportedSlotBoundaryRole(fileName) {
965
1003
  /**
966
1004
  * What one directory name means to the route path.
967
1005
  *
968
- * Mirrors `uf_router::classify_route_segment`, which is the same six answers
1006
+ * Mirrors `uf_router::classify_route_segment`, which is the same seven answers
969
1007
  * in the same order. The order is load-bearing in one place: an interception
970
1008
  * marker is a `(…)` *prefix* with a route after it, and a `(group)` is a
971
1009
  * segment that ends in `)`, so the interception test has to come first or
@@ -975,6 +1013,7 @@ function unsupportedSlotBoundaryRole(fileName) {
975
1013
  * @returns {{kind: "group"}
976
1014
  * | {kind: "param", name: string}
977
1015
  * | {kind: "catchAll", name: string}
1016
+ * | {kind: "optionalCatchAll", name: string}
978
1017
  * | {kind: "literal", name: string}
979
1018
  * | {kind: "slot", name: string}
980
1019
  * | {kind: "interception", marker: string, route: string}}
@@ -984,6 +1023,9 @@ export function classifyRouteSegment(segment) {
984
1023
  const intercepted = interceptionMarker(segment);
985
1024
  if (intercepted != null) return { kind: "interception", ...intercepted };
986
1025
  if (segment.startsWith("(") && segment.endsWith(")")) return { kind: "group" };
1026
+ if (segment.startsWith("[[...") && segment.endsWith("]]")) {
1027
+ return { kind: "optionalCatchAll", name: segment.slice(5, -2) };
1028
+ }
987
1029
  if (segment.startsWith("[...") && segment.endsWith("]")) {
988
1030
  return { kind: "catchAll", name: segment.slice(4, -1) };
989
1031
  }
@@ -1046,7 +1088,7 @@ function readInterception(classified) {
1046
1088
  const climb = interceptionClimb(classified.marker);
1047
1089
  const route = classifyRouteSegment(classified.route);
1048
1090
  if (climb == null) return null;
1049
- if (route.kind !== "literal" && route.kind !== "param" && route.kind !== "catchAll") {
1091
+ if (!["literal", "param", "catchAll", "optionalCatchAll"].includes(route.kind)) {
1050
1092
  return null;
1051
1093
  }
1052
1094
  return { climb, route };
@@ -1139,7 +1181,8 @@ function climbReason(segment, depth) {
1139
1181
  * Turn directory segments into a route path and its parameters.
1140
1182
  *
1141
1183
  * `(group)` segments organise files without appearing in the URL, `[name]`
1142
- * captures one segment, and `[...name]` captures the rest of the path. A
1184
+ * captures one segment, `[...name]` captures the rest of the path, and
1185
+ * `[[...name]]` captures the rest of it, which may be none. A
1143
1186
  * `@slot` contributes nothing either — it is a named place a route renders
1144
1187
  * into, matched against the URL of the segment that declares it — so a slot's
1145
1188
  * pages are matched against ordinary paths and add none of their own.
@@ -1174,7 +1217,9 @@ export function routeFromSegments(segments) {
1174
1217
  out = read.climb === "root" ? [] : out.slice(0, out.length - read.climb);
1175
1218
  named = read.route;
1176
1219
  }
1177
- if (named.kind === "catchAll") {
1220
+ if (named.kind === "optionalCatchAll") {
1221
+ out.push({ spelling: `:${named.name}*?`, param: { name: named.name, catchAll: true } });
1222
+ } else if (named.kind === "catchAll") {
1178
1223
  out.push({ spelling: `:${named.name}*`, param: { name: named.name, catchAll: true } });
1179
1224
  } else if (named.kind === "param") {
1180
1225
  out.push({ spelling: `:${named.name}`, param: { name: named.name, catchAll: false } });
@@ -1184,7 +1229,7 @@ export function routeFromSegments(segments) {
1184
1229
  }
1185
1230
  const routePath = out.length === 0 ? "/" : `/${out.map((entry) => entry.spelling).join("/")}`;
1186
1231
  const params = out.flatMap((entry) => (entry.param == null ? [] : [entry.param]));
1187
- return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
1232
+ return { path: routePath, pattern: routePath.replace(/:(\w+)\*\??/g, "*$1"), params };
1188
1233
  }
1189
1234
 
1190
1235
  /**
package/internal/rsc.js CHANGED
@@ -446,22 +446,36 @@ export function declaresDefaultExport(source) {
446
446
  * the endpoint, a route handler and a server component call exactly what they
447
447
  * called before. See ubugeeei-prod/uf#1358.
448
448
  *
449
+ * In the rsc graph (`flight`) each one is also registered with React as a
450
+ * server reference under its id (`registerServerFunction` in
451
+ * `@uniflowed/router/rsc`), so a Server Component can pass it to a Client
452
+ * Component as a prop. An export with no manifest row is not callable, is not
453
+ * registered, and Flight still refuses to send it. See ubugeeei-prod/uf#1359.
454
+ *
449
455
  * @param {string} file absolute path of the module
450
456
  * @param {Array<{id: string, module: string, export: string}>} actions its callable exports
451
457
  * @param {boolean} hasDefault whether the file declares a default export
458
+ * @param {boolean} [flight] whether this is the rsc graph
452
459
  */
453
- export function serverActionSource(file, actions, hasDefault) {
460
+ export function serverActionSource(file, actions, hasDefault, flight = false) {
454
461
  const impl = JSON.stringify(`${file}${SERVER_ACTION_IMPL_QUERY}`);
455
462
  const lines = [
456
463
  'import { registerServerAction } from "@uniflowed/router/action";',
464
+ ...(flight ? ['import { registerServerFunction } from "@uniflowed/router/rsc";'] : []),
457
465
  `import * as impl from ${impl};`,
458
466
  `export * from ${impl};`,
459
467
  ];
460
468
  if (hasDefault) lines.push(`export { default } from ${impl};`);
461
469
  for (const action of actions) {
462
- lines.push(
463
- `registerServerAction(impl[${JSON.stringify(action.export)}], ${JSON.stringify(action.id)});`,
464
- );
470
+ const binding = `impl[${JSON.stringify(action.export)}]`;
471
+ lines.push(`registerServerAction(${binding}, ${JSON.stringify(action.id)});`);
472
+ if (flight) {
473
+ lines.push(
474
+ `registerServerFunction(${binding}, ${JSON.stringify(action.id)}, ${JSON.stringify(
475
+ `${action.module}#${action.export}`,
476
+ )});`,
477
+ );
478
+ }
465
479
  }
466
480
  return `${lines.join("\n")}\n`;
467
481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.5.0",
3
+ "version": "0.7.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.5.0",
39
- "@uniflowed/router": "0.5.0",
40
- "@uniflowed/server": "0.5.0",
41
- "@uniflowed/validator": "0.5.0",
38
+ "@uniflowed/host": "0.7.0",
39
+ "@uniflowed/router": "0.7.0",
40
+ "@uniflowed/server": "0.7.0",
41
+ "@uniflowed/validator": "0.7.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",