@uniflowed/vite 0.4.0 → 0.6.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 +53 -3
- package/index.js +36 -12
- package/internal/flight.js +49 -25
- package/internal/openapi.js +8 -3
- package/internal/routes.js +59 -14
- package/internal/rsc.js +18 -4
- package/package.json +5 -5
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
|
-
|
|
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
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
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(
|
|
590
|
+
return routesModuleSource(table);
|
|
589
591
|
}
|
|
590
592
|
if (flightState != null && isSsr(this, loadOptions)) {
|
|
591
|
-
return routesModuleSource({
|
|
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(
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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();
|
package/internal/flight.js
CHANGED
|
@@ -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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// the
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
|
|
534
|
-
|
|
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
|
|
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
|
|
714
|
-
*
|
|
715
|
-
*
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
764
|
-
|
|
787
|
+
export const runMiddleware = (request) =>
|
|
788
|
+
traceRequestPhase("middleware", () => runMiddlewareInRsc(request));
|
|
765
789
|
`;
|
|
766
790
|
}
|
|
767
791
|
|
package/internal/openapi.js
CHANGED
|
@@ -165,7 +165,7 @@ function openApiPath(routePath) {
|
|
|
165
165
|
.split("/")
|
|
166
166
|
.map((segment) => {
|
|
167
167
|
if (!segment.startsWith(":")) return segment;
|
|
168
|
-
return `{${
|
|
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:
|
|
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
|
package/internal/routes.js
CHANGED
|
@@ -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, -
|
|
625
|
-
: part.startsWith(":")
|
|
626
|
-
? `[
|
|
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,
|
|
655
|
-
* Mirrors `uf_router`'s
|
|
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 (
|
|
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
|
|
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 (
|
|
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,
|
|
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 === "
|
|
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+)
|
|
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
|
-
|
|
463
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.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.
|
|
39
|
-
"@uniflowed/router": "0.
|
|
40
|
-
"@uniflowed/server": "0.
|
|
41
|
-
"@uniflowed/validator": "0.
|
|
38
|
+
"@uniflowed/host": "0.6.0",
|
|
39
|
+
"@uniflowed/router": "0.6.0",
|
|
40
|
+
"@uniflowed/server": "0.6.0",
|
|
41
|
+
"@uniflowed/validator": "0.6.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",
|