@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.
Files changed (140) hide show
  1. package/README.md +53 -9
  2. package/dist/actions.d.ts +6 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +70 -10
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/aws-lambda.d.ts +15 -4
  7. package/dist/adapters/aws-lambda.d.ts.map +1 -1
  8. package/dist/adapters/aws-lambda.js +301 -63
  9. package/dist/adapters/aws-lambda.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +2 -1
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +18 -10
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/edge.d.ts +1 -1
  15. package/dist/adapters/edge.d.ts.map +1 -1
  16. package/dist/adapters/edge.js.map +1 -1
  17. package/dist/adapters/node.d.ts +5 -3
  18. package/dist/adapters/node.d.ts.map +1 -1
  19. package/dist/adapters/node.js +2 -1
  20. package/dist/adapters/node.js.map +1 -1
  21. package/dist/adapters/static.d.ts.map +1 -1
  22. package/dist/adapters/static.js +24 -2
  23. package/dist/adapters/static.js.map +1 -1
  24. package/dist/build.d.ts +1 -0
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +91 -5
  27. package/dist/build.js.map +1 -1
  28. package/dist/built-runtime.d.ts +2 -0
  29. package/dist/built-runtime.d.ts.map +1 -1
  30. package/dist/built-runtime.js +16 -0
  31. package/dist/built-runtime.js.map +1 -1
  32. package/dist/cache.d.ts +3 -1
  33. package/dist/cache.d.ts.map +1 -1
  34. package/dist/cache.js +211 -13
  35. package/dist/cache.js.map +1 -1
  36. package/dist/cli.js +12 -1
  37. package/dist/cli.js.map +1 -1
  38. package/dist/client.d.ts.map +1 -1
  39. package/dist/client.js +32 -1
  40. package/dist/client.js.map +1 -1
  41. package/dist/config.d.ts +3 -0
  42. package/dist/config.d.ts.map +1 -1
  43. package/dist/config.js +21 -2
  44. package/dist/config.js.map +1 -1
  45. package/dist/csp.d.ts +1 -0
  46. package/dist/csp.d.ts.map +1 -1
  47. package/dist/csp.js +11 -6
  48. package/dist/csp.js.map +1 -1
  49. package/dist/dehydrate-policy.d.ts +3 -0
  50. package/dist/dehydrate-policy.d.ts.map +1 -0
  51. package/dist/dehydrate-policy.js +16 -0
  52. package/dist/dehydrate-policy.js.map +1 -0
  53. package/dist/dev-server.d.ts +7 -2
  54. package/dist/dev-server.d.ts.map +1 -1
  55. package/dist/dev-server.js +120 -11
  56. package/dist/dev-server.js.map +1 -1
  57. package/dist/http.d.ts +1 -1
  58. package/dist/http.d.ts.map +1 -1
  59. package/dist/http.js +147 -7
  60. package/dist/http.js.map +1 -1
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/logger.d.ts +22 -1
  66. package/dist/logger.d.ts.map +1 -1
  67. package/dist/logger.js +22 -8
  68. package/dist/logger.js.map +1 -1
  69. package/dist/metadata.d.ts +8 -0
  70. package/dist/metadata.d.ts.map +1 -1
  71. package/dist/metadata.js +60 -22
  72. package/dist/metadata.js.map +1 -1
  73. package/dist/navigation-marker.d.ts.map +1 -1
  74. package/dist/navigation-marker.js +72 -9
  75. package/dist/navigation-marker.js.map +1 -1
  76. package/dist/navigation.d.ts +1 -0
  77. package/dist/navigation.d.ts.map +1 -1
  78. package/dist/navigation.js +1 -1
  79. package/dist/navigation.js.map +1 -1
  80. package/dist/node-server.d.ts +5 -2
  81. package/dist/node-server.d.ts.map +1 -1
  82. package/dist/node-server.js +75 -6
  83. package/dist/node-server.js.map +1 -1
  84. package/dist/prerender-entry.d.ts +14 -0
  85. package/dist/prerender-entry.d.ts.map +1 -1
  86. package/dist/prerender-entry.js +40 -3
  87. package/dist/prerender-entry.js.map +1 -1
  88. package/dist/render.d.ts +9 -1
  89. package/dist/render.d.ts.map +1 -1
  90. package/dist/render.js +102 -123
  91. package/dist/render.js.map +1 -1
  92. package/dist/serve.d.ts +8 -2
  93. package/dist/serve.d.ts.map +1 -1
  94. package/dist/serve.js +22 -20
  95. package/dist/serve.js.map +1 -1
  96. package/dist/session.d.ts.map +1 -1
  97. package/dist/session.js +52 -19
  98. package/dist/session.js.map +1 -1
  99. package/dist/upgrade.d.ts +59 -2
  100. package/dist/upgrade.d.ts.map +1 -1
  101. package/dist/upgrade.js +282 -1
  102. package/dist/upgrade.js.map +1 -1
  103. package/dist/vite-config.d.ts +2 -0
  104. package/dist/vite-config.d.ts.map +1 -1
  105. package/dist/vite-config.js +2 -0
  106. package/dist/vite-config.js.map +1 -1
  107. package/dist/vite.d.ts +4 -1
  108. package/dist/vite.d.ts.map +1 -1
  109. package/dist/vite.js +117 -6
  110. package/dist/vite.js.map +1 -1
  111. package/package.json +11 -11
  112. package/src/actions.ts +81 -11
  113. package/src/adapters/aws-lambda.ts +426 -115
  114. package/src/adapters/cloudflare.ts +29 -13
  115. package/src/adapters/edge.ts +2 -0
  116. package/src/adapters/node.ts +8 -10
  117. package/src/adapters/static.ts +31 -2
  118. package/src/build.ts +117 -10
  119. package/src/built-runtime.ts +28 -0
  120. package/src/cache.ts +262 -15
  121. package/src/cli.ts +14 -1
  122. package/src/client.ts +32 -1
  123. package/src/config.ts +43 -6
  124. package/src/csp.ts +14 -11
  125. package/src/dehydrate-policy.ts +23 -0
  126. package/src/dev-server.ts +188 -15
  127. package/src/http.ts +172 -6
  128. package/src/index.ts +14 -1
  129. package/src/logger.ts +47 -10
  130. package/src/metadata.ts +125 -60
  131. package/src/navigation-marker.ts +79 -9
  132. package/src/navigation.ts +1 -1
  133. package/src/node-server.ts +128 -22
  134. package/src/prerender-entry.ts +52 -3
  135. package/src/render.ts +807 -753
  136. package/src/serve.ts +49 -56
  137. package/src/session.ts +56 -25
  138. package/src/upgrade.ts +402 -3
  139. package/src/vite-config.ts +6 -0
  140. 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
- const next: RouteMetadata = metadata === undefined ? {} : { ...metadata };
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 openGraphImagePath = fileConventionMetadataRoutePath(
76
- routes,
77
- filename,
78
- params,
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
- if (iconRoute !== undefined && next.icons?.icon === undefined) {
83
- next.icons = { ...next.icons, icon: iconRoute.path };
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 (appleIconRoute !== undefined && next.icons?.apple === undefined) {
86
- next.icons = { ...next.icons, apple: appleIconRoute.path };
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(`Invalid metadata field ${descriptorPath}.nonce: expected string or boolean.`);
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(`Invalid metadata field ${path}: event and dangerous attributes are not allowed.`);
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(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
- ]));
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(value: unknown, path: string): asserts value is Record<string, unknown> {
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 ? [] : [metadataImageUrl(openGraph.image, "openGraph.image")];
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(`Invalid metadata field ${path}: expected string, number, boolean, or object with url.`);
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 {
@@ -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 = skipCdataSection(html, openingBracket + 9);
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 === "!" || discriminator === "?" || 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 (tag.hasMarker) {
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 { end: cursor + 1, hasMarker: markerValueIsNonEmpty === true, name };
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 { end: cursor + 2, hasMarker: markerValueIsNonEmpty === true, name };
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: skipTagTail(html, cursor), hasMarker: false, name };
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;
@@ -9,28 +9,43 @@ import {
9
9
  requestLogFields,
10
10
  type AppRouterLogger,
11
11
  } from "./logger.js";
12
- import type { HttpUpgradeHandler } from "./upgrade.js";
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?: ((error: unknown) => {
17
- body: string;
18
- status: number;
19
- headers?: Record<string, string>;
20
- }) | undefined;
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?: HttpUpgradeHandler | undefined;
34
+ onUpgrade?: ManagedHttpUpgradeHandler | undefined;
25
35
  port: number;
26
36
  render(request: Request): Promise<Response>;
27
- resolveHost?: ((options: {
28
- allowedHosts?: readonly string[] | undefined;
29
- fallbackHost: string;
30
- hostPolicy?: "strict" | "trusted-proxy" | undefined;
31
- rawHost: string | undefined;
32
- }) => string) | undefined;
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 = `${options.hostname ?? "127.0.0.1"}:${options.port}`;
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
- if (options.onUpgrade !== undefined) {
124
- server.on("upgrade", options.onUpgrade);
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"}:${port}`,
136
- close: () =>
137
- new Promise<void>((resolve, reject) =>
138
- server.close((error) => (error ? reject(error) : resolve())),
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;