@reckona/mreact-router 0.0.141 → 0.0.143
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/README.md +4 -2
- package/dist/actions.d.ts +1 -1
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +28 -8
- package/dist/actions.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +31 -12
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +470 -12
- package/dist/build.js.map +1 -1
- package/dist/cli-options.js +1 -1
- package/dist/cli-options.js.map +1 -1
- package/dist/client-route-inference.d.ts +1 -1
- package/dist/client-route-inference.d.ts.map +1 -1
- package/dist/client-route-inference.js +1 -1
- package/dist/client-route-inference.js.map +1 -1
- package/dist/client.d.ts +8 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +16 -0
- package/dist/client.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/import-policy.js +7 -0
- package/dist/import-policy.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata.d.ts +1 -0
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +288 -1
- package/dist/metadata.js.map +1 -1
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +13 -0
- package/dist/middleware.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +41 -9
- package/dist/render.js.map +1 -1
- package/dist/typed-routes.d.ts +24 -0
- package/dist/typed-routes.d.ts.map +1 -0
- package/dist/typed-routes.js +60 -0
- package/dist/typed-routes.js.map +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +22 -1
- package/dist/vite.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +39 -12
- package/src/adapters/cloudflare.ts +48 -12
- package/src/build.ts +491 -11
- package/src/cli-options.ts +1 -1
- package/src/client-route-inference.ts +1 -0
- package/src/client.ts +28 -1
- package/src/config.ts +1 -1
- package/src/import-policy.ts +8 -0
- package/src/index.ts +9 -0
- package/src/metadata.ts +388 -1
- package/src/middleware.ts +22 -0
- package/src/render.ts +58 -7
- package/src/typed-routes.ts +121 -0
- package/src/vite.ts +25 -0
package/src/build.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
detectClientNavigationHint,
|
|
32
32
|
formatClientRouteInferenceDiagnostic,
|
|
33
33
|
inferClientRouteModule,
|
|
34
|
+
navigationRuntimeLinkDisabledDiagnostic,
|
|
34
35
|
resolveNavigationRuntime,
|
|
35
36
|
type ClientRouteManifestEntry,
|
|
36
37
|
type ClientRouteInferenceCache,
|
|
@@ -89,6 +90,11 @@ import { sourceModuleCandidates } from "./source-modules.js";
|
|
|
89
90
|
import { collectBuildInferredServerActions } from "./server-action-inference.js";
|
|
90
91
|
import { viteDefineCacheKey, vitePluginsCacheKey } from "./vite-plugin-cache-key.js";
|
|
91
92
|
import { workspacePackageFile } from "./workspace-packages.js";
|
|
93
|
+
import {
|
|
94
|
+
parseRouteMiddlewareControl,
|
|
95
|
+
parseStaticMiddlewareConfig,
|
|
96
|
+
validateRouteMiddlewareControl,
|
|
97
|
+
} from "./middleware.js";
|
|
92
98
|
import type { PluginOption, UserConfig } from "vite";
|
|
93
99
|
|
|
94
100
|
const nativeEscapeTransform = {
|
|
@@ -109,6 +115,46 @@ export interface BuildAppOptions extends AppRouterProjectOptions {
|
|
|
109
115
|
viteConfig?: Pick<UserConfig, "define" | "plugins"> | undefined;
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
async function validateBuildMiddlewareControls(
|
|
119
|
+
appDir: string,
|
|
120
|
+
routes: readonly AppRoute[],
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const availableIds = await collectBuildMiddlewareIds(appDir);
|
|
123
|
+
|
|
124
|
+
for (const route of routes) {
|
|
125
|
+
if (route.kind !== "page") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
validateRouteMiddlewareControl({
|
|
130
|
+
availableIds,
|
|
131
|
+
control: parseRouteMiddlewareControl(await readFile(route.file, "utf8")),
|
|
132
|
+
routePath: route.path,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function collectBuildMiddlewareIds(appDir: string): Promise<ReadonlySet<string>> {
|
|
138
|
+
const ids = new Set<string>();
|
|
139
|
+
|
|
140
|
+
for (const file of [join(appDir, "middleware.ts"), join(appDir, "middleware.mreact.ts")]) {
|
|
141
|
+
let code: string;
|
|
142
|
+
try {
|
|
143
|
+
code = await readFile(file, "utf8");
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const id = parseStaticMiddlewareConfig(code).id;
|
|
149
|
+
|
|
150
|
+
if (id !== undefined) {
|
|
151
|
+
ids.add(id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return ids;
|
|
156
|
+
}
|
|
157
|
+
|
|
112
158
|
export type BuildAppPhase =
|
|
113
159
|
| "scan"
|
|
114
160
|
| "collectFiles"
|
|
@@ -283,6 +329,7 @@ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult
|
|
|
283
329
|
: await timeBuildPhase(timingSink, "scan", () =>
|
|
284
330
|
scanAppRoutes({ appDir: project.routesDir }),
|
|
285
331
|
);
|
|
332
|
+
await validateBuildMiddlewareControls(project.routesDir, routes);
|
|
286
333
|
const viteDefine = options.viteConfig?.define;
|
|
287
334
|
const vitePlugins = options.viteConfig?.plugins;
|
|
288
335
|
const files =
|
|
@@ -608,6 +655,7 @@ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult
|
|
|
608
655
|
const writeManifestFiles = async () => {
|
|
609
656
|
await Promise.all([
|
|
610
657
|
writeFile(join(serverDir, "manifest.json"), JSON.stringify(serverManifest, null, 2)),
|
|
658
|
+
writeFile(join(options.outDir, "routes.d.ts"), typedRoutesDeclaration(routes)),
|
|
611
659
|
writeFile(
|
|
612
660
|
join(serverDir, "import-policy.json"),
|
|
613
661
|
JSON.stringify(generatedImportPolicy, null, 2),
|
|
@@ -660,6 +708,38 @@ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult
|
|
|
660
708
|
return { routes };
|
|
661
709
|
}
|
|
662
710
|
|
|
711
|
+
function typedRoutesDeclaration(routes: readonly AppRoute[]): string {
|
|
712
|
+
const routePaths = Array.from(
|
|
713
|
+
new Set(
|
|
714
|
+
routes
|
|
715
|
+
.filter((route) => route.kind === "page" || route.kind === "server")
|
|
716
|
+
.map((route) => route.path),
|
|
717
|
+
),
|
|
718
|
+
).sort((left, right) => {
|
|
719
|
+
if (left === "/") {
|
|
720
|
+
return -1;
|
|
721
|
+
}
|
|
722
|
+
if (right === "/") {
|
|
723
|
+
return 1;
|
|
724
|
+
}
|
|
725
|
+
return left.localeCompare(right);
|
|
726
|
+
});
|
|
727
|
+
const routeUnion = routePaths.map((routePath) => JSON.stringify(routePath)).join(" | ");
|
|
728
|
+
const routesObject = routePaths
|
|
729
|
+
.map((routePath) => ` readonly ${JSON.stringify(routePath)}: AppRouteHref<${JSON.stringify(routePath)}>;`)
|
|
730
|
+
.join("\n");
|
|
731
|
+
|
|
732
|
+
return [
|
|
733
|
+
`import type { AppRouteHref } from "@reckona/mreact-router";`,
|
|
734
|
+
``,
|
|
735
|
+
`export type AppRoutePath = ${routeUnion === "" ? "never" : routeUnion};`,
|
|
736
|
+
`export declare const routes: {`,
|
|
737
|
+
routesObject,
|
|
738
|
+
`};`,
|
|
739
|
+
``,
|
|
740
|
+
].join("\n");
|
|
741
|
+
}
|
|
742
|
+
|
|
663
743
|
async function timeBuildPhase<T>(
|
|
664
744
|
sink: (timing: BuildAppPhaseTiming) => void,
|
|
665
745
|
phase: BuildAppPhase,
|
|
@@ -2790,6 +2870,8 @@ function cloudflarePageRouteFacadeModuleSource(componentImport: string): string
|
|
|
2790
2870
|
return `import * as componentModule from ${JSON.stringify(componentImport)};
|
|
2791
2871
|
|
|
2792
2872
|
const componentSlots = readComponentModuleExport(componentModule, "slots");
|
|
2873
|
+
const componentGenerateMetadata = readComponentModuleExport(componentModule, "generateMetadata");
|
|
2874
|
+
const componentMetadata = readComponentModuleExport(componentModule, "metadata");
|
|
2793
2875
|
|
|
2794
2876
|
export function App(props) {
|
|
2795
2877
|
return renderCloudflareRouteComponent(props);
|
|
@@ -2801,6 +2883,9 @@ export function CloudflareRouteComponent(props) {
|
|
|
2801
2883
|
return renderCloudflareRouteComponent(props);
|
|
2802
2884
|
}
|
|
2803
2885
|
export const slots = componentSlots === undefined ? undefined : { ...componentSlots };
|
|
2886
|
+
export const generateMetadata =
|
|
2887
|
+
typeof componentGenerateMetadata === "function" ? componentGenerateMetadata : undefined;
|
|
2888
|
+
export const metadata = componentMetadata;
|
|
2804
2889
|
|
|
2805
2890
|
function renderCloudflareRouteComponent(props) {
|
|
2806
2891
|
const routeComponent = resolveCloudflareRouteComponent();
|
|
@@ -3192,9 +3277,9 @@ async function renderCloudflareStringRoute(props) {
|
|
|
3192
3277
|
}
|
|
3193
3278
|
html = injectCloudflareHead(html, metadata, cloudflareRouteHeadTags(props.clientManifest, props.route.path));
|
|
3194
3279
|
return new Response(html, {
|
|
3195
|
-
headers: {
|
|
3280
|
+
headers: cloudflareMetadataHeaders(metadata, props.request, {
|
|
3196
3281
|
"content-type": "text/html; charset=utf-8"
|
|
3197
|
-
}
|
|
3282
|
+
})
|
|
3198
3283
|
});
|
|
3199
3284
|
}
|
|
3200
3285
|
|
|
@@ -3376,9 +3461,9 @@ async function renderCloudflareStringRoute(props) {
|
|
|
3376
3461
|
}
|
|
3377
3462
|
html = injectCloudflareHead(html, metadata, cloudflareRouteHeadTags(props.clientManifest, props.route.path));
|
|
3378
3463
|
return new Response(html, {
|
|
3379
|
-
headers: {
|
|
3464
|
+
headers: cloudflareMetadataHeaders(metadata, props.request, {
|
|
3380
3465
|
"content-type": "text/html; charset=utf-8"
|
|
3381
|
-
}
|
|
3466
|
+
})
|
|
3382
3467
|
});
|
|
3383
3468
|
}
|
|
3384
3469
|
|
|
@@ -3471,11 +3556,11 @@ export const slots = pageModule.slots;
|
|
|
3471
3556
|
export const App = renderCloudflareStreamRoute;
|
|
3472
3557
|
export default renderCloudflareStreamRoute;
|
|
3473
3558
|
|
|
3474
|
-
function renderCloudflareStreamRoute(props) {
|
|
3559
|
+
async function renderCloudflareStreamRoute(props) {
|
|
3560
|
+
const metadata = await resolveRouteMetadata([...shells.map((shell) => shell.module), pageModule], props);
|
|
3475
3561
|
const body = renderToReadableStream(async ($sink) => {
|
|
3476
3562
|
const slotHtml = await renderRouteSlots(pageModule.slots, props);
|
|
3477
3563
|
const layoutShells = await renderLayoutShells(shells, props, slotHtml);
|
|
3478
|
-
const metadata = await resolveRouteMetadata([...shells.map((shell) => shell.module), pageModule], props);
|
|
3479
3564
|
const routeHeadTags = cloudflareRouteHeadTags(props.clientManifest, props.route.path);
|
|
3480
3565
|
$sink.append("<!DOCTYPE html>");
|
|
3481
3566
|
if (layoutShells.length === 0) {
|
|
@@ -3495,10 +3580,10 @@ function renderCloudflareStreamRoute(props) {
|
|
|
3495
3580
|
}
|
|
3496
3581
|
});
|
|
3497
3582
|
return new Response(body, {
|
|
3498
|
-
headers: {
|
|
3583
|
+
headers: cloudflareMetadataHeaders(metadata, props.request, {
|
|
3499
3584
|
"content-type": "text/html; charset=utf-8",
|
|
3500
3585
|
"x-mreact-stream": "1"
|
|
3501
|
-
}
|
|
3586
|
+
})
|
|
3502
3587
|
});
|
|
3503
3588
|
}
|
|
3504
3589
|
|
|
@@ -3673,16 +3758,401 @@ async function resolveRouteMetadata(modules, props) {
|
|
|
3673
3758
|
request: props.request
|
|
3674
3759
|
};
|
|
3675
3760
|
for (const module of modules) {
|
|
3676
|
-
let next = module.metadata;
|
|
3761
|
+
let next = validateRouteMetadata(module.metadata);
|
|
3677
3762
|
if (typeof module.generateMetadata === "function") {
|
|
3678
|
-
const generated = await module.generateMetadata(context);
|
|
3763
|
+
const generated = validateRouteMetadata(await module.generateMetadata(context), "generateMetadata");
|
|
3679
3764
|
next = mergeRouteMetadata([next, generated].filter(Boolean));
|
|
3680
3765
|
}
|
|
3681
3766
|
if (next !== undefined) {
|
|
3682
3767
|
metadata.push(next);
|
|
3683
3768
|
}
|
|
3684
3769
|
}
|
|
3685
|
-
return mergeRouteMetadata(metadata);
|
|
3770
|
+
return validateRouteMetadata(mergeRouteMetadata(metadata));
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
function validateRouteMetadata(metadata, path = "metadata") {
|
|
3774
|
+
if (metadata === undefined) {
|
|
3775
|
+
return undefined;
|
|
3776
|
+
}
|
|
3777
|
+
assertMetadataObject(metadata, path);
|
|
3778
|
+
validateOptionalMetadataObject(metadata.alternates, \`\${path}.alternates\`, { canonical: validateMetadataScalar });
|
|
3779
|
+
validateOptionalCspMetadata(metadata.csp, \`\${path}.csp\`);
|
|
3780
|
+
validateOptionalMetadataScalar(metadata.description, \`\${path}.description\`);
|
|
3781
|
+
validateOptionalHeadMetadata(metadata.head, \`\${path}.head\`);
|
|
3782
|
+
validateOptionalMetadataObject(metadata.icons, \`\${path}.icons\`, {
|
|
3783
|
+
apple: validateMetadataScalar,
|
|
3784
|
+
icon: validateMetadataScalar,
|
|
3785
|
+
});
|
|
3786
|
+
validateOptionalOpenGraphMetadata(metadata.openGraph, \`\${path}.openGraph\`);
|
|
3787
|
+
validateOptionalMetadataScalar(metadata.lang, \`\${path}.lang\`);
|
|
3788
|
+
validateOptionalRobotsMetadata(metadata.robots, \`\${path}.robots\`);
|
|
3789
|
+
validateOptionalSecurityMetadata(metadata.security, \`\${path}.security\`);
|
|
3790
|
+
validateOptionalThemeColorMetadata(metadata.themeColor, \`\${path}.themeColor\`);
|
|
3791
|
+
validateOptionalMetadataScalar(metadata.title, \`\${path}.title\`);
|
|
3792
|
+
validateOptionalViewportMetadata(metadata.viewport, \`\${path}.viewport\`);
|
|
3793
|
+
validateUnknownJsonMetadataFields(metadata, path, new Set(["alternates", "csp", "description", "head", "icons", "lang", "openGraph", "robots", "security", "themeColor", "title", "viewport"]));
|
|
3794
|
+
return metadata;
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
function validateOptionalMetadataScalar(value, path) {
|
|
3798
|
+
if (value !== undefined) {
|
|
3799
|
+
validateMetadataScalar(value, path);
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
function validateMetadataScalar(value, path) {
|
|
3804
|
+
if (!isMetadataScalar(value)) {
|
|
3805
|
+
throw new Error(\`Invalid metadata field \${path}: expected string, number, or boolean.\`);
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
function isMetadataScalar(value) {
|
|
3810
|
+
return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean";
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
function validateOptionalMetadataObject(value, path, validators) {
|
|
3814
|
+
if (value === undefined) {
|
|
3815
|
+
return;
|
|
3816
|
+
}
|
|
3817
|
+
assertMetadataObject(value, path);
|
|
3818
|
+
for (const [key, validator] of Object.entries(validators)) {
|
|
3819
|
+
if (value[key] !== undefined) {
|
|
3820
|
+
validator(value[key], \`\${path}.\${key}\`);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
validateUnknownJsonMetadataFields(value, path, new Set(Object.keys(validators)));
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
function validateOptionalCspMetadata(value, path) {
|
|
3827
|
+
if (value === undefined) {
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
assertMetadataObject(value, path);
|
|
3831
|
+
if (value.disable !== undefined && typeof value.disable !== "boolean") {
|
|
3832
|
+
throw new Error(\`Invalid metadata field \${path}.disable: expected boolean.\`);
|
|
3833
|
+
}
|
|
3834
|
+
if (value.nonce !== undefined && typeof value.nonce !== "string") {
|
|
3835
|
+
throw new Error(\`Invalid metadata field \${path}.nonce: expected string.\`);
|
|
3836
|
+
}
|
|
3837
|
+
validateOptionalDirectiveMap(value.directives, \`\${path}.directives\`);
|
|
3838
|
+
validateOptionalDirectiveMap(value.replace, \`\${path}.replace\`);
|
|
3839
|
+
if (value.remove !== undefined) {
|
|
3840
|
+
validateStringArray(value.remove, \`\${path}.remove\`);
|
|
3841
|
+
}
|
|
3842
|
+
validateUnknownJsonMetadataFields(value, path, new Set(["directives", "disable", "nonce", "remove", "replace"]));
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
function validateOptionalDirectiveMap(value, path) {
|
|
3846
|
+
if (value === undefined) {
|
|
3847
|
+
return;
|
|
3848
|
+
}
|
|
3849
|
+
assertMetadataObject(value, path);
|
|
3850
|
+
for (const [name, directive] of Object.entries(value)) {
|
|
3851
|
+
if (typeof directive !== "string") {
|
|
3852
|
+
validateStringArray(directive, \`\${path}.\${name}\`);
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
function validateStringArray(value, path) {
|
|
3858
|
+
if (!Array.isArray(value)) {
|
|
3859
|
+
throw new Error(\`Invalid metadata field \${path}: expected string or string array.\`);
|
|
3860
|
+
}
|
|
3861
|
+
value.forEach((entry, index) => {
|
|
3862
|
+
if (typeof entry !== "string") {
|
|
3863
|
+
throw new Error(\`Invalid metadata field \${path}.\${index}: expected string.\`);
|
|
3864
|
+
}
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
function validateOptionalHeadMetadata(value, path) {
|
|
3869
|
+
if (value === undefined) {
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
if (!Array.isArray(value)) {
|
|
3873
|
+
throw new Error(\`Invalid metadata field \${path}: expected array.\`);
|
|
3874
|
+
}
|
|
3875
|
+
value.forEach((descriptor, index) => {
|
|
3876
|
+
const descriptorPath = \`\${path}.\${index}\`;
|
|
3877
|
+
assertMetadataObject(descriptor, descriptorPath);
|
|
3878
|
+
if (!["base", "link", "meta", "script", "style"].includes(String(descriptor.tag))) {
|
|
3879
|
+
throw new Error(\`Invalid metadata field \${descriptorPath}.tag: expected supported head tag.\`);
|
|
3880
|
+
}
|
|
3881
|
+
if (descriptor.content !== undefined && typeof descriptor.content !== "string") {
|
|
3882
|
+
throw new Error(\`Invalid metadata field \${descriptorPath}.content: expected string.\`);
|
|
3883
|
+
}
|
|
3884
|
+
if (descriptor.nonce !== undefined && typeof descriptor.nonce !== "boolean" && typeof descriptor.nonce !== "string") {
|
|
3885
|
+
throw new Error(\`Invalid metadata field \${descriptorPath}.nonce: expected string or boolean.\`);
|
|
3886
|
+
}
|
|
3887
|
+
if (descriptor.attrs !== undefined) {
|
|
3888
|
+
assertMetadataObject(descriptor.attrs, \`\${descriptorPath}.attrs\`);
|
|
3889
|
+
for (const [name, attr] of Object.entries(descriptor.attrs)) {
|
|
3890
|
+
validateHeadAttribute(name, attr, \`\${descriptorPath}.attrs.\${name}\`);
|
|
3891
|
+
if (attr !== undefined && typeof attr !== "boolean" && typeof attr !== "number" && typeof attr !== "string") {
|
|
3892
|
+
throw new Error(\`Invalid metadata field \${descriptorPath}.attrs.\${name}: expected string, number, boolean, or undefined.\`);
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
function validateHeadAttribute(name, value, path) {
|
|
3900
|
+
if (!isSafeHeadAttributeName(name)) {
|
|
3901
|
+
throw new Error(\`Invalid metadata field \${path}: expected safe HTML attribute name.\`);
|
|
3902
|
+
}
|
|
3903
|
+
const canonicalName = name.toLowerCase();
|
|
3904
|
+
if (canonicalName.startsWith("on") || canonicalName === "srcdoc") {
|
|
3905
|
+
throw new Error(\`Invalid metadata field \${path}: event and dangerous attributes are not allowed.\`);
|
|
3906
|
+
}
|
|
3907
|
+
if (typeof value === "string" && isUnsafeUrlAttribute(canonicalName, value)) {
|
|
3908
|
+
throw new Error(\`Invalid metadata field \${path}: unsafe URL value.\`);
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
function isSafeHeadAttributeName(name) {
|
|
3913
|
+
if (name.length === 0) {
|
|
3914
|
+
return false;
|
|
3915
|
+
}
|
|
3916
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
3917
|
+
const code = name.charCodeAt(index);
|
|
3918
|
+
if (code <= 0x20 || code === 0x22 || code === 0x27 || code === 0x2f || code === 0x3c || code === 0x3d || code === 0x3e || code === 0x60 || code === 0x7f) {
|
|
3919
|
+
return false;
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
return true;
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
function isUnsafeUrlAttribute(name, value) {
|
|
3926
|
+
if (name === "srcset" || name === "imagesrcset") {
|
|
3927
|
+
const canonical = canonicalizeUrlForSchemeCheck(value);
|
|
3928
|
+
for (const candidate of canonical.split(",")) {
|
|
3929
|
+
const url = candidate.trim().split(/\\s+/)[0] ?? "";
|
|
3930
|
+
if (url !== "" && isUnsafeUrlValueForName("src", url)) {
|
|
3931
|
+
return true;
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
return false;
|
|
3935
|
+
}
|
|
3936
|
+
if (name !== "href" && name !== "src" && name !== "action" && name !== "formaction" && name !== "xlink:href" && name !== "ping" && name !== "poster" && name !== "background" && name !== "manifest") {
|
|
3937
|
+
return false;
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
return isUnsafeUrlValueForName(name, value);
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
function canonicalizeUrlForSchemeCheck(value) {
|
|
3944
|
+
let start = 0;
|
|
3945
|
+
while (start < value.length && value.charCodeAt(start) <= 0x20) {
|
|
3946
|
+
start += 1;
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
return value.slice(start).replace(/[\\t\\r\\n]/g, "");
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
function isUnsafeUrlValueForName(name, value) {
|
|
3953
|
+
const canonical = canonicalizeUrlForSchemeCheck(value);
|
|
3954
|
+
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(canonical);
|
|
3955
|
+
if (match === null) {
|
|
3956
|
+
return false;
|
|
3957
|
+
}
|
|
3958
|
+
const scheme = match[1].toLowerCase();
|
|
3959
|
+
if (scheme === "data" && (name === "src" || name === "poster")) {
|
|
3960
|
+
return !/^data:image\\/(?!svg\\+xml(?:[;,]|$))/i.test(canonical);
|
|
3961
|
+
}
|
|
3962
|
+
return scheme === "javascript" || scheme === "data" || scheme === "vbscript" || scheme === "livescript" || scheme === "mhtml" || scheme === "file";
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
function validateOptionalOpenGraphMetadata(value, path) {
|
|
3966
|
+
if (value === undefined) {
|
|
3967
|
+
return;
|
|
3968
|
+
}
|
|
3969
|
+
assertMetadataObject(value, path);
|
|
3970
|
+
validateOptionalMetadataScalar(value.description, \`\${path}.description\`);
|
|
3971
|
+
validateOptionalMetadataImage(value.image, \`\${path}.image\`);
|
|
3972
|
+
if (value.images !== undefined) {
|
|
3973
|
+
if (!Array.isArray(value.images)) {
|
|
3974
|
+
throw new Error(\`Invalid metadata field \${path}.images: expected array.\`);
|
|
3975
|
+
}
|
|
3976
|
+
value.images.forEach((image, index) => validateOptionalMetadataImage(image, \`\${path}.images.\${index}\`));
|
|
3977
|
+
}
|
|
3978
|
+
validateOptionalMetadataScalar(value.title, \`\${path}.title\`);
|
|
3979
|
+
validateUnknownJsonMetadataFields(value, path, new Set(["description", "image", "images", "title"]));
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
function validateOptionalMetadataImage(value, path) {
|
|
3983
|
+
if (value === undefined || isMetadataScalar(value)) {
|
|
3984
|
+
return;
|
|
3985
|
+
}
|
|
3986
|
+
assertMetadataObject(value, path);
|
|
3987
|
+
validateMetadataScalar(value.url, \`\${path}.url\`);
|
|
3988
|
+
validateOptionalMetadataScalar(value.alt, \`\${path}.alt\`);
|
|
3989
|
+
validateOptionalMetadataScalar(value.height, \`\${path}.height\`);
|
|
3990
|
+
validateOptionalMetadataScalar(value.type, \`\${path}.type\`);
|
|
3991
|
+
validateOptionalMetadataScalar(value.width, \`\${path}.width\`);
|
|
3992
|
+
validateUnknownJsonMetadataFields(value, path, new Set(["alt", "height", "type", "url", "width"]));
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
function validateOptionalRobotsMetadata(value, path) {
|
|
3996
|
+
if (value === undefined || typeof value === "string") {
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3999
|
+
assertMetadataObject(value, path);
|
|
4000
|
+
if (value.follow !== undefined && typeof value.follow !== "boolean") {
|
|
4001
|
+
throw new Error(\`Invalid metadata field \${path}.follow: expected boolean.\`);
|
|
4002
|
+
}
|
|
4003
|
+
if (value.index !== undefined && typeof value.index !== "boolean") {
|
|
4004
|
+
throw new Error(\`Invalid metadata field \${path}.index: expected boolean.\`);
|
|
4005
|
+
}
|
|
4006
|
+
validateUnknownJsonMetadataFields(value, path, new Set(["follow", "index"]));
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
function validateOptionalSecurityMetadata(value, path) {
|
|
4010
|
+
if (value !== undefined) {
|
|
4011
|
+
validateJsonSerializableMetadata(value, path);
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
function validateOptionalThemeColorMetadata(value, path) {
|
|
4016
|
+
if (value === undefined || isMetadataScalar(value)) {
|
|
4017
|
+
return;
|
|
4018
|
+
}
|
|
4019
|
+
validateOptionalMetadataObject(value, path, {
|
|
4020
|
+
color: validateMetadataScalar,
|
|
4021
|
+
media: validateMetadataScalar,
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
function validateOptionalViewportMetadata(value, path) {
|
|
4026
|
+
if (value === undefined || isMetadataScalar(value)) {
|
|
4027
|
+
return;
|
|
4028
|
+
}
|
|
4029
|
+
assertMetadataObject(value, path);
|
|
4030
|
+
for (const [key, viewportValue] of Object.entries(value)) {
|
|
4031
|
+
if (viewportValue !== undefined && viewportValue !== null && !isMetadataScalar(viewportValue)) {
|
|
4032
|
+
throw new Error(\`Invalid metadata field \${path}.\${key}: expected string, number, boolean, null, or undefined.\`);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
function validateUnknownJsonMetadataFields(value, path, knownFields) {
|
|
4038
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
4039
|
+
if (!knownFields.has(key)) {
|
|
4040
|
+
validateJsonSerializableMetadata(entry, \`\${path}.\${key}\`);
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
function validateJsonSerializableMetadata(value, path) {
|
|
4046
|
+
if (value === undefined || value === null || isMetadataScalar(value)) {
|
|
4047
|
+
return;
|
|
4048
|
+
}
|
|
4049
|
+
if (Array.isArray(value)) {
|
|
4050
|
+
value.forEach((entry, index) => validateJsonSerializableMetadata(entry, \`\${path}.\${index}\`));
|
|
4051
|
+
return;
|
|
4052
|
+
}
|
|
4053
|
+
if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
4054
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
4055
|
+
validateJsonSerializableMetadata(entry, \`\${path}.\${key}\`);
|
|
4056
|
+
}
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
throw new Error(\`Invalid metadata field \${path}: expected a JSON-serializable value.\`);
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
function assertMetadataObject(value, path) {
|
|
4063
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
|
|
4064
|
+
throw new Error(\`Invalid metadata field \${path}: expected object.\`);
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
function cloudflareMetadataHeaders(metadata, request, extraHeaders) {
|
|
4069
|
+
const headers = new Headers(extraHeaders);
|
|
4070
|
+
const csp = contentSecurityPolicy(metadata?.csp);
|
|
4071
|
+
if (csp !== undefined && !headers.has("content-security-policy")) {
|
|
4072
|
+
headers.set("content-security-policy", csp);
|
|
4073
|
+
}
|
|
4074
|
+
for (const [name, value] of Object.entries(routeSecurityHeaders(metadata?.security, request))) {
|
|
4075
|
+
if (!headers.has(name)) {
|
|
4076
|
+
headers.set(name, value);
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
return headers;
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
function contentSecurityPolicy(csp) {
|
|
4083
|
+
if (csp?.disable === true || csp?.directives === undefined) {
|
|
4084
|
+
return undefined;
|
|
4085
|
+
}
|
|
4086
|
+
const serialized = [];
|
|
4087
|
+
for (const [name, value] of Object.entries(csp.directives)) {
|
|
4088
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(name)) {
|
|
4089
|
+
throw new TypeError(\`invalid CSP directive name: \${JSON.stringify(name)}\`);
|
|
4090
|
+
}
|
|
4091
|
+
const values = Array.isArray(value) ? [...value] : [value];
|
|
4092
|
+
for (const rawValue of values) {
|
|
4093
|
+
if (typeof rawValue !== "string" || !isValidCspDirectiveValue(rawValue)) {
|
|
4094
|
+
throw new TypeError(\`invalid CSP directive value for \${name}: \${JSON.stringify(rawValue)}\`);
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
if (csp.nonce !== undefined && (name === "script-src" || name === "style-src")) {
|
|
4098
|
+
values.push(\`'nonce-\${csp.nonce}'\`);
|
|
4099
|
+
}
|
|
4100
|
+
serialized.push(\`\${name} \${values.join(" ")}\`);
|
|
4101
|
+
}
|
|
4102
|
+
return serialized.join("; ");
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
function isValidCspDirectiveValue(value) {
|
|
4106
|
+
if (/^'[A-Za-z0-9+/=_:.-]+'$/.test(value)) {
|
|
4107
|
+
return true;
|
|
4108
|
+
}
|
|
4109
|
+
if (value.length === 0) {
|
|
4110
|
+
return false;
|
|
4111
|
+
}
|
|
4112
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
4113
|
+
const code = value.charCodeAt(index);
|
|
4114
|
+
if (code <= 0x20 || code === 0x22 || code === 0x27 || code === 0x3b || code === 0x7f) {
|
|
4115
|
+
return false;
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
return true;
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4121
|
+
function routeSecurityHeaders(security, request) {
|
|
4122
|
+
const headers = {
|
|
4123
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
|
4124
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
4125
|
+
"x-content-type-options": "nosniff",
|
|
4126
|
+
};
|
|
4127
|
+
if (security?.contentTypeOptions === null) {
|
|
4128
|
+
delete headers["x-content-type-options"];
|
|
4129
|
+
} else {
|
|
4130
|
+
headers["x-content-type-options"] = validateHeaderValue(security?.contentTypeOptions ?? "nosniff");
|
|
4131
|
+
}
|
|
4132
|
+
if (security?.referrerPolicy === null) {
|
|
4133
|
+
delete headers["referrer-policy"];
|
|
4134
|
+
} else {
|
|
4135
|
+
headers["referrer-policy"] = validateHeaderValue(security?.referrerPolicy ?? "strict-origin-when-cross-origin");
|
|
4136
|
+
}
|
|
4137
|
+
if (security?.frameOptions === null) {
|
|
4138
|
+
delete headers["x-frame-options"];
|
|
4139
|
+
} else if (security?.frameOptions !== undefined) {
|
|
4140
|
+
headers["x-frame-options"] = validateHeaderValue(security.frameOptions);
|
|
4141
|
+
}
|
|
4142
|
+
if (request.url.startsWith("https://") && security?.hsts !== undefined && security.hsts !== false && security.hsts !== null) {
|
|
4143
|
+
headers["strict-transport-security"] = \`max-age=\${Math.trunc(security.hsts.maxAge)}\${security.hsts.includeSubDomains === true ? "; includeSubDomains" : ""}\${security.hsts.preload === true ? "; preload" : ""}\`;
|
|
4144
|
+
}
|
|
4145
|
+
return headers;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
function validateHeaderValue(value) {
|
|
4149
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
4150
|
+
const code = value.charCodeAt(index);
|
|
4151
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
4152
|
+
throw new TypeError(\`Invalid security header value: \${JSON.stringify(value)}\`);
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
return value;
|
|
3686
4156
|
}
|
|
3687
4157
|
|
|
3688
4158
|
function mergeRouteMetadata(metadata) {
|
|
@@ -4588,6 +5058,16 @@ async function writeClientRouteBundles(options: {
|
|
|
4588
5058
|
for (const diagnostic of references.diagnostics) {
|
|
4589
5059
|
console.warn(formatClientRouteInferenceDiagnostic(diagnostic));
|
|
4590
5060
|
}
|
|
5061
|
+
const navigationRuntimeDiagnostic = navigationRuntimeLinkDisabledDiagnostic({
|
|
5062
|
+
filename: route.file,
|
|
5063
|
+
references,
|
|
5064
|
+
routePath: route.path,
|
|
5065
|
+
source,
|
|
5066
|
+
});
|
|
5067
|
+
|
|
5068
|
+
if (navigationRuntimeDiagnostic !== undefined) {
|
|
5069
|
+
console.warn(formatClientRouteInferenceDiagnostic(navigationRuntimeDiagnostic));
|
|
5070
|
+
}
|
|
4591
5071
|
|
|
4592
5072
|
if (!references.client) {
|
|
4593
5073
|
return {
|
package/src/cli-options.ts
CHANGED
|
@@ -186,7 +186,7 @@ export function formatCliHelp(command?: string | undefined): string {
|
|
|
186
186
|
"",
|
|
187
187
|
"Options:",
|
|
188
188
|
" --target=node|cloudflare|aws-lambda|all",
|
|
189
|
-
" Select build artifacts. aws-lambda writes .mreact/aws-lambda/mreact-handler.mjs and .mreact/server/import-policy.json.",
|
|
189
|
+
" Select build artifacts. Defaults to node. aws-lambda writes .mreact/aws-lambda/mreact-handler.mjs and .mreact/server/import-policy.json.",
|
|
190
190
|
" --client-source-maps=none|hidden",
|
|
191
191
|
" Control production client source map output.",
|
|
192
192
|
" -h, --help",
|
package/src/client.ts
CHANGED
|
@@ -147,7 +147,8 @@ export interface ClientRouteInferenceDiagnostic {
|
|
|
147
147
|
code:
|
|
148
148
|
| typeof clientBoundaryInferenceServerOnlyReferenceCode
|
|
149
149
|
| typeof clientBoundaryInferenceFunctionCallInteractiveCode
|
|
150
|
-
| typeof clientBoundaryInferenceUnsupportedReferenceCode
|
|
150
|
+
| typeof clientBoundaryInferenceUnsupportedReferenceCode
|
|
151
|
+
| typeof navigationRuntimeLinkDisabledCode;
|
|
151
152
|
filename: string;
|
|
152
153
|
level: "warn";
|
|
153
154
|
localNames: string[];
|
|
@@ -162,6 +163,7 @@ const clientBoundaryInferenceFunctionCallInteractiveCode =
|
|
|
162
163
|
"MR_CLIENT_BOUNDARY_INFERENCE_FUNCTION_CALL_INTERACTIVE";
|
|
163
164
|
const clientBoundaryInferenceUnsupportedReferenceCode =
|
|
164
165
|
"MR_CLIENT_BOUNDARY_INFERENCE_UNSUPPORTED_REFERENCE";
|
|
166
|
+
const navigationRuntimeLinkDisabledCode = "MR_NAVIGATION_RUNTIME_LINK_DISABLED";
|
|
165
167
|
|
|
166
168
|
export async function routeToClientManifestEntry(
|
|
167
169
|
route: AppRoute,
|
|
@@ -571,6 +573,31 @@ export async function resolveNavigationRuntime(options: {
|
|
|
571
573
|
return references.usesNavigationLink;
|
|
572
574
|
}
|
|
573
575
|
|
|
576
|
+
export function navigationRuntimeLinkDisabledDiagnostic(options: {
|
|
577
|
+
filename: string;
|
|
578
|
+
references: Pick<ClientRouteReferenceResult, "usesNavigationLink">;
|
|
579
|
+
routePath?: string | undefined;
|
|
580
|
+
source: string;
|
|
581
|
+
}): ClientRouteInferenceDiagnostic | undefined {
|
|
582
|
+
if (
|
|
583
|
+
!options.references.usesNavigationLink ||
|
|
584
|
+
detectNavigationRuntimeOverride(options.source) !== false
|
|
585
|
+
) {
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
code: navigationRuntimeLinkDisabledCode,
|
|
591
|
+
filename: options.filename,
|
|
592
|
+
level: "warn",
|
|
593
|
+
localNames: [],
|
|
594
|
+
message:
|
|
595
|
+
"A rendered Link was detected, but this route exports navigationRuntime = false. Client-side navigation will be disabled for this route; remove the override or replace Link with a plain anchor if that is intentional.",
|
|
596
|
+
routePath: options.routePath,
|
|
597
|
+
source: options.source,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
574
601
|
async function inferClientRouteShellModules(options: {
|
|
575
602
|
appDir: string;
|
|
576
603
|
cache: ClientRouteInferenceCache;
|
package/src/config.ts
CHANGED
|
@@ -157,7 +157,7 @@ export function resolveBuildTargets(
|
|
|
157
157
|
targets: readonly AppRouterBuildTarget[] | undefined,
|
|
158
158
|
): readonly AppRouterBuildTarget[] {
|
|
159
159
|
if (targets === undefined) {
|
|
160
|
-
return ["node"
|
|
160
|
+
return ["node"];
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
const uniqueTargets = [...new Set(targets)];
|