@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
@@ -143,11 +143,19 @@ function importPolicyPackageError(label: string, packageName: string): string {
143
143
  normalizedLabel.includes("action")
144
144
  ? normalizedLabel
145
145
  : `${normalizedLabel} module`;
146
+ const aliasHint =
147
+ packageName === "~" || packageName === "@"
148
+ ? [
149
+ "",
150
+ 'This looks like an app-local alias import. Use a relative import such as "./" or "../" in server-side route code, or move the import behind a supported client boundary.',
151
+ ]
152
+ : [];
146
153
 
147
154
  return [
148
155
  `"${packageName}" is imported by a ${moduleKind} but is not allowed by the app-router import policy.`,
149
156
  "",
150
157
  "The policy blocks server-side static imports from loader, middleware, route handler, metadata, and server action modules unless the package is explicitly allowed.",
158
+ ...aliasHint,
151
159
  "",
152
160
  "Allow it in the Vite plugin config:",
153
161
  " mreactRouter({",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export { defineMessages, detectLocale } from "./i18n.js";
6
6
  export { defer, isDeferredLoaderData } from "./deferred.js";
7
7
  export type { DeferredLoaderData } from "./deferred.js";
8
8
  export { Link, linkProps } from "./link.js";
9
+ export { href } from "./typed-routes.js";
9
10
  export { parseMultipartStream } from "./multipart.js";
10
11
  export type {
11
12
  MultipartFixedLengthStream,
@@ -81,6 +82,14 @@ export type {
81
82
  PackageCloudflarePagesArtifactOptions,
82
83
  } from "./build.js";
83
84
  export type { ServerActionContext } from "./actions.js";
85
+ export type {
86
+ AppRouteHref,
87
+ DynamicHrefOptions,
88
+ RouteParamsFor,
89
+ RouteSearchParams,
90
+ RouteSearchValue,
91
+ StaticHrefOptions,
92
+ } from "./typed-routes.js";
84
93
  export type {
85
94
  InferLoaderData,
86
95
  LayoutProps,
package/src/metadata.ts CHANGED
@@ -2,6 +2,10 @@ import {
2
2
  escapeHtmlAttribute,
3
3
  escapeHtmlText as escapeHtml,
4
4
  } from "@reckona/mreact-shared/html-escape";
5
+ import {
6
+ isDangerousHtmlAttribute,
7
+ isUnsafeUrlAttribute,
8
+ } from "@reckona/mreact-shared/url-safety";
5
9
  import { contentSecurityPolicy } from "./csp.js";
6
10
  import type { AppFileConvention } from "./file-conventions.js";
7
11
  import type { AppRoute } from "./routes.js";
@@ -93,6 +97,8 @@ export function applyFileConventionMetadata(
93
97
  }
94
98
 
95
99
  export function injectHeadMetadata(html: string, metadata: RouteMetadata | undefined): string {
100
+ validateRouteMetadata(metadata);
101
+
96
102
  if (metadata === undefined) {
97
103
  return html;
98
104
  }
@@ -179,6 +185,37 @@ export function responseHeadersForMetadata(
179
185
  };
180
186
  }
181
187
 
188
+ export function validateRouteMetadata(
189
+ metadata: RouteMetadata | undefined,
190
+ path = "metadata",
191
+ ): RouteMetadata | undefined {
192
+ if (metadata === undefined) {
193
+ return undefined;
194
+ }
195
+
196
+ assertPlainMetadataObject(metadata, path);
197
+ validateOptionalMetadataObject(metadata.alternates, `${path}.alternates`, {
198
+ canonical: validateMetadataScalar,
199
+ });
200
+ validateOptionalCspMetadata(metadata.csp, `${path}.csp`);
201
+ validateOptionalMetadataScalar(metadata.description, `${path}.description`);
202
+ validateOptionalHeadMetadata(metadata.head, `${path}.head`);
203
+ validateOptionalMetadataObject(metadata.icons, `${path}.icons`, {
204
+ apple: validateMetadataScalar,
205
+ icon: validateMetadataScalar,
206
+ });
207
+ validateOptionalOpenGraphMetadata(metadata.openGraph, `${path}.openGraph`);
208
+ validateOptionalMetadataScalar(metadata.lang, `${path}.lang`);
209
+ validateOptionalRobotsMetadata(metadata.robots, `${path}.robots`);
210
+ validateOptionalSecurityMetadata(metadata.security, `${path}.security`);
211
+ validateOptionalThemeColorMetadata(metadata.themeColor, `${path}.themeColor`);
212
+ validateOptionalMetadataScalar(metadata.title, `${path}.title`);
213
+ validateOptionalViewportMetadata(metadata.viewport, `${path}.viewport`);
214
+ validateUnknownMetadataFields(metadata, path);
215
+
216
+ return metadata;
217
+ }
218
+
182
219
  export function serializeRobots(manifest: RobotsManifest): string {
183
220
  const lines: string[] = [];
184
221
  const rules =
@@ -541,7 +578,357 @@ function metadataScalarField(value: unknown, path: string): MetadataScalar {
541
578
  }
542
579
 
543
580
  function isMetadataScalar(value: unknown): value is MetadataScalar {
544
- return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
581
+ return (
582
+ typeof value === "string" ||
583
+ (typeof value === "number" && Number.isFinite(value)) ||
584
+ typeof value === "boolean"
585
+ );
586
+ }
587
+
588
+ function validateOptionalMetadataScalar(value: unknown, path: string): void {
589
+ if (value !== undefined) {
590
+ validateMetadataScalar(value, path);
591
+ }
592
+ }
593
+
594
+ function validateMetadataScalar(value: unknown, path: string): void {
595
+ if (!isMetadataScalar(value)) {
596
+ throw new Error(`Invalid metadata field ${path}: expected string, number, or boolean.`);
597
+ }
598
+ }
599
+
600
+ function validateOptionalMetadataObject<T extends Record<string, unknown>>(
601
+ value: unknown,
602
+ path: string,
603
+ validators: Record<string, (value: unknown, path: string) => void>,
604
+ ): void {
605
+ if (value === undefined) {
606
+ return;
607
+ }
608
+
609
+ assertPlainMetadataObject(value, path);
610
+ for (const [key, validator] of Object.entries(validators)) {
611
+ const fieldValue = (value as T)[key];
612
+ if (fieldValue !== undefined) {
613
+ validator(fieldValue, `${path}.${key}`);
614
+ }
615
+ }
616
+ validateUnknownJsonMetadataFields(
617
+ value as Record<string, unknown>,
618
+ path,
619
+ new Set(Object.keys(validators)),
620
+ );
621
+ }
622
+
623
+ function validateOptionalCspMetadata(value: unknown, path: string): void {
624
+ if (value === undefined) {
625
+ return;
626
+ }
627
+
628
+ assertPlainMetadataObject(value, path);
629
+ const csp = value as NonNullable<RouteMetadata["csp"]>;
630
+ if (csp.disable !== undefined && typeof csp.disable !== "boolean") {
631
+ throw new Error(`Invalid metadata field ${path}.disable: expected boolean.`);
632
+ }
633
+ if (csp.nonce !== undefined && typeof csp.nonce !== "string") {
634
+ throw new Error(`Invalid metadata field ${path}.nonce: expected string.`);
635
+ }
636
+ validateOptionalDirectiveMap(csp.directives, `${path}.directives`);
637
+ validateOptionalDirectiveMap(csp.replace, `${path}.replace`);
638
+ if (csp.remove !== undefined) {
639
+ validateStringArray(csp.remove, `${path}.remove`);
640
+ }
641
+ validateUnknownJsonMetadataFields(
642
+ csp as Record<string, unknown>,
643
+ path,
644
+ new Set(["directives", "disable", "nonce", "remove", "replace"]),
645
+ );
646
+ }
647
+
648
+ function validateOptionalDirectiveMap(value: unknown, path: string): void {
649
+ if (value === undefined) {
650
+ return;
651
+ }
652
+
653
+ assertPlainMetadataObject(value, path);
654
+ for (const [name, directive] of Object.entries(value)) {
655
+ if (typeof directive === "string") {
656
+ continue;
657
+ }
658
+ validateStringArray(directive, `${path}.${name}`);
659
+ }
660
+ }
661
+
662
+ function validateStringArray(value: unknown, path: string): void {
663
+ if (!Array.isArray(value)) {
664
+ throw new Error(`Invalid metadata field ${path}: expected string or string array.`);
665
+ }
666
+
667
+ value.forEach((entry, index) => {
668
+ if (typeof entry !== "string") {
669
+ throw new Error(`Invalid metadata field ${path}.${index}: expected string.`);
670
+ }
671
+ });
672
+ }
673
+
674
+ function validateOptionalHeadMetadata(value: unknown, path: string): void {
675
+ if (value === undefined) {
676
+ return;
677
+ }
678
+
679
+ if (!Array.isArray(value)) {
680
+ throw new Error(`Invalid metadata field ${path}: expected array.`);
681
+ }
682
+
683
+ value.forEach((descriptor, index) => {
684
+ const descriptorPath = `${path}.${index}`;
685
+ assertPlainMetadataObject(descriptor, descriptorPath);
686
+ const head = descriptor as unknown as RouteHeadDescriptor;
687
+ if (!["base", "link", "meta", "script", "style"].includes(String(head.tag))) {
688
+ throw new Error(`Invalid metadata field ${descriptorPath}.tag: expected supported head tag.`);
689
+ }
690
+ if (head.content !== undefined && typeof head.content !== "string") {
691
+ throw new Error(`Invalid metadata field ${descriptorPath}.content: expected string.`);
692
+ }
693
+ if (
694
+ head.nonce !== undefined &&
695
+ typeof head.nonce !== "boolean" &&
696
+ typeof head.nonce !== "string"
697
+ ) {
698
+ throw new Error(`Invalid metadata field ${descriptorPath}.nonce: expected string or boolean.`);
699
+ }
700
+ if (head.attrs !== undefined) {
701
+ assertPlainMetadataObject(head.attrs, `${descriptorPath}.attrs`);
702
+ for (const [name, attr] of Object.entries(head.attrs)) {
703
+ validateHeadAttribute(name, attr, `${descriptorPath}.attrs.${name}`);
704
+ if (
705
+ attr !== undefined &&
706
+ typeof attr !== "boolean" &&
707
+ typeof attr !== "number" &&
708
+ typeof attr !== "string"
709
+ ) {
710
+ throw new Error(
711
+ `Invalid metadata field ${descriptorPath}.attrs.${name}: expected string, number, boolean, or undefined.`,
712
+ );
713
+ }
714
+ }
715
+ }
716
+ validateUnknownJsonMetadataFields(
717
+ descriptor as Record<string, unknown>,
718
+ descriptorPath,
719
+ new Set(["attrs", "content", "nonce", "tag"]),
720
+ );
721
+ });
722
+ }
723
+
724
+ function validateHeadAttribute(name: string, value: unknown, path: string): void {
725
+ if (!isSafeHeadAttributeName(name)) {
726
+ throw new Error(`Invalid metadata field ${path}: expected safe HTML attribute name.`);
727
+ }
728
+
729
+ const canonicalName = name.toLowerCase();
730
+ if (canonicalName.startsWith("on") || isDangerousHtmlAttribute(canonicalName)) {
731
+ throw new Error(`Invalid metadata field ${path}: event and dangerous attributes are not allowed.`);
732
+ }
733
+
734
+ if (typeof value === "string" && isUnsafeUrlAttribute(canonicalName, value)) {
735
+ throw new Error(`Invalid metadata field ${path}: unsafe URL value.`);
736
+ }
737
+ }
738
+
739
+ function isSafeHeadAttributeName(name: string): boolean {
740
+ if (name.length === 0) {
741
+ return false;
742
+ }
743
+
744
+ for (let index = 0; index < name.length; index += 1) {
745
+ const code = name.charCodeAt(index);
746
+
747
+ if (
748
+ code <= 0x20 ||
749
+ code === 0x22 ||
750
+ code === 0x27 ||
751
+ code === 0x2f ||
752
+ code === 0x3c ||
753
+ code === 0x3d ||
754
+ code === 0x3e ||
755
+ code === 0x60 ||
756
+ code === 0x7f
757
+ ) {
758
+ return false;
759
+ }
760
+ }
761
+
762
+ return true;
763
+ }
764
+
765
+ function validateOptionalOpenGraphMetadata(value: unknown, path: string): void {
766
+ if (value === undefined) {
767
+ return;
768
+ }
769
+
770
+ assertPlainMetadataObject(value, path);
771
+ const openGraph = value as NonNullable<RouteMetadata["openGraph"]>;
772
+ validateOptionalMetadataScalar(openGraph.description, `${path}.description`);
773
+ validateOptionalMetadataImage(openGraph.image, `${path}.image`);
774
+ if (openGraph.images !== undefined) {
775
+ if (!Array.isArray(openGraph.images)) {
776
+ throw new Error(`Invalid metadata field ${path}.images: expected array.`);
777
+ }
778
+ openGraph.images.forEach((image, index) =>
779
+ validateOptionalMetadataImage(image, `${path}.images.${index}`),
780
+ );
781
+ }
782
+ validateOptionalMetadataScalar(openGraph.title, `${path}.title`);
783
+ validateUnknownJsonMetadataFields(
784
+ openGraph as Record<string, unknown>,
785
+ path,
786
+ new Set(["description", "image", "images", "title"]),
787
+ );
788
+ }
789
+
790
+ function validateOptionalMetadataImage(value: unknown, path: string): void {
791
+ if (value === undefined) {
792
+ return;
793
+ }
794
+
795
+ if (isMetadataScalar(value)) {
796
+ return;
797
+ }
798
+
799
+ assertPlainMetadataObject(value, path);
800
+ const image = value as unknown as MetadataImage;
801
+ validateMetadataScalar(image.url, `${path}.url`);
802
+ validateOptionalMetadataScalar(image.alt, `${path}.alt`);
803
+ validateOptionalMetadataScalar(image.height, `${path}.height`);
804
+ validateOptionalMetadataScalar(image.type, `${path}.type`);
805
+ validateOptionalMetadataScalar(image.width, `${path}.width`);
806
+ validateUnknownJsonMetadataFields(
807
+ image as unknown as Record<string, unknown>,
808
+ path,
809
+ new Set(["alt", "height", "type", "url", "width"]),
810
+ );
811
+ }
812
+
813
+ function validateOptionalRobotsMetadata(value: unknown, path: string): void {
814
+ if (value === undefined || typeof value === "string") {
815
+ return;
816
+ }
817
+
818
+ assertPlainMetadataObject(value, path);
819
+ const robots = value as Extract<NonNullable<RouteMetadata["robots"]>, object>;
820
+ if (robots.follow !== undefined && typeof robots.follow !== "boolean") {
821
+ throw new Error(`Invalid metadata field ${path}.follow: expected boolean.`);
822
+ }
823
+ if (robots.index !== undefined && typeof robots.index !== "boolean") {
824
+ throw new Error(`Invalid metadata field ${path}.index: expected boolean.`);
825
+ }
826
+ validateUnknownJsonMetadataFields(
827
+ robots as Record<string, unknown>,
828
+ path,
829
+ new Set(["follow", "index"]),
830
+ );
831
+ }
832
+
833
+ function validateOptionalSecurityMetadata(value: unknown, path: string): void {
834
+ if (value === undefined) {
835
+ return;
836
+ }
837
+
838
+ assertPlainMetadataObject(value, path);
839
+ validateJsonSerializableMetadata(value, path);
840
+ }
841
+
842
+ function validateOptionalThemeColorMetadata(value: unknown, path: string): void {
843
+ if (value === undefined || isMetadataScalar(value)) {
844
+ return;
845
+ }
846
+
847
+ validateOptionalMetadataObject(value, path, {
848
+ color: validateMetadataScalar,
849
+ media: validateMetadataScalar,
850
+ });
851
+ }
852
+
853
+ function validateOptionalViewportMetadata(value: unknown, path: string): void {
854
+ if (value === undefined || isMetadataScalar(value)) {
855
+ return;
856
+ }
857
+
858
+ assertPlainMetadataObject(value, path);
859
+ for (const [key, viewportValue] of Object.entries(value)) {
860
+ if (viewportValue !== undefined && viewportValue !== null && !isMetadataScalar(viewportValue)) {
861
+ throw new Error(
862
+ `Invalid metadata field ${path}.${key}: expected string, number, boolean, null, or undefined.`,
863
+ );
864
+ }
865
+ }
866
+ }
867
+
868
+ function validateUnknownMetadataFields(metadata: RouteMetadata, path: string): void {
869
+ validateUnknownJsonMetadataFields(metadata as Record<string, unknown>, path, new Set([
870
+ "alternates",
871
+ "csp",
872
+ "description",
873
+ "head",
874
+ "icons",
875
+ "lang",
876
+ "openGraph",
877
+ "robots",
878
+ "security",
879
+ "themeColor",
880
+ "title",
881
+ "viewport",
882
+ ]));
883
+ }
884
+
885
+ function validateUnknownJsonMetadataFields(
886
+ value: Record<string, unknown>,
887
+ path: string,
888
+ knownFields: ReadonlySet<string>,
889
+ ): void {
890
+ for (const [key, entry] of Object.entries(value)) {
891
+ if (!knownFields.has(key)) {
892
+ validateJsonSerializableMetadata(entry, `${path}.${key}`);
893
+ }
894
+ }
895
+ }
896
+
897
+ function validateJsonSerializableMetadata(value: unknown, path: string): void {
898
+ if (value === undefined || value === null || isMetadataScalar(value)) {
899
+ return;
900
+ }
901
+
902
+ if (Array.isArray(value)) {
903
+ value.forEach((entry, index) => validateJsonSerializableMetadata(entry, `${path}.${index}`));
904
+ return;
905
+ }
906
+
907
+ if (isPlainMetadataObject(value)) {
908
+ for (const [key, entry] of Object.entries(value)) {
909
+ validateJsonSerializableMetadata(entry, `${path}.${key}`);
910
+ }
911
+ return;
912
+ }
913
+
914
+ throw new Error(
915
+ `Invalid metadata field ${path}: expected a JSON-serializable value.`,
916
+ );
917
+ }
918
+
919
+ function assertPlainMetadataObject(value: unknown, path: string): asserts value is Record<string, unknown> {
920
+ if (!isPlainMetadataObject(value)) {
921
+ throw new Error(`Invalid metadata field ${path}: expected object.`);
922
+ }
923
+ }
924
+
925
+ function isPlainMetadataObject(value: unknown): value is Record<string, unknown> {
926
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
927
+ return false;
928
+ }
929
+
930
+ const prototype = Object.getPrototypeOf(value);
931
+ return prototype === Object.prototype || prototype === null;
545
932
  }
546
933
 
547
934
  function openGraphImages(openGraph: RouteMetadata["openGraph"]): readonly string[] {
package/src/middleware.ts CHANGED
@@ -34,6 +34,24 @@ export function shouldSkipMiddleware(
34
34
  return typeof config?.id === "string" && control.skip.includes(config.id);
35
35
  }
36
36
 
37
+ export function validateRouteMiddlewareControl(options: {
38
+ availableIds: ReadonlySet<string>;
39
+ control: RouteMiddlewareControl | undefined;
40
+ routePath: string;
41
+ }): void {
42
+ if (!Array.isArray(options.control?.skip)) {
43
+ return;
44
+ }
45
+
46
+ for (const id of options.control.skip) {
47
+ if (!options.availableIds.has(id)) {
48
+ throw new Error(
49
+ `Unknown middleware skip id "${id}" for route "${options.routePath}". Available middleware ids: ${formatAvailableMiddlewareIds(options.availableIds)}.`,
50
+ );
51
+ }
52
+ }
53
+ }
54
+
37
55
  export function parseStaticMiddlewareConfig(code: string): StaticMiddlewareConfig {
38
56
  const configBody = /\bexport\s+const\s+config\s*=\s*\{(?<body>[\s\S]*?)\}\s*;?/.exec(code)
39
57
  ?.groups?.body;
@@ -156,3 +174,7 @@ function middlewarePatternMatches(pattern: string, pathname: string): boolean {
156
174
 
157
175
  return false;
158
176
  }
177
+
178
+ function formatAvailableMiddlewareIds(ids: ReadonlySet<string>): string {
179
+ return ids.size === 0 ? "(none)" : [...ids].sort().join(", ");
180
+ }
package/src/render.ts CHANGED
@@ -108,6 +108,7 @@ import {
108
108
  parseRouteMiddlewareControl,
109
109
  parseStaticMiddlewareConfig,
110
110
  shouldSkipMiddleware,
111
+ validateRouteMiddlewareControl,
111
112
  type MiddlewareModule,
112
113
  type RouteMiddlewareControl,
113
114
  } from "./middleware.js";
@@ -118,6 +119,7 @@ import {
118
119
  responseHeadersForMetadata,
119
120
  serializeRobots,
120
121
  serializeSitemap,
122
+ validateRouteMetadata,
121
123
  } from "./metadata.js";
122
124
  import {
123
125
  createSlotRenderContext,
@@ -2215,6 +2217,18 @@ async function runMiddleware(options: {
2215
2217
  ];
2216
2218
  const pathname = new URL(options.request.url).pathname;
2217
2219
 
2220
+ if (Array.isArray(options.middlewareControl?.skip)) {
2221
+ validateRouteMiddlewareControl({
2222
+ availableIds: await collectAvailableMiddlewareIds({
2223
+ candidates,
2224
+ serverModuleCacheVersion: options.serverModuleCacheVersion,
2225
+ serverSourceFiles: options.serverSourceFiles,
2226
+ }),
2227
+ control: options.middlewareControl,
2228
+ routePath: pathname,
2229
+ });
2230
+ }
2231
+
2218
2232
  for (const file of candidates) {
2219
2233
  try {
2220
2234
  await access(file);
@@ -2318,6 +2332,35 @@ async function runMiddleware(options: {
2318
2332
  return undefined;
2319
2333
  }
2320
2334
 
2335
+ async function collectAvailableMiddlewareIds(options: {
2336
+ candidates: readonly string[];
2337
+ serverModuleCacheVersion?: string | undefined;
2338
+ serverSourceFiles?: ReadonlyMap<string, string> | undefined;
2339
+ }): Promise<ReadonlySet<string>> {
2340
+ const ids = new Set<string>();
2341
+
2342
+ for (const file of options.candidates) {
2343
+ try {
2344
+ await access(file);
2345
+ } catch {
2346
+ continue;
2347
+ }
2348
+
2349
+ const code = await readServerSourceFile(
2350
+ file,
2351
+ options.serverModuleCacheVersion,
2352
+ options.serverSourceFiles,
2353
+ );
2354
+ const id = parseStaticMiddlewareConfig(code).id;
2355
+
2356
+ if (id !== undefined) {
2357
+ ids.add(id);
2358
+ }
2359
+ }
2360
+
2361
+ return ids;
2362
+ }
2363
+
2321
2364
  async function loadMiddlewareModule(options: {
2322
2365
  appDir: string;
2323
2366
  code?: string | undefined;
@@ -4392,22 +4435,30 @@ async function resolveRouteMetadataModule(
4392
4435
  module: RouteMetadataModule,
4393
4436
  context: RouteMetadataContext,
4394
4437
  ): Promise<RouteMetadata | undefined> {
4438
+ const staticMetadata = validateRouteMetadata(module.metadata);
4439
+
4395
4440
  if (module.generateMetadata === undefined) {
4396
- return module.metadata;
4441
+ return staticMetadata;
4397
4442
  }
4398
4443
 
4444
+ let generated: RouteMetadata | undefined;
4399
4445
  try {
4400
- const generated = await module.generateMetadata(context);
4401
- return generated === undefined
4402
- ? module.metadata
4403
- : mergeRouteMetadata([module.metadata, generated].filter(isRouteMetadata));
4446
+ generated = await module.generateMetadata(context);
4404
4447
  } catch (error) {
4405
- if (module.metadata !== undefined) {
4406
- return module.metadata;
4448
+ if (staticMetadata !== undefined) {
4449
+ return staticMetadata;
4407
4450
  }
4408
4451
 
4409
4452
  throw error;
4410
4453
  }
4454
+
4455
+ const validatedGenerated = validateRouteMetadata(generated, "generateMetadata");
4456
+ const merged =
4457
+ validatedGenerated === undefined
4458
+ ? staticMetadata
4459
+ : mergeRouteMetadata([staticMetadata, validatedGenerated].filter(isRouteMetadata));
4460
+
4461
+ return validateRouteMetadata(merged);
4411
4462
  }
4412
4463
 
4413
4464
  function isRouteMetadata(value: RouteMetadata | undefined): value is RouteMetadata {