@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.
- 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/import-policy.ts
CHANGED
|
@@ -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
|
|
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
|
|
4441
|
+
return staticMetadata;
|
|
4397
4442
|
}
|
|
4398
4443
|
|
|
4444
|
+
let generated: RouteMetadata | undefined;
|
|
4399
4445
|
try {
|
|
4400
|
-
|
|
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 (
|
|
4406
|
-
return
|
|
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 {
|