@reckona/mreact-router 0.0.141 → 0.0.142

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.
Files changed (62) hide show
  1. package/README.md +4 -2
  2. package/dist/actions.d.ts +1 -1
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +28 -8
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/cloudflare.d.ts.map +1 -1
  7. package/dist/adapters/cloudflare.js +31 -12
  8. package/dist/adapters/cloudflare.js.map +1 -1
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +470 -12
  11. package/dist/build.js.map +1 -1
  12. package/dist/cli-options.js +1 -1
  13. package/dist/cli-options.js.map +1 -1
  14. package/dist/client-route-inference.d.ts +1 -1
  15. package/dist/client-route-inference.d.ts.map +1 -1
  16. package/dist/client-route-inference.js +1 -1
  17. package/dist/client-route-inference.js.map +1 -1
  18. package/dist/client.d.ts +8 -1
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +16 -0
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.js +1 -1
  23. package/dist/config.js.map +1 -1
  24. package/dist/import-policy.js +7 -0
  25. package/dist/import-policy.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/metadata.d.ts +1 -0
  31. package/dist/metadata.d.ts.map +1 -1
  32. package/dist/metadata.js +288 -1
  33. package/dist/metadata.js.map +1 -1
  34. package/dist/middleware.d.ts +5 -0
  35. package/dist/middleware.d.ts.map +1 -1
  36. package/dist/middleware.js +13 -0
  37. package/dist/middleware.js.map +1 -1
  38. package/dist/render.d.ts.map +1 -1
  39. package/dist/render.js +41 -9
  40. package/dist/render.js.map +1 -1
  41. package/dist/typed-routes.d.ts +24 -0
  42. package/dist/typed-routes.d.ts.map +1 -0
  43. package/dist/typed-routes.js +60 -0
  44. package/dist/typed-routes.js.map +1 -0
  45. package/dist/vite.d.ts.map +1 -1
  46. package/dist/vite.js +22 -1
  47. package/dist/vite.js.map +1 -1
  48. package/package.json +11 -11
  49. package/src/actions.ts +39 -12
  50. package/src/adapters/cloudflare.ts +48 -12
  51. package/src/build.ts +491 -11
  52. package/src/cli-options.ts +1 -1
  53. package/src/client-route-inference.ts +1 -0
  54. package/src/client.ts +28 -1
  55. package/src/config.ts +1 -1
  56. package/src/import-policy.ts +8 -0
  57. package/src/index.ts +9 -0
  58. package/src/metadata.ts +388 -1
  59. package/src/middleware.ts +22 -0
  60. package/src/render.ts +58 -7
  61. package/src/typed-routes.ts +121 -0
  62. 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 {
@@ -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",
@@ -8,6 +8,7 @@ export {
8
8
  inferClientRouteModule,
9
9
  isClientRouteModule,
10
10
  isClientRouteSource,
11
+ navigationRuntimeLinkDisabledDiagnostic,
11
12
  resolveNavigationRuntime,
12
13
  routeToClientManifestEntry,
13
14
  type ClientReferenceImport,
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", "cloudflare"];
160
+ return ["node"];
161
161
  }
162
162
 
163
163
  const uniqueTargets = [...new Set(targets)];