@reckona/mreact-router 0.0.203 → 0.0.205
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 +53 -9
- package/dist/actions.d.ts +6 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +70 -10
- package/dist/actions.js.map +1 -1
- package/dist/adapters/aws-lambda.d.ts +15 -4
- package/dist/adapters/aws-lambda.d.ts.map +1 -1
- package/dist/adapters/aws-lambda.js +301 -63
- package/dist/adapters/aws-lambda.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +2 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +18 -10
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/edge.d.ts +1 -1
- package/dist/adapters/edge.d.ts.map +1 -1
- package/dist/adapters/edge.js.map +1 -1
- package/dist/adapters/node.d.ts +5 -3
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +2 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/static.d.ts.map +1 -1
- package/dist/adapters/static.js +24 -2
- package/dist/adapters/static.js.map +1 -1
- package/dist/build.d.ts +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +91 -5
- package/dist/build.js.map +1 -1
- package/dist/built-runtime.d.ts +2 -0
- package/dist/built-runtime.d.ts.map +1 -1
- package/dist/built-runtime.js +16 -0
- package/dist/built-runtime.js.map +1 -1
- package/dist/cache.d.ts +3 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +211 -13
- package/dist/cache.js.map +1 -1
- package/dist/cli.js +12 -1
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +32 -1
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -2
- package/dist/config.js.map +1 -1
- package/dist/csp.d.ts +1 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +11 -6
- package/dist/csp.js.map +1 -1
- package/dist/dehydrate-policy.d.ts +3 -0
- package/dist/dehydrate-policy.d.ts.map +1 -0
- package/dist/dehydrate-policy.js +16 -0
- package/dist/dehydrate-policy.js.map +1 -0
- package/dist/dev-server.d.ts +7 -2
- package/dist/dev-server.d.ts.map +1 -1
- package/dist/dev-server.js +120 -11
- package/dist/dev-server.js.map +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +147 -7
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +22 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +22 -8
- package/dist/logger.js.map +1 -1
- package/dist/metadata.d.ts +8 -0
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +60 -22
- package/dist/metadata.js.map +1 -1
- package/dist/navigation-marker.d.ts.map +1 -1
- package/dist/navigation-marker.js +72 -9
- package/dist/navigation-marker.js.map +1 -1
- package/dist/navigation.d.ts +1 -0
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +1 -1
- package/dist/navigation.js.map +1 -1
- package/dist/node-server.d.ts +5 -2
- package/dist/node-server.d.ts.map +1 -1
- package/dist/node-server.js +75 -6
- package/dist/node-server.js.map +1 -1
- package/dist/prerender-entry.d.ts +14 -0
- package/dist/prerender-entry.d.ts.map +1 -1
- package/dist/prerender-entry.js +40 -3
- package/dist/prerender-entry.js.map +1 -1
- package/dist/render.d.ts +9 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +102 -123
- package/dist/render.js.map +1 -1
- package/dist/serve.d.ts +8 -2
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +22 -20
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +52 -19
- package/dist/session.js.map +1 -1
- package/dist/upgrade.d.ts +59 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +282 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/vite-config.d.ts +2 -0
- package/dist/vite-config.d.ts.map +1 -1
- package/dist/vite-config.js +2 -0
- package/dist/vite-config.js.map +1 -1
- package/dist/vite.d.ts +4 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +117 -6
- package/dist/vite.js.map +1 -1
- package/package.json +11 -11
- package/src/actions.ts +81 -11
- package/src/adapters/aws-lambda.ts +426 -115
- package/src/adapters/cloudflare.ts +29 -13
- package/src/adapters/edge.ts +2 -0
- package/src/adapters/node.ts +8 -10
- package/src/adapters/static.ts +31 -2
- package/src/build.ts +117 -10
- package/src/built-runtime.ts +28 -0
- package/src/cache.ts +262 -15
- package/src/cli.ts +14 -1
- package/src/client.ts +32 -1
- package/src/config.ts +43 -6
- package/src/csp.ts +14 -11
- package/src/dehydrate-policy.ts +23 -0
- package/src/dev-server.ts +188 -15
- package/src/http.ts +172 -6
- package/src/index.ts +14 -1
- package/src/logger.ts +47 -10
- package/src/metadata.ts +125 -60
- package/src/navigation-marker.ts +79 -9
- package/src/navigation.ts +1 -1
- package/src/node-server.ts +128 -22
- package/src/prerender-entry.ts +52 -3
- package/src/render.ts +807 -753
- package/src/serve.ts +49 -56
- package/src/session.ts +56 -25
- package/src/upgrade.ts +402 -3
- package/src/vite-config.ts +6 -0
- package/src/vite.ts +157 -5
package/src/metadata.ts
CHANGED
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
} from "@reckona/mreact-shared/html-escape";
|
|
5
5
|
import {
|
|
6
6
|
isDangerousHtmlAttribute,
|
|
7
|
+
isUnsafeMetaRefreshContent,
|
|
7
8
|
isUnsafeUrlAttribute,
|
|
8
9
|
} from "@reckona/mreact-shared/url-safety";
|
|
9
10
|
import { contentSecurityPolicy } from "./csp.js";
|
|
10
|
-
import type { AppFileConvention } from "./file-conventions.js";
|
|
11
11
|
import type { AppRoute } from "./routes.js";
|
|
12
12
|
import { routeSecurityHeaders } from "./security-headers.js";
|
|
13
13
|
import type {
|
|
@@ -28,6 +28,13 @@ const DEFAULT_HTML_RESPONSE_HEADERS = Object.freeze({
|
|
|
28
28
|
"content-type": "text/html; charset=utf-8",
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
export interface FileConventionMetadataTemplate {
|
|
32
|
+
appleIconPath?: string | undefined;
|
|
33
|
+
iconPath?: string | undefined;
|
|
34
|
+
openGraphImageFallbackPath?: string | undefined;
|
|
35
|
+
openGraphImageRoutePath?: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
export function mergeRouteMetadata(metadata: readonly RouteMetadata[]): RouteMetadata | undefined {
|
|
32
39
|
if (metadata.length === 0) {
|
|
33
40
|
return undefined;
|
|
@@ -67,23 +74,64 @@ export function applyFileConventionMetadata(
|
|
|
67
74
|
filename: string,
|
|
68
75
|
params: RouteParams,
|
|
69
76
|
): RouteMetadata | undefined {
|
|
70
|
-
|
|
77
|
+
return applyFileConventionMetadataTemplate(
|
|
78
|
+
metadata,
|
|
79
|
+
fileConventionMetadataTemplate(routes, filename),
|
|
80
|
+
params,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fileConventionMetadataTemplate(
|
|
85
|
+
routes: readonly AppRoute[],
|
|
86
|
+
filename: string,
|
|
87
|
+
): FileConventionMetadataTemplate {
|
|
88
|
+
const pageRoute = routes.find((route) => route.kind === "page" && route.file === filename);
|
|
71
89
|
const iconRoute = routes.find((route) => route.kind === "asset" && route.convention === "icon");
|
|
72
90
|
const appleIconRoute = routes.find(
|
|
73
91
|
(route) => route.kind === "asset" && route.convention === "apple-icon",
|
|
74
92
|
);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"opengraph-image",
|
|
93
|
+
const openGraphImageRoutes = routes.filter(
|
|
94
|
+
(route) =>
|
|
95
|
+
(route.kind === "asset" || route.kind === "metadata") &&
|
|
96
|
+
route.convention === "opengraph-image",
|
|
80
97
|
);
|
|
98
|
+
const expectedOpenGraphImagePath =
|
|
99
|
+
pageRoute === undefined
|
|
100
|
+
? undefined
|
|
101
|
+
: pageRoute.path === "/"
|
|
102
|
+
? "/opengraph-image"
|
|
103
|
+
: `${pageRoute.path}/opengraph-image`;
|
|
81
104
|
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
return {
|
|
106
|
+
...(appleIconRoute === undefined ? {} : { appleIconPath: appleIconRoute.path }),
|
|
107
|
+
...(iconRoute === undefined ? {} : { iconPath: iconRoute.path }),
|
|
108
|
+
...(openGraphImageRoutes.find((route) => route.path === "/opengraph-image") === undefined
|
|
109
|
+
? {}
|
|
110
|
+
: { openGraphImageFallbackPath: "/opengraph-image" }),
|
|
111
|
+
...(expectedOpenGraphImagePath === undefined ||
|
|
112
|
+
openGraphImageRoutes.find((route) => route.path === expectedOpenGraphImagePath) === undefined
|
|
113
|
+
? {}
|
|
114
|
+
: { openGraphImageRoutePath: expectedOpenGraphImagePath }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function applyFileConventionMetadataTemplate(
|
|
119
|
+
metadata: RouteMetadata | undefined,
|
|
120
|
+
template: FileConventionMetadataTemplate,
|
|
121
|
+
params: RouteParams,
|
|
122
|
+
): RouteMetadata | undefined {
|
|
123
|
+
const next: RouteMetadata = metadata === undefined ? {} : { ...metadata };
|
|
124
|
+
const openGraphImagePath =
|
|
125
|
+
(template.openGraphImageRoutePath === undefined
|
|
126
|
+
? undefined
|
|
127
|
+
: concreteRoutePath(template.openGraphImageRoutePath, params)) ??
|
|
128
|
+
template.openGraphImageFallbackPath;
|
|
129
|
+
|
|
130
|
+
if (template.iconPath !== undefined && next.icons?.icon === undefined) {
|
|
131
|
+
next.icons = { ...next.icons, icon: template.iconPath };
|
|
84
132
|
}
|
|
85
|
-
if (
|
|
86
|
-
next.icons = { ...next.icons, apple:
|
|
133
|
+
if (template.appleIconPath !== undefined && next.icons?.apple === undefined) {
|
|
134
|
+
next.icons = { ...next.icons, apple: template.appleIconPath };
|
|
87
135
|
}
|
|
88
136
|
if (
|
|
89
137
|
openGraphImagePath !== undefined &&
|
|
@@ -268,33 +316,6 @@ export function serializeSitemap(entries: readonly SitemapEntry[]): string {
|
|
|
268
316
|
return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
|
|
269
317
|
}
|
|
270
318
|
|
|
271
|
-
function fileConventionMetadataRoutePath(
|
|
272
|
-
routes: readonly AppRoute[],
|
|
273
|
-
filename: string,
|
|
274
|
-
params: RouteParams,
|
|
275
|
-
convention: AppFileConvention,
|
|
276
|
-
): string | undefined {
|
|
277
|
-
const pageRoute = routes.find((route) => route.kind === "page" && route.file === filename);
|
|
278
|
-
const candidateRoutes = routes.filter(
|
|
279
|
-
(route) =>
|
|
280
|
-
(route.kind === "asset" || route.kind === "metadata") &&
|
|
281
|
-
route.convention === convention,
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
if (pageRoute !== undefined) {
|
|
285
|
-
const expectedPath = pageRoute.path === "/" ? `/${convention}` : `${pageRoute.path}/${convention}`;
|
|
286
|
-
const routeLocal = candidateRoutes.find((route) => route.path === expectedPath);
|
|
287
|
-
const routeLocalPath =
|
|
288
|
-
routeLocal === undefined ? undefined : concreteRoutePath(routeLocal.path, params);
|
|
289
|
-
|
|
290
|
-
if (routeLocalPath !== undefined) {
|
|
291
|
-
return routeLocalPath;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return candidateRoutes.find((route) => route.path === `/${convention}`)?.path;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
319
|
function concreteRoutePath(path: string, params: RouteParams): string | undefined {
|
|
299
320
|
const segments = path === "/" ? [] : path.slice(1).split("/");
|
|
300
321
|
const concrete: string[] = [];
|
|
@@ -695,7 +716,9 @@ function validateOptionalHeadMetadata(value: unknown, path: string): void {
|
|
|
695
716
|
typeof head.nonce !== "boolean" &&
|
|
696
717
|
typeof head.nonce !== "string"
|
|
697
718
|
) {
|
|
698
|
-
throw new Error(
|
|
719
|
+
throw new Error(
|
|
720
|
+
`Invalid metadata field ${descriptorPath}.nonce: expected string or boolean.`,
|
|
721
|
+
);
|
|
699
722
|
}
|
|
700
723
|
if (head.attrs !== undefined) {
|
|
701
724
|
assertPlainMetadataObject(head.attrs, `${descriptorPath}.attrs`);
|
|
@@ -713,6 +736,7 @@ function validateOptionalHeadMetadata(value: unknown, path: string): void {
|
|
|
713
736
|
}
|
|
714
737
|
}
|
|
715
738
|
}
|
|
739
|
+
validateMetaRefreshHeadDescriptor(head, descriptorPath);
|
|
716
740
|
validateUnknownJsonMetadataFields(
|
|
717
741
|
descriptor as Record<string, unknown>,
|
|
718
742
|
descriptorPath,
|
|
@@ -721,6 +745,36 @@ function validateOptionalHeadMetadata(value: unknown, path: string): void {
|
|
|
721
745
|
});
|
|
722
746
|
}
|
|
723
747
|
|
|
748
|
+
function validateMetaRefreshHeadDescriptor(head: RouteHeadDescriptor, path: string): void {
|
|
749
|
+
if (head.tag !== "meta" || head.attrs === undefined) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const attrs = Object.entries(head.attrs);
|
|
754
|
+
const hasRefresh = attrs.some(([name, value]) => {
|
|
755
|
+
const canonicalName = name.toLowerCase();
|
|
756
|
+
return (
|
|
757
|
+
(canonicalName === "http-equiv" || canonicalName === "httpequiv") &&
|
|
758
|
+
typeof value === "string" &&
|
|
759
|
+
value.toLowerCase() === "refresh"
|
|
760
|
+
);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
if (!hasRefresh) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
for (const [name, value] of attrs) {
|
|
768
|
+
if (
|
|
769
|
+
name.toLowerCase() === "content" &&
|
|
770
|
+
typeof value === "string" &&
|
|
771
|
+
isUnsafeMetaRefreshContent("refresh", value)
|
|
772
|
+
) {
|
|
773
|
+
throw new Error(`Invalid metadata field ${path}.attrs.${name}: unsafe refresh URL value.`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
724
778
|
function validateHeadAttribute(name: string, value: unknown, path: string): void {
|
|
725
779
|
if (!isSafeHeadAttributeName(name)) {
|
|
726
780
|
throw new Error(`Invalid metadata field ${path}: expected safe HTML attribute name.`);
|
|
@@ -728,7 +782,9 @@ function validateHeadAttribute(name: string, value: unknown, path: string): void
|
|
|
728
782
|
|
|
729
783
|
const canonicalName = name.toLowerCase();
|
|
730
784
|
if (canonicalName.startsWith("on") || isDangerousHtmlAttribute(canonicalName)) {
|
|
731
|
-
throw new Error(
|
|
785
|
+
throw new Error(
|
|
786
|
+
`Invalid metadata field ${path}: event and dangerous attributes are not allowed.`,
|
|
787
|
+
);
|
|
732
788
|
}
|
|
733
789
|
|
|
734
790
|
if (typeof value === "string" && isUnsafeUrlAttribute(canonicalName, value)) {
|
|
@@ -866,20 +922,24 @@ function validateOptionalViewportMetadata(value: unknown, path: string): void {
|
|
|
866
922
|
}
|
|
867
923
|
|
|
868
924
|
function validateUnknownMetadataFields(metadata: RouteMetadata, path: string): void {
|
|
869
|
-
validateUnknownJsonMetadataFields(
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
925
|
+
validateUnknownJsonMetadataFields(
|
|
926
|
+
metadata as Record<string, unknown>,
|
|
927
|
+
path,
|
|
928
|
+
new Set([
|
|
929
|
+
"alternates",
|
|
930
|
+
"csp",
|
|
931
|
+
"description",
|
|
932
|
+
"head",
|
|
933
|
+
"icons",
|
|
934
|
+
"lang",
|
|
935
|
+
"openGraph",
|
|
936
|
+
"robots",
|
|
937
|
+
"security",
|
|
938
|
+
"themeColor",
|
|
939
|
+
"title",
|
|
940
|
+
"viewport",
|
|
941
|
+
]),
|
|
942
|
+
);
|
|
883
943
|
}
|
|
884
944
|
|
|
885
945
|
function validateUnknownJsonMetadataFields(
|
|
@@ -911,12 +971,13 @@ function validateJsonSerializableMetadata(value: unknown, path: string): void {
|
|
|
911
971
|
return;
|
|
912
972
|
}
|
|
913
973
|
|
|
914
|
-
throw new Error(
|
|
915
|
-
`Invalid metadata field ${path}: expected a JSON-serializable value.`,
|
|
916
|
-
);
|
|
974
|
+
throw new Error(`Invalid metadata field ${path}: expected a JSON-serializable value.`);
|
|
917
975
|
}
|
|
918
976
|
|
|
919
|
-
function assertPlainMetadataObject(
|
|
977
|
+
function assertPlainMetadataObject(
|
|
978
|
+
value: unknown,
|
|
979
|
+
path: string,
|
|
980
|
+
): asserts value is Record<string, unknown> {
|
|
920
981
|
if (!isPlainMetadataObject(value)) {
|
|
921
982
|
throw new Error(`Invalid metadata field ${path}: expected object.`);
|
|
922
983
|
}
|
|
@@ -938,7 +999,9 @@ function openGraphImages(openGraph: RouteMetadata["openGraph"]): readonly string
|
|
|
938
999
|
);
|
|
939
1000
|
}
|
|
940
1001
|
|
|
941
|
-
return openGraph?.image === undefined
|
|
1002
|
+
return openGraph?.image === undefined
|
|
1003
|
+
? []
|
|
1004
|
+
: [metadataImageUrl(openGraph.image, "openGraph.image")];
|
|
942
1005
|
}
|
|
943
1006
|
|
|
944
1007
|
function metadataImageUrl(value: MetadataImage | MetadataScalar, path: string): string {
|
|
@@ -950,7 +1013,9 @@ function metadataImageUrl(value: MetadataImage | MetadataScalar, path: string):
|
|
|
950
1013
|
return metadataString(value.url, `${path}.url`);
|
|
951
1014
|
}
|
|
952
1015
|
|
|
953
|
-
throw new Error(
|
|
1016
|
+
throw new Error(
|
|
1017
|
+
`Invalid metadata field ${path}: expected string, number, boolean, or object with url.`,
|
|
1018
|
+
);
|
|
954
1019
|
}
|
|
955
1020
|
|
|
956
1021
|
function robotsContent(robots: NonNullable<RouteMetadata["robots"]>): string {
|
package/src/navigation-marker.ts
CHANGED
|
@@ -16,10 +16,13 @@ interface ParsedStartTag {
|
|
|
16
16
|
end: number;
|
|
17
17
|
hasMarker: boolean;
|
|
18
18
|
name: string;
|
|
19
|
+
selfClosing: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function hasNavigationRouteMarker(html: string): boolean {
|
|
22
23
|
let cursor = 0;
|
|
24
|
+
let foreignContentDepth = 0;
|
|
25
|
+
let templateDepth = 0;
|
|
23
26
|
|
|
24
27
|
while (cursor < html.length) {
|
|
25
28
|
const openingBracket = html.indexOf("<", cursor);
|
|
@@ -33,12 +36,28 @@ export function hasNavigationRouteMarker(html: string): boolean {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
if (html.startsWith("<![CDATA[", openingBracket)) {
|
|
36
|
-
cursor =
|
|
39
|
+
cursor =
|
|
40
|
+
foreignContentDepth > 0
|
|
41
|
+
? skipCdataSection(html, openingBracket + 9)
|
|
42
|
+
: skipBogusComment(html, openingBracket + 2);
|
|
37
43
|
continue;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
const discriminator = html[openingBracket + 1];
|
|
41
|
-
if (discriminator === "
|
|
47
|
+
if (discriminator === "/") {
|
|
48
|
+
const endTag = parseEndTag(html, openingBracket);
|
|
49
|
+
if (endTag.name === "template" && templateDepth > 0) {
|
|
50
|
+
templateDepth -= 1;
|
|
51
|
+
} else if (
|
|
52
|
+
(endTag.name === "svg" || endTag.name === "math") &&
|
|
53
|
+
foreignContentDepth > 0
|
|
54
|
+
) {
|
|
55
|
+
foreignContentDepth -= 1;
|
|
56
|
+
}
|
|
57
|
+
cursor = endTag.end;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (discriminator === "!" || discriminator === "?") {
|
|
42
61
|
cursor = skipTagTail(html, openingBracket + 2);
|
|
43
62
|
continue;
|
|
44
63
|
}
|
|
@@ -52,7 +71,14 @@ export function hasNavigationRouteMarker(html: string): boolean {
|
|
|
52
71
|
cursor = openingBracket + 1;
|
|
53
72
|
continue;
|
|
54
73
|
}
|
|
55
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
tag.hasMarker &&
|
|
76
|
+
templateDepth === 0 &&
|
|
77
|
+
tag.name !== "html" &&
|
|
78
|
+
tag.name !== "head" &&
|
|
79
|
+
tag.name !== "body" &&
|
|
80
|
+
tag.name !== "frameset"
|
|
81
|
+
) {
|
|
56
82
|
return true;
|
|
57
83
|
}
|
|
58
84
|
|
|
@@ -60,10 +86,16 @@ export function hasNavigationRouteMarker(html: string): boolean {
|
|
|
60
86
|
return false;
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
if (!tag.selfClosing && tag.name === "template") {
|
|
90
|
+
templateDepth += 1;
|
|
91
|
+
} else if (!tag.selfClosing && (tag.name === "svg" || tag.name === "math")) {
|
|
92
|
+
foreignContentDepth += 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
63
95
|
cursor =
|
|
64
96
|
tag.name === "script"
|
|
65
97
|
? skipScriptElement(html, tag.end)
|
|
66
|
-
: rawTextElementNames.has(tag.name)
|
|
98
|
+
: foreignContentDepth === 0 && rawTextElementNames.has(tag.name)
|
|
67
99
|
? skipRawTextElement(html, tag.end, tag.name)
|
|
68
100
|
: tag.end;
|
|
69
101
|
}
|
|
@@ -92,13 +124,23 @@ function parseStartTag(html: string, openingBracket: number): ParsedStartTag | u
|
|
|
92
124
|
for (;;) {
|
|
93
125
|
cursor = skipAsciiWhitespace(html, cursor);
|
|
94
126
|
if (cursor >= html.length) {
|
|
95
|
-
return { end: html.length, hasMarker: false, name };
|
|
127
|
+
return { end: html.length, hasMarker: false, name, selfClosing: false };
|
|
96
128
|
}
|
|
97
129
|
if (html[cursor] === ">") {
|
|
98
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
end: cursor + 1,
|
|
132
|
+
hasMarker: markerValueIsNonEmpty === true,
|
|
133
|
+
name,
|
|
134
|
+
selfClosing: false,
|
|
135
|
+
};
|
|
99
136
|
}
|
|
100
137
|
if (html[cursor] === "/" && html[cursor + 1] === ">") {
|
|
101
|
-
return {
|
|
138
|
+
return {
|
|
139
|
+
end: cursor + 2,
|
|
140
|
+
hasMarker: markerValueIsNonEmpty === true,
|
|
141
|
+
name,
|
|
142
|
+
selfClosing: true,
|
|
143
|
+
};
|
|
102
144
|
}
|
|
103
145
|
if (html[cursor] === "/") {
|
|
104
146
|
return invalidStartTag(html, cursor, name);
|
|
@@ -127,7 +169,7 @@ function parseStartTag(html: string, openingBracket: number): ParsedStartTag | u
|
|
|
127
169
|
const valueStart = cursor + 1;
|
|
128
170
|
const valueEnd = html.indexOf(quote, valueStart);
|
|
129
171
|
if (valueEnd < 0) {
|
|
130
|
-
return { end: html.length, hasMarker: false, name };
|
|
172
|
+
return { end: html.length, hasMarker: false, name, selfClosing: false };
|
|
131
173
|
}
|
|
132
174
|
valueIsNonEmpty = valueEnd > valueStart;
|
|
133
175
|
cursor = valueEnd + 1;
|
|
@@ -153,10 +195,16 @@ function parseStartTag(html: string, openingBracket: number): ParsedStartTag | u
|
|
|
153
195
|
}
|
|
154
196
|
|
|
155
197
|
function invalidStartTag(html: string, cursor: number, name: string): ParsedStartTag {
|
|
156
|
-
return { end:
|
|
198
|
+
return { end: skipMalformedTagTail(html, cursor), hasMarker: false, name, selfClosing: false };
|
|
157
199
|
}
|
|
158
200
|
|
|
159
201
|
function skipComment(html: string, cursor: number): number {
|
|
202
|
+
if (html[cursor] === ">") {
|
|
203
|
+
return cursor + 1;
|
|
204
|
+
}
|
|
205
|
+
if (html.startsWith("->", cursor)) {
|
|
206
|
+
return cursor + 2;
|
|
207
|
+
}
|
|
160
208
|
const standardEnd = html.indexOf("-->", cursor);
|
|
161
209
|
const abruptEnd = html.indexOf("--!>", cursor);
|
|
162
210
|
const end =
|
|
@@ -164,6 +212,28 @@ function skipComment(html: string, cursor: number): number {
|
|
|
164
212
|
return end < 0 ? html.length : end + (end === abruptEnd ? 4 : 3);
|
|
165
213
|
}
|
|
166
214
|
|
|
215
|
+
function skipBogusComment(html: string, cursor: number): number {
|
|
216
|
+
const end = html.indexOf(">", cursor);
|
|
217
|
+
return end < 0 ? html.length : end + 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseEndTag(html: string, openingBracket: number): { end: number; name: string } {
|
|
221
|
+
let cursor = openingBracket + 2;
|
|
222
|
+
const nameStart = cursor;
|
|
223
|
+
while (isAsciiLetter(html[cursor])) {
|
|
224
|
+
cursor += 1;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
end: skipTagTail(html, cursor),
|
|
228
|
+
name: html.slice(nameStart, cursor).toLowerCase(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function skipMalformedTagTail(html: string, cursor: number): number {
|
|
233
|
+
const end = html.indexOf(">", cursor);
|
|
234
|
+
return end < 0 ? html.length : end + 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
167
237
|
function skipCdataSection(html: string, cursor: number): number {
|
|
168
238
|
const end = html.indexOf("]]>", cursor);
|
|
169
239
|
return end < 0 ? html.length : end + 3;
|
package/src/navigation.ts
CHANGED
|
@@ -46,7 +46,7 @@ function containsControlCharacter(value: string): boolean {
|
|
|
46
46
|
// Allowed: path-absolute (`/foo`), query-only (`?x=1`), hash-only (`#x`),
|
|
47
47
|
// relative (`foo`). Rejected: protocol-relative (`//evil`), backslash variants
|
|
48
48
|
// (`/\evil`, `\\evil`), and anything with a scheme like `javascript:`.
|
|
49
|
-
function isSafeInternalRedirect(location: string): boolean {
|
|
49
|
+
export function isSafeInternalRedirect(location: string): boolean {
|
|
50
50
|
if (containsControlCharacter(location)) return false;
|
|
51
51
|
const trimmed = stripLeadingControlOrWhitespace(location);
|
|
52
52
|
if (trimmed === "") return false;
|
package/src/node-server.ts
CHANGED
|
@@ -9,28 +9,43 @@ import {
|
|
|
9
9
|
requestLogFields,
|
|
10
10
|
type AppRouterLogger,
|
|
11
11
|
} from "./logger.js";
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
assertValidHttpUpgradeOriginPolicy,
|
|
14
|
+
closeServerWithUpgrades,
|
|
15
|
+
createManagedHttpUpgradeLifecycle,
|
|
16
|
+
validateHttpUpgradeOrigin,
|
|
17
|
+
type HttpUpgradeOriginPolicy,
|
|
18
|
+
type HttpUpgradeOriginValidation,
|
|
19
|
+
type ManagedHttpUpgradeHandler,
|
|
20
|
+
} from "./upgrade.js";
|
|
13
21
|
|
|
14
22
|
export interface StartNodeRequestServerOptions {
|
|
15
23
|
allowedHosts?: readonly string[] | undefined;
|
|
16
|
-
errorHandler?:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
errorHandler?:
|
|
25
|
+
| ((error: unknown) => {
|
|
26
|
+
body: string;
|
|
27
|
+
status: number;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
})
|
|
30
|
+
| undefined;
|
|
21
31
|
hostname?: string | undefined;
|
|
22
32
|
hostPolicy?: "strict" | "trusted-proxy" | undefined;
|
|
23
33
|
logger?: AppRouterLogger | undefined;
|
|
24
|
-
onUpgrade?:
|
|
34
|
+
onUpgrade?: ManagedHttpUpgradeHandler | undefined;
|
|
25
35
|
port: number;
|
|
26
36
|
render(request: Request): Promise<Response>;
|
|
27
|
-
resolveHost?:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
resolveHost?:
|
|
38
|
+
| ((options: {
|
|
39
|
+
allowedHosts?: readonly string[] | undefined;
|
|
40
|
+
fallbackHost: string;
|
|
41
|
+
hostPolicy?: "strict" | "trusted-proxy" | undefined;
|
|
42
|
+
rawHost: string | undefined;
|
|
43
|
+
}) => string)
|
|
44
|
+
| undefined;
|
|
33
45
|
trustForwardedProto?: boolean | undefined;
|
|
46
|
+
upgradeCloseTimeoutMs?: number | undefined;
|
|
47
|
+
upgradeDecisionTimeoutMs?: number | undefined;
|
|
48
|
+
upgradeOriginPolicy?: HttpUpgradeOriginPolicy | undefined;
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
export function resolveNodeRequestProtocol(options: {
|
|
@@ -56,6 +71,18 @@ export function resolveNodeRequestProtocol(options: {
|
|
|
56
71
|
export async function startNodeRequestServer(
|
|
57
72
|
options: StartNodeRequestServerOptions,
|
|
58
73
|
): Promise<{ close(): Promise<void>; server: Server; url: string }> {
|
|
74
|
+
const upgradeCloseTimeoutMs = finiteNonNegativeTimeout(
|
|
75
|
+
options.upgradeCloseTimeoutMs,
|
|
76
|
+
1_000,
|
|
77
|
+
"upgradeCloseTimeoutMs",
|
|
78
|
+
);
|
|
79
|
+
const upgradeDecisionTimeoutMs = finiteNonNegativeTimeout(
|
|
80
|
+
options.upgradeDecisionTimeoutMs,
|
|
81
|
+
1_000,
|
|
82
|
+
"upgradeDecisionTimeoutMs",
|
|
83
|
+
);
|
|
84
|
+
assertValidHttpUpgradeOriginPolicy(options.upgradeOriginPolicy);
|
|
85
|
+
let listeningPort = options.port;
|
|
59
86
|
const server = createServer(async (incoming, outgoing) => {
|
|
60
87
|
const startedAt = logNow();
|
|
61
88
|
const fallbackRequestFields = {
|
|
@@ -65,7 +92,7 @@ export async function startNodeRequestServer(
|
|
|
65
92
|
};
|
|
66
93
|
|
|
67
94
|
try {
|
|
68
|
-
const fallbackHost =
|
|
95
|
+
const fallbackHost = formatNodeAuthority(options.hostname ?? "127.0.0.1", options.port);
|
|
69
96
|
const host = (options.resolveHost ?? defaultResolveRequestHost)({
|
|
70
97
|
allowedHosts: options.allowedHosts,
|
|
71
98
|
fallbackHost,
|
|
@@ -77,7 +104,7 @@ export async function startNodeRequestServer(
|
|
|
77
104
|
forwardedProto: incoming.headers["x-forwarded-proto"],
|
|
78
105
|
trustForwardedProto: options.trustForwardedProto,
|
|
79
106
|
});
|
|
80
|
-
const request = nodeRequestToWebRequest(incoming, `${protocol}://${host}
|
|
107
|
+
const request = nodeRequestToWebRequest(incoming, `${protocol}://${host}`, outgoing);
|
|
81
108
|
const logFields = requestLogFields(request, "node");
|
|
82
109
|
emitRouterLog(options.logger, "info", {
|
|
83
110
|
...logFields,
|
|
@@ -120,8 +147,21 @@ export async function startNodeRequestServer(
|
|
|
120
147
|
}
|
|
121
148
|
});
|
|
122
149
|
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
const upgradeLifecycle =
|
|
151
|
+
options.onUpgrade === undefined
|
|
152
|
+
? undefined
|
|
153
|
+
: createManagedHttpUpgradeLifecycle({
|
|
154
|
+
decisionTimeoutMs: upgradeDecisionTimeoutMs,
|
|
155
|
+
handler: options.onUpgrade,
|
|
156
|
+
isOriginAllowed: (request) =>
|
|
157
|
+
validateNodeUpgradeOrigin(request, {
|
|
158
|
+
...options,
|
|
159
|
+
listeningPort,
|
|
160
|
+
}),
|
|
161
|
+
logger: options.logger,
|
|
162
|
+
});
|
|
163
|
+
if (upgradeLifecycle !== undefined) {
|
|
164
|
+
server.on("upgrade", upgradeLifecycle.handle);
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
await new Promise<void>((resolve) =>
|
|
@@ -129,17 +169,83 @@ export async function startNodeRequestServer(
|
|
|
129
169
|
);
|
|
130
170
|
const address = server.address();
|
|
131
171
|
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
172
|
+
listeningPort = port;
|
|
173
|
+
let closePromise: Promise<void> | undefined;
|
|
132
174
|
|
|
133
175
|
return {
|
|
134
176
|
server,
|
|
135
|
-
url: `http://${options.hostname ?? "127.0.0.1"
|
|
136
|
-
close: () =>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
url: `http://${formatNodeAuthority(options.hostname ?? "127.0.0.1", port)}`,
|
|
178
|
+
close: () => {
|
|
179
|
+
closePromise ??=
|
|
180
|
+
upgradeLifecycle === undefined
|
|
181
|
+
? new Promise<void>((resolve, reject) =>
|
|
182
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
183
|
+
)
|
|
184
|
+
: closeServerWithUpgrades({
|
|
185
|
+
lifecycle: upgradeLifecycle,
|
|
186
|
+
server,
|
|
187
|
+
timeoutMs: upgradeCloseTimeoutMs,
|
|
188
|
+
});
|
|
189
|
+
return closePromise;
|
|
190
|
+
},
|
|
140
191
|
};
|
|
141
192
|
}
|
|
142
193
|
|
|
194
|
+
function validateNodeUpgradeOrigin(
|
|
195
|
+
request: import("node:http").IncomingMessage,
|
|
196
|
+
options: StartNodeRequestServerOptions & { listeningPort: number },
|
|
197
|
+
): HttpUpgradeOriginValidation {
|
|
198
|
+
const policy = options.upgradeOriginPolicy ?? "same-origin";
|
|
199
|
+
if (policy === "unchecked") {
|
|
200
|
+
return { ok: true, origin: request.headers.origin };
|
|
201
|
+
}
|
|
202
|
+
if (typeof policy === "object") {
|
|
203
|
+
return validateHttpUpgradeOrigin(request, policy);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fallbackHost = formatNodeAuthority(options.hostname ?? "127.0.0.1", options.listeningPort);
|
|
207
|
+
const host = (options.resolveHost ?? defaultResolveRequestHost)({
|
|
208
|
+
allowedHosts: options.allowedHosts,
|
|
209
|
+
fallbackHost,
|
|
210
|
+
hostPolicy: options.hostPolicy,
|
|
211
|
+
rawHost: request.headers.host,
|
|
212
|
+
});
|
|
213
|
+
const protocol = resolveNodeRequestProtocol({
|
|
214
|
+
encrypted: (request.socket as { encrypted?: boolean }).encrypted === true,
|
|
215
|
+
forwardedProto: request.headers["x-forwarded-proto"],
|
|
216
|
+
trustForwardedProto: options.trustForwardedProto,
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
return validateHttpUpgradeOrigin(request, { allowedOrigins: [`${protocol}://${host}`] });
|
|
220
|
+
} catch {
|
|
221
|
+
return { ok: false, reason: "malformed-origin" };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatNodeAuthority(hostname: string, port: number): string {
|
|
226
|
+
const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
227
|
+
return `${host}:${port}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function finiteNonNegativeTimeout(
|
|
231
|
+
value: number | undefined,
|
|
232
|
+
fallback: number,
|
|
233
|
+
name: string,
|
|
234
|
+
): number {
|
|
235
|
+
const timeout = value ?? fallback;
|
|
236
|
+
if (
|
|
237
|
+
!Number.isFinite(timeout) ||
|
|
238
|
+
timeout < 0 ||
|
|
239
|
+
!Number.isSafeInteger(timeout) ||
|
|
240
|
+
timeout > 2_147_483_647
|
|
241
|
+
) {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
`${name} must be a finite non-negative safe integer no greater than 2147483647.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return timeout;
|
|
247
|
+
}
|
|
248
|
+
|
|
143
249
|
function defaultResolveRequestHost(options: {
|
|
144
250
|
allowedHosts?: readonly string[] | undefined;
|
|
145
251
|
fallbackHost: string;
|