@reckona/mreact-router 0.0.202 → 0.0.204

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 (152) hide show
  1. package/README.md +58 -11
  2. package/dist/actions.d.ts +8 -3
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +125 -45
  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 +5 -2
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +35 -11
  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 +4 -1
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +185 -13
  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 +4 -2
  33. package/dist/cache.d.ts.map +1 -1
  34. package/dist/cache.js +216 -17
  35. package/dist/cache.js.map +1 -1
  36. package/dist/cli-options.d.ts +3 -0
  37. package/dist/cli-options.d.ts.map +1 -1
  38. package/dist/cli-options.js +10 -2
  39. package/dist/cli-options.js.map +1 -1
  40. package/dist/cli.js +14 -3
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client.d.ts +5 -0
  43. package/dist/client.d.ts.map +1 -1
  44. package/dist/client.js +43 -3
  45. package/dist/client.js.map +1 -1
  46. package/dist/config.d.ts +3 -0
  47. package/dist/config.d.ts.map +1 -1
  48. package/dist/config.js +21 -2
  49. package/dist/config.js.map +1 -1
  50. package/dist/csp.d.ts +1 -0
  51. package/dist/csp.d.ts.map +1 -1
  52. package/dist/csp.js +9 -0
  53. package/dist/csp.js.map +1 -1
  54. package/dist/dehydrate-policy.d.ts +3 -0
  55. package/dist/dehydrate-policy.d.ts.map +1 -0
  56. package/dist/dehydrate-policy.js +16 -0
  57. package/dist/dehydrate-policy.js.map +1 -0
  58. package/dist/dev-server.d.ts +7 -2
  59. package/dist/dev-server.d.ts.map +1 -1
  60. package/dist/dev-server.js +120 -11
  61. package/dist/dev-server.js.map +1 -1
  62. package/dist/http.d.ts +1 -1
  63. package/dist/http.d.ts.map +1 -1
  64. package/dist/http.js +147 -7
  65. package/dist/http.js.map +1 -1
  66. package/dist/index.d.ts +4 -3
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/logger.d.ts +22 -1
  71. package/dist/logger.d.ts.map +1 -1
  72. package/dist/logger.js +22 -8
  73. package/dist/logger.js.map +1 -1
  74. package/dist/metadata.d.ts +8 -0
  75. package/dist/metadata.d.ts.map +1 -1
  76. package/dist/metadata.js +60 -22
  77. package/dist/metadata.js.map +1 -1
  78. package/dist/navigation-marker.d.ts +2 -0
  79. package/dist/navigation-marker.d.ts.map +1 -0
  80. package/dist/navigation-marker.js +357 -0
  81. package/dist/navigation-marker.js.map +1 -0
  82. package/dist/navigation-runtime.d.ts +1 -1
  83. package/dist/navigation-runtime.d.ts.map +1 -1
  84. package/dist/navigation-runtime.js +1 -1
  85. package/dist/navigation-runtime.js.map +1 -1
  86. package/dist/navigation.d.ts +1 -0
  87. package/dist/navigation.d.ts.map +1 -1
  88. package/dist/navigation.js +1 -1
  89. package/dist/navigation.js.map +1 -1
  90. package/dist/node-server.d.ts +5 -2
  91. package/dist/node-server.d.ts.map +1 -1
  92. package/dist/node-server.js +75 -6
  93. package/dist/node-server.js.map +1 -1
  94. package/dist/prerender-entry.d.ts +17 -1
  95. package/dist/prerender-entry.d.ts.map +1 -1
  96. package/dist/prerender-entry.js +98 -3
  97. package/dist/prerender-entry.js.map +1 -1
  98. package/dist/render.d.ts +16 -2
  99. package/dist/render.d.ts.map +1 -1
  100. package/dist/render.js +142 -135
  101. package/dist/render.js.map +1 -1
  102. package/dist/serve.d.ts +11 -2
  103. package/dist/serve.d.ts.map +1 -1
  104. package/dist/serve.js +67 -41
  105. package/dist/serve.js.map +1 -1
  106. package/dist/session.d.ts.map +1 -1
  107. package/dist/session.js +52 -19
  108. package/dist/session.js.map +1 -1
  109. package/dist/upgrade.d.ts +59 -2
  110. package/dist/upgrade.d.ts.map +1 -1
  111. package/dist/upgrade.js +282 -1
  112. package/dist/upgrade.js.map +1 -1
  113. package/dist/vite-config.d.ts +2 -0
  114. package/dist/vite-config.d.ts.map +1 -1
  115. package/dist/vite-config.js +2 -0
  116. package/dist/vite-config.js.map +1 -1
  117. package/dist/vite.d.ts +4 -1
  118. package/dist/vite.d.ts.map +1 -1
  119. package/dist/vite.js +117 -6
  120. package/dist/vite.js.map +1 -1
  121. package/package.json +11 -11
  122. package/src/actions.ts +146 -60
  123. package/src/adapters/aws-lambda.ts +426 -115
  124. package/src/adapters/cloudflare.ts +58 -12
  125. package/src/adapters/edge.ts +2 -0
  126. package/src/adapters/node.ts +8 -10
  127. package/src/adapters/static.ts +31 -2
  128. package/src/build.ts +217 -13
  129. package/src/built-runtime.ts +28 -0
  130. package/src/cache.ts +272 -19
  131. package/src/cli-options.ts +15 -2
  132. package/src/cli.ts +16 -2
  133. package/src/client.ts +47 -3
  134. package/src/config.ts +43 -6
  135. package/src/csp.ts +11 -0
  136. package/src/dehydrate-policy.ts +23 -0
  137. package/src/dev-server.ts +188 -15
  138. package/src/http.ts +172 -6
  139. package/src/index.ts +14 -1
  140. package/src/logger.ts +47 -10
  141. package/src/metadata.ts +125 -60
  142. package/src/navigation-marker.ts +423 -0
  143. package/src/navigation-runtime.ts +1 -0
  144. package/src/navigation.ts +1 -1
  145. package/src/node-server.ts +128 -22
  146. package/src/prerender-entry.ts +127 -5
  147. package/src/render.ts +859 -749
  148. package/src/serve.ts +112 -82
  149. package/src/session.ts +56 -25
  150. package/src/upgrade.ts +402 -3
  151. package/src/vite-config.ts +6 -0
  152. package/src/vite.ts +157 -5
@@ -0,0 +1,423 @@
1
+ import { routeHydrationContract } from "./route-hydration-contract.js";
2
+
3
+ const markerAttribute = routeHydrationContract.routeMarkerAttribute;
4
+ const rawTextElementNames = new Set([
5
+ "iframe",
6
+ "noembed",
7
+ "noframes",
8
+ "noscript",
9
+ "style",
10
+ "textarea",
11
+ "title",
12
+ "xmp",
13
+ ]);
14
+
15
+ interface ParsedStartTag {
16
+ end: number;
17
+ hasMarker: boolean;
18
+ name: string;
19
+ selfClosing: boolean;
20
+ }
21
+
22
+ export function hasNavigationRouteMarker(html: string): boolean {
23
+ let cursor = 0;
24
+ let foreignContentDepth = 0;
25
+ let templateDepth = 0;
26
+
27
+ while (cursor < html.length) {
28
+ const openingBracket = html.indexOf("<", cursor);
29
+ if (openingBracket < 0) {
30
+ return false;
31
+ }
32
+
33
+ if (html.startsWith("<!--", openingBracket)) {
34
+ cursor = skipComment(html, openingBracket + 4);
35
+ continue;
36
+ }
37
+
38
+ if (html.startsWith("<![CDATA[", openingBracket)) {
39
+ cursor =
40
+ foreignContentDepth > 0
41
+ ? skipCdataSection(html, openingBracket + 9)
42
+ : skipBogusComment(html, openingBracket + 2);
43
+ continue;
44
+ }
45
+
46
+ const discriminator = html[openingBracket + 1];
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 === "?") {
61
+ cursor = skipTagTail(html, openingBracket + 2);
62
+ continue;
63
+ }
64
+ if (!isAsciiLetter(discriminator)) {
65
+ cursor = openingBracket + 1;
66
+ continue;
67
+ }
68
+
69
+ const tag = parseStartTag(html, openingBracket);
70
+ if (tag === undefined) {
71
+ cursor = openingBracket + 1;
72
+ continue;
73
+ }
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
+ ) {
82
+ return true;
83
+ }
84
+
85
+ if (tag.name === "plaintext") {
86
+ return false;
87
+ }
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
+
95
+ cursor =
96
+ tag.name === "script"
97
+ ? skipScriptElement(html, tag.end)
98
+ : foreignContentDepth === 0 && rawTextElementNames.has(tag.name)
99
+ ? skipRawTextElement(html, tag.end, tag.name)
100
+ : tag.end;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ function parseStartTag(html: string, openingBracket: number): ParsedStartTag | undefined {
107
+ let cursor = openingBracket + 1;
108
+ const nameStart = cursor;
109
+
110
+ while (cursor < html.length && !isTagNameTerminator(html[cursor])) {
111
+ if (isForbiddenTagNameCharacter(html[cursor])) {
112
+ return invalidStartTag(html, cursor, "");
113
+ }
114
+ cursor += 1;
115
+ }
116
+
117
+ if (cursor === nameStart) {
118
+ return undefined;
119
+ }
120
+
121
+ const name = html.slice(nameStart, cursor).toLowerCase();
122
+ let markerValueIsNonEmpty: boolean | undefined;
123
+
124
+ for (;;) {
125
+ cursor = skipAsciiWhitespace(html, cursor);
126
+ if (cursor >= html.length) {
127
+ return { end: html.length, hasMarker: false, name, selfClosing: false };
128
+ }
129
+ if (html[cursor] === ">") {
130
+ return {
131
+ end: cursor + 1,
132
+ hasMarker: markerValueIsNonEmpty === true,
133
+ name,
134
+ selfClosing: false,
135
+ };
136
+ }
137
+ if (html[cursor] === "/" && html[cursor + 1] === ">") {
138
+ return {
139
+ end: cursor + 2,
140
+ hasMarker: markerValueIsNonEmpty === true,
141
+ name,
142
+ selfClosing: true,
143
+ };
144
+ }
145
+ if (html[cursor] === "/") {
146
+ return invalidStartTag(html, cursor, name);
147
+ }
148
+
149
+ const attributeNameStart = cursor;
150
+ while (cursor < html.length && !isAttributeNameTerminator(html[cursor])) {
151
+ if (isForbiddenAttributeNameCharacter(html[cursor])) {
152
+ return invalidStartTag(html, cursor, name);
153
+ }
154
+ cursor += 1;
155
+ }
156
+ if (cursor === attributeNameStart) {
157
+ return invalidStartTag(html, cursor, name);
158
+ }
159
+
160
+ const attributeName = html.slice(attributeNameStart, cursor).toLowerCase();
161
+ cursor = skipAsciiWhitespace(html, cursor);
162
+ let valueIsNonEmpty = false;
163
+
164
+ if (html[cursor] === "=") {
165
+ cursor = skipAsciiWhitespace(html, cursor + 1);
166
+ const quote = html[cursor];
167
+
168
+ if (quote === '"' || quote === "'") {
169
+ const valueStart = cursor + 1;
170
+ const valueEnd = html.indexOf(quote, valueStart);
171
+ if (valueEnd < 0) {
172
+ return { end: html.length, hasMarker: false, name, selfClosing: false };
173
+ }
174
+ valueIsNonEmpty = valueEnd > valueStart;
175
+ cursor = valueEnd + 1;
176
+ } else {
177
+ const valueStart = cursor;
178
+ while (cursor < html.length && !isUnquotedValueTerminator(html[cursor])) {
179
+ if (isForbiddenUnquotedValueCharacter(html[cursor])) {
180
+ return invalidStartTag(html, cursor, name);
181
+ }
182
+ cursor += 1;
183
+ }
184
+ if (cursor === valueStart) {
185
+ return invalidStartTag(html, cursor, name);
186
+ }
187
+ valueIsNonEmpty = true;
188
+ }
189
+ }
190
+
191
+ if (attributeName === markerAttribute && markerValueIsNonEmpty === undefined) {
192
+ markerValueIsNonEmpty = valueIsNonEmpty;
193
+ }
194
+ }
195
+ }
196
+
197
+ function invalidStartTag(html: string, cursor: number, name: string): ParsedStartTag {
198
+ return { end: skipMalformedTagTail(html, cursor), hasMarker: false, name, selfClosing: false };
199
+ }
200
+
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
+ }
208
+ const standardEnd = html.indexOf("-->", cursor);
209
+ const abruptEnd = html.indexOf("--!>", cursor);
210
+ const end =
211
+ standardEnd < 0 ? abruptEnd : abruptEnd < 0 ? standardEnd : Math.min(standardEnd, abruptEnd);
212
+ return end < 0 ? html.length : end + (end === abruptEnd ? 4 : 3);
213
+ }
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
+
237
+ function skipCdataSection(html: string, cursor: number): number {
238
+ const end = html.indexOf("]]>", cursor);
239
+ return end < 0 ? html.length : end + 3;
240
+ }
241
+
242
+ function skipTagTail(html: string, cursor: number): number {
243
+ let quote: '"' | "'" | undefined;
244
+ while (cursor < html.length) {
245
+ const character = html[cursor];
246
+ if (quote !== undefined) {
247
+ if (character === quote) {
248
+ quote = undefined;
249
+ }
250
+ } else if (character === '"' || character === "'") {
251
+ quote = character;
252
+ } else if (character === ">") {
253
+ return cursor + 1;
254
+ }
255
+ cursor += 1;
256
+ }
257
+ return html.length;
258
+ }
259
+
260
+ function skipRawTextElement(html: string, cursor: number, name: string): number {
261
+ const closingPrefix = `</${name}`;
262
+ for (;;) {
263
+ const closingStart = indexOfAsciiCaseInsensitive(html, closingPrefix, cursor);
264
+ if (closingStart < 0) {
265
+ return html.length;
266
+ }
267
+ const delimiter = html[closingStart + closingPrefix.length];
268
+ if (delimiter === ">" || delimiter === "/" || isAsciiWhitespace(delimiter)) {
269
+ return skipTagTail(html, closingStart + closingPrefix.length);
270
+ }
271
+ cursor = closingStart + 2;
272
+ }
273
+ }
274
+
275
+ function skipScriptElement(html: string, cursor: number): number {
276
+ let state: "data" | "double-escaped" | "escaped" = "data";
277
+
278
+ while (cursor < html.length) {
279
+ if (state === "data" && html.startsWith("<!--", cursor)) {
280
+ state = "escaped";
281
+ cursor += 4;
282
+ continue;
283
+ }
284
+
285
+ if (state !== "data" && html.startsWith("-->", cursor)) {
286
+ state = "data";
287
+ cursor += 3;
288
+ continue;
289
+ }
290
+
291
+ if (html[cursor] === "<") {
292
+ if (isScriptTagAt(html, cursor, true)) {
293
+ if (state === "double-escaped") {
294
+ state = "escaped";
295
+ cursor += 8;
296
+ continue;
297
+ }
298
+ return skipTagTail(html, cursor + 8);
299
+ }
300
+
301
+ if (state === "escaped" && isScriptTagAt(html, cursor, false)) {
302
+ state = "double-escaped";
303
+ cursor += 7;
304
+ continue;
305
+ }
306
+ }
307
+
308
+ cursor += 1;
309
+ }
310
+
311
+ return html.length;
312
+ }
313
+
314
+ function isScriptTagAt(html: string, cursor: number, closing: boolean): boolean {
315
+ const prefix = closing ? "</script" : "<script";
316
+ if (!matchesAsciiCaseInsensitive(html, prefix, cursor)) {
317
+ return false;
318
+ }
319
+ const delimiter = html[cursor + prefix.length];
320
+ return delimiter === ">" || delimiter === "/" || isAsciiWhitespace(delimiter);
321
+ }
322
+
323
+ function matchesAsciiCaseInsensitive(html: string, needle: string, start: number): boolean {
324
+ if (start + needle.length > html.length) {
325
+ return false;
326
+ }
327
+ for (let offset = 0; offset < needle.length; offset += 1) {
328
+ if (html.charAt(start + offset).toLowerCase() !== needle.charAt(offset)) {
329
+ return false;
330
+ }
331
+ }
332
+ return true;
333
+ }
334
+
335
+ function indexOfAsciiCaseInsensitive(html: string, needle: string, start: number): number {
336
+ const lastStart = html.length - needle.length;
337
+ for (let index = start; index <= lastStart; index += 1) {
338
+ let offset = 0;
339
+ while (
340
+ offset < needle.length &&
341
+ html.charAt(index + offset).toLowerCase() === needle.charAt(offset)
342
+ ) {
343
+ offset += 1;
344
+ }
345
+ if (offset === needle.length) {
346
+ return index;
347
+ }
348
+ }
349
+ return -1;
350
+ }
351
+
352
+ function skipAsciiWhitespace(html: string, cursor: number): number {
353
+ while (isAsciiWhitespace(html[cursor])) {
354
+ cursor += 1;
355
+ }
356
+ return cursor;
357
+ }
358
+
359
+ function isAsciiWhitespace(character: string | undefined): boolean {
360
+ return (
361
+ character === " " ||
362
+ character === "\t" ||
363
+ character === "\n" ||
364
+ character === "\f" ||
365
+ character === "\r"
366
+ );
367
+ }
368
+
369
+ function isAsciiLetter(character: string | undefined): boolean {
370
+ if (character === undefined) {
371
+ return false;
372
+ }
373
+ const code = character.charCodeAt(0);
374
+ return (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
375
+ }
376
+
377
+ function isTagNameTerminator(character: string | undefined): boolean {
378
+ return (
379
+ character === undefined ||
380
+ character === ">" ||
381
+ character === "/" ||
382
+ isAsciiWhitespace(character)
383
+ );
384
+ }
385
+
386
+ function isForbiddenTagNameCharacter(character: string | undefined): boolean {
387
+ return (
388
+ character === "\0" ||
389
+ character === '"' ||
390
+ character === "'" ||
391
+ character === "<" ||
392
+ character === "="
393
+ );
394
+ }
395
+
396
+ function isAttributeNameTerminator(character: string | undefined): boolean {
397
+ return (
398
+ character === undefined ||
399
+ character === ">" ||
400
+ character === "/" ||
401
+ character === "=" ||
402
+ isAsciiWhitespace(character)
403
+ );
404
+ }
405
+
406
+ function isForbiddenAttributeNameCharacter(character: string | undefined): boolean {
407
+ return character === "\0" || character === '"' || character === "'" || character === "<";
408
+ }
409
+
410
+ function isUnquotedValueTerminator(character: string | undefined): boolean {
411
+ return character === undefined || character === ">" || isAsciiWhitespace(character);
412
+ }
413
+
414
+ function isForbiddenUnquotedValueCharacter(character: string | undefined): boolean {
415
+ return (
416
+ character === "\0" ||
417
+ character === '"' ||
418
+ character === "'" ||
419
+ character === "`" ||
420
+ character === "<" ||
421
+ character === "="
422
+ );
423
+ }
@@ -7,6 +7,7 @@ export {
7
7
  clientScriptForPath,
8
8
  hydrationMarkerParts,
9
9
  navigationRuntimeScriptForDev,
10
+ routeMarkerParts,
10
11
  routeIdForPath,
11
12
  withHydrationMarkers,
12
13
  withRouteMarkers,
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;