@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/dev-server.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createServer, type Server } from "node:http";
2
2
  import type { ServerResponse } from "node:http";
3
3
  import { readFile } from "node:fs/promises";
4
- import { resolve } from "node:path";
4
+ import { isIP } from "node:net";
5
+ import { posix, resolve } from "node:path";
6
+ import type { DehydrateOptions } from "@reckona/mreact-query";
5
7
  import { createServer as createViteServer, type UserConfig, type ViteDevServer } from "vite";
6
8
  import type { AppRouterServerActionOptions } from "./actions.js";
7
9
  import { createMemoryRouteCache, type AppRouterCache } from "./cache.js";
@@ -19,12 +21,21 @@ import {
19
21
  nodeRequestPath,
20
22
  type AppRouterLogger,
21
23
  } from "./logger.js";
22
- import type { HttpUpgradeHandler } from "./upgrade.js";
24
+ import {
25
+ assertValidHttpUpgradeOriginPolicy,
26
+ closeServerWithUpgrades,
27
+ createManagedHttpUpgradeLifecycle,
28
+ validateHttpUpgradeOrigin,
29
+ type HttpUpgradeOriginPolicy,
30
+ type HttpUpgradeOriginValidation,
31
+ type ManagedHttpUpgradeHandler,
32
+ } from "./upgrade.js";
23
33
 
24
34
  /**
25
35
  * Configures the Vite-powered app-router development server.
26
36
  */
27
37
  export interface StartDevServerOptions extends AppRouterProjectOptions {
38
+ dehydrateOptions?: DehydrateOptions | undefined;
28
39
  port?: number | undefined;
29
40
  hostname?: string;
30
41
  importPolicy?: AppRouterImportPolicy | undefined;
@@ -33,7 +44,10 @@ export interface StartDevServerOptions extends AppRouterProjectOptions {
33
44
  serverActions?: AppRouterServerActionOptions | undefined;
34
45
  verboseErrors?: boolean | undefined;
35
46
  viteConfig?: UserConfig | undefined;
36
- onUpgrade?: HttpUpgradeHandler | undefined;
47
+ onUpgrade?: ManagedHttpUpgradeHandler | undefined;
48
+ upgradeCloseTimeoutMs?: number | undefined;
49
+ upgradeDecisionTimeoutMs?: number | undefined;
50
+ upgradeOriginPolicy?: HttpUpgradeOriginPolicy | undefined;
37
51
  }
38
52
 
39
53
  /**
@@ -53,6 +67,17 @@ export async function startDevServer(
53
67
  const userHmrConfig =
54
68
  typeof userViteServerConfig.hmr === "object" ? userViteServerConfig.hmr : {};
55
69
  const port = options.port ?? resolved.serverPort ?? 3001;
70
+ const upgradeCloseTimeoutMs = finiteNonNegativeTimeout(
71
+ options.upgradeCloseTimeoutMs,
72
+ 1_000,
73
+ "upgradeCloseTimeoutMs",
74
+ );
75
+ const upgradeDecisionTimeoutMs = finiteNonNegativeTimeout(
76
+ options.upgradeDecisionTimeoutMs,
77
+ 1_000,
78
+ "upgradeDecisionTimeoutMs",
79
+ );
80
+ assertValidHttpUpgradeOriginPolicy(options.upgradeOriginPolicy);
56
81
  const routeCache = options.routeCache ?? createMemoryRouteCache();
57
82
  const declaredPackages = await readDeclaredProjectPackages(project.projectRoot);
58
83
  const configImportPolicy = resolved.importPolicy;
@@ -68,6 +93,7 @@ export async function startDevServer(
68
93
  ],
69
94
  };
70
95
  let vite: ViteDevServer | undefined;
96
+ let actualPort = port;
71
97
  const server = createServer((incoming, outgoing) => {
72
98
  const logger = options.logger;
73
99
  const startedAt = logger === undefined ? undefined : logNow();
@@ -115,8 +141,25 @@ export async function startDevServer(
115
141
  });
116
142
  });
117
143
 
118
- if (options.onUpgrade !== undefined) {
119
- server.on("upgrade", options.onUpgrade);
144
+ const upgradeLifecycle =
145
+ options.onUpgrade === undefined
146
+ ? undefined
147
+ : createManagedHttpUpgradeLifecycle({
148
+ decisionTimeoutMs: upgradeDecisionTimeoutMs,
149
+ handler: options.onUpgrade,
150
+ isOriginAllowed: (request) =>
151
+ validateDevUpgradeOrigin(
152
+ request,
153
+ options.upgradeOriginPolicy,
154
+ hostname,
155
+ actualPort,
156
+ vite,
157
+ ),
158
+ logger: options.logger,
159
+ shouldBypass: (request) => isViteHmrUpgradeRequest(request, vite),
160
+ });
161
+ if (upgradeLifecycle !== undefined) {
162
+ server.on("upgrade", upgradeLifecycle.handle);
120
163
  }
121
164
 
122
165
  vite = await createViteServer({
@@ -133,6 +176,8 @@ export async function startDevServer(
133
176
  ...(userViteConfig.plugins ?? []),
134
177
  createAppRouterVitePlugin({
135
178
  allowedSourceDirs: project.allowedSourceDirs,
179
+ dehydrateOptions: options.dehydrateOptions ?? resolved.dehydrateOptions,
180
+ dehydratePolicyModule: project.dehydratePolicyModule,
136
181
  projectRoot: project.projectRoot,
137
182
  publicDir: project.publicDir,
138
183
  routesDir: project.routesDir,
@@ -151,20 +196,140 @@ export async function startDevServer(
151
196
  throw error;
152
197
  }
153
198
  const address = server.address();
154
- const actualPort = typeof address === "object" && address !== null ? address.port : port;
199
+ actualPort = typeof address === "object" && address !== null ? address.port : port;
200
+ let closePromise: Promise<void> | undefined;
155
201
 
156
202
  return {
157
203
  server,
158
- url: `http://${hostname}:${actualPort}`,
159
- close: async () => {
160
- await vite?.close();
161
- await new Promise<void>((resolve, reject) => {
162
- server.close((error) => (error ? reject(error) : resolve()));
163
- });
204
+ url: `http://${formatNodeAuthority(hostname, actualPort)}`,
205
+ close: () => {
206
+ closePromise ??= (async () => {
207
+ upgradeLifecycle?.beginClose();
208
+ const httpClose =
209
+ upgradeLifecycle === undefined
210
+ ? new Promise<void>((resolve, reject) => {
211
+ server.close((error) => (error ? reject(error) : resolve()));
212
+ })
213
+ : closeServerWithUpgrades({
214
+ lifecycle: upgradeLifecycle,
215
+ server,
216
+ timeoutMs: upgradeCloseTimeoutMs,
217
+ });
218
+ const results = await Promise.allSettled([vite?.close(), httpClose]);
219
+ const errors = results.flatMap((result) =>
220
+ result.status === "rejected" ? [result.reason] : [],
221
+ );
222
+ if (errors.length > 0) {
223
+ throw new AggregateError(errors, "Failed to close the mreact development server.");
224
+ }
225
+ })();
226
+ return closePromise;
164
227
  },
165
228
  };
166
229
  }
167
230
 
231
+ function validateDevUpgradeOrigin(
232
+ request: import("node:http").IncomingMessage,
233
+ policy: HttpUpgradeOriginPolicy | undefined,
234
+ hostname: string,
235
+ port: number,
236
+ vite: ViteDevServer | undefined,
237
+ ): HttpUpgradeOriginValidation {
238
+ if (!isDevUpgradeHostAllowed(request.headers.host, vite?.config.server.allowedHosts)) {
239
+ return { ok: false, reason: "disallowed-origin" };
240
+ }
241
+ if (policy === "unchecked") {
242
+ return { ok: true, origin: request.headers.origin };
243
+ }
244
+ if (typeof policy === "object") {
245
+ return validateHttpUpgradeOrigin(request, policy);
246
+ }
247
+ try {
248
+ return validateHttpUpgradeOrigin(request, {
249
+ allowedOrigins: [`http://${request.headers.host ?? formatNodeAuthority(hostname, port)}`],
250
+ });
251
+ } catch {
252
+ return { ok: false, reason: "malformed-origin" };
253
+ }
254
+ }
255
+
256
+ function isDevUpgradeHostAllowed(
257
+ hostHeader: string | undefined,
258
+ allowedHosts: true | string[] | undefined,
259
+ ): boolean {
260
+ if (allowedHosts === true || hostHeader === undefined) {
261
+ return true;
262
+ }
263
+
264
+ const host = hostHeader.trim();
265
+ if (host.startsWith("[")) {
266
+ const closingBracket = host.indexOf("]");
267
+ return closingBracket > 0 && isIP(host.slice(1, closingBracket)) === 6;
268
+ }
269
+ const colon = host.indexOf(":");
270
+ const hostname = colon === -1 ? host : host.slice(0, colon);
271
+ if (isIP(hostname) === 4 || hostname === "localhost" || hostname.endsWith(".localhost")) {
272
+ return true;
273
+ }
274
+
275
+ return (allowedHosts ?? []).some(
276
+ (allowedHost) =>
277
+ allowedHost === hostname ||
278
+ (allowedHost.startsWith(".") &&
279
+ (allowedHost.slice(1) === hostname || hostname.endsWith(allowedHost))),
280
+ );
281
+ }
282
+
283
+ function formatNodeAuthority(hostname: string, port: number): string {
284
+ const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
285
+ return `${host}:${port}`;
286
+ }
287
+
288
+ function isViteHmrUpgradeRequest(
289
+ request: import("node:http").IncomingMessage,
290
+ vite: ViteDevServer | undefined,
291
+ ): boolean {
292
+ if (vite === undefined) {
293
+ return false;
294
+ }
295
+ const protocol = request.headers["sec-websocket-protocol"];
296
+ if (protocol !== "vite-hmr" && protocol !== "vite-ping") {
297
+ return false;
298
+ }
299
+ const hmr = vite.config.server.hmr;
300
+ if (hmr === false) {
301
+ return false;
302
+ }
303
+ const hmrPath = typeof hmr === "object" ? hmr.path : undefined;
304
+ const expectedPath =
305
+ hmrPath === undefined ? vite.config.base : posix.join(vite.config.base, hmrPath);
306
+
307
+ try {
308
+ return new URL(request.url ?? "/", "http://mreact.local").pathname === expectedPath;
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
313
+
314
+ function finiteNonNegativeTimeout(
315
+ value: number | undefined,
316
+ fallback: number,
317
+ name: string,
318
+ ): number {
319
+ const timeout = value ?? fallback;
320
+ if (
321
+ !Number.isFinite(timeout) ||
322
+ timeout < 0 ||
323
+ !Number.isSafeInteger(timeout) ||
324
+ timeout > 2_147_483_647
325
+ ) {
326
+ throw new TypeError(
327
+ `${name} must be a finite non-negative safe integer no greater than 2147483647.`,
328
+ );
329
+ }
330
+ return timeout;
331
+ }
332
+
168
333
  function listenDevHttpServer(server: Server, port: number, hostname: string): Promise<void> {
169
334
  return new Promise((resolve, reject) => {
170
335
  const onError = (error: Error) => {
@@ -202,6 +367,7 @@ function isNodeErrorCode(error: unknown, code: string): boolean {
202
367
  }
203
368
 
204
369
  async function resolveStartDevServerProject(options: StartDevServerOptions): Promise<{
370
+ dehydrateOptions?: LoadedMreactRouterViteConfig["dehydrateOptions"];
205
371
  project: ReturnType<typeof resolveAppRouterProjectOptions>;
206
372
  serverPort?: number | undefined;
207
373
  importPolicy?: LoadedMreactRouterViteConfig["importPolicy"];
@@ -216,6 +382,9 @@ async function resolveStartDevServerProject(options: StartDevServerOptions): Pro
216
382
  );
217
383
 
218
384
  return {
385
+ ...(config?.dehydrateOptions === undefined
386
+ ? {}
387
+ : { dehydrateOptions: config.dehydrateOptions }),
219
388
  ...(config?.importPolicy === undefined ? {} : { importPolicy: config.importPolicy }),
220
389
  project: resolveAppRouterProjectOptions({
221
390
  ...config?.project,
@@ -280,7 +449,11 @@ function sendDevServerError(
280
449
  ): void {
281
450
  outgoing.statusCode = 500;
282
451
  outgoing.setHeader("content-type", "text/plain; charset=utf-8");
283
- outgoing.end(options.verboseErrors === true
284
- ? error instanceof Error ? error.stack : String(error)
285
- : "Internal Server Error");
452
+ outgoing.end(
453
+ options.verboseErrors === true
454
+ ? error instanceof Error
455
+ ? error.stack
456
+ : String(error)
457
+ : "Internal Server Error",
458
+ );
286
459
  }
package/src/http.ts CHANGED
@@ -7,8 +7,10 @@ const rawUrlByRequest = new WeakMap<Request, string>();
7
7
  export function nodeRequestToWebRequest(
8
8
  incoming: IncomingMessage,
9
9
  origin: string,
10
+ outgoing?: ServerResponse,
10
11
  ): Request {
11
12
  const method = incoming.method ?? "GET";
13
+ const rawUrl = incoming.url ?? "/";
12
14
  const headers = new Headers();
13
15
 
14
16
  for (const [name, value] of Object.entries(incoming.headers)) {
@@ -29,16 +31,81 @@ export function nodeRequestToWebRequest(
29
31
  method,
30
32
  };
31
33
 
34
+ if (outgoing !== undefined) {
35
+ init.signal = nodeRequestSignal(incoming, outgoing);
36
+ }
37
+
32
38
  if (method !== "GET" && method !== "HEAD") {
33
39
  init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;
34
40
  init.duplex = "half";
35
41
  }
36
42
 
37
- const request = new Request(new URL(incoming.url ?? "/", origin), init);
38
- rawUrlByRequest.set(request, incoming.url ?? "/");
43
+ const request = new Request(nodeRequestUrl(rawUrl, origin), init);
44
+ rawUrlByRequest.set(request, rawUrl);
39
45
  return request;
40
46
  }
41
47
 
48
+ function nodeRequestSignal(incoming: IncomingMessage, outgoing: ServerResponse): AbortSignal {
49
+ const controller = new AbortController();
50
+ let disposed = false;
51
+
52
+ const dispose = (): void => {
53
+ if (disposed) {
54
+ return;
55
+ }
56
+ disposed = true;
57
+ incoming.off("aborted", onIncomingAborted);
58
+ outgoing.off("close", onOutgoingClose);
59
+ outgoing.off("error", onOutgoingError);
60
+ outgoing.off("finish", onOutgoingFinish);
61
+ };
62
+ const abort = (reason: Error): void => {
63
+ controller.abort(reason);
64
+ dispose();
65
+ };
66
+ const onIncomingAborted = (): void => {
67
+ abort(new Error("The incoming HTTP request was aborted."));
68
+ };
69
+ const onOutgoingClose = (): void => {
70
+ if (!outgoing.writableFinished) {
71
+ abort(new Error("The outgoing HTTP response closed before completion."));
72
+ return;
73
+ }
74
+ dispose();
75
+ };
76
+ const onOutgoingError = (error: Error): void => {
77
+ abort(error);
78
+ };
79
+ const onOutgoingFinish = (): void => {
80
+ dispose();
81
+ };
82
+
83
+ incoming.once("aborted", onIncomingAborted);
84
+ outgoing.once("close", onOutgoingClose);
85
+ outgoing.once("error", onOutgoingError);
86
+ outgoing.once("finish", onOutgoingFinish);
87
+
88
+ if (incoming.aborted) {
89
+ onIncomingAborted();
90
+ } else if (outgoing.destroyed && !outgoing.writableFinished) {
91
+ onOutgoingClose();
92
+ }
93
+
94
+ return controller.signal;
95
+ }
96
+
97
+ function nodeRequestUrl(rawUrl: string, origin: string): URL {
98
+ const validatedOrigin = new URL(origin).origin;
99
+ const rootedPath = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
100
+ const requestUrl = new URL(`${validatedOrigin}${rootedPath}`);
101
+
102
+ if (requestUrl.origin !== validatedOrigin) {
103
+ throw new TypeError("Node request target changed the validated request origin.");
104
+ }
105
+
106
+ return requestUrl;
107
+ }
108
+
42
109
  export function rawNodeRequestUrl(request: Request): string | undefined {
43
110
  return rawUrlByRequest.get(request);
44
111
  }
@@ -121,12 +188,13 @@ export async function sendResponse(
121
188
  return;
122
189
  }
123
190
 
124
- outgoing.flushHeaders();
125
191
  const reader = response.body.getReader();
192
+ const lifecycle = observeOutgoingFailure(outgoing);
193
+ outgoing.flushHeaders();
126
194
 
127
195
  try {
128
196
  while (true) {
129
- const result = await reader.read();
197
+ const result = await readWithAbort(reader, lifecycle.signal);
130
198
 
131
199
  if (result.done) {
132
200
  outgoing.end();
@@ -134,16 +202,114 @@ export async function sendResponse(
134
202
  }
135
203
 
136
204
  if (!outgoing.write(result.value)) {
137
- await new Promise<void>((resolve) => outgoing.once("drain", resolve));
205
+ await waitForDrain(outgoing, lifecycle.signal);
138
206
  }
139
207
  }
140
208
  } catch (error) {
141
- outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
209
+ const reason = error instanceof Error ? error : new Error(String(error));
210
+ try {
211
+ await reader.cancel(reason);
212
+ } catch {
213
+ // The stream may already be errored or cancelled.
214
+ }
215
+ if (!outgoing.destroyed) {
216
+ outgoing.destroy(reason);
217
+ }
142
218
  } finally {
219
+ lifecycle.dispose();
143
220
  reader.releaseLock();
144
221
  }
145
222
  }
146
223
 
224
+ function observeOutgoingFailure(outgoing: ServerResponse): {
225
+ dispose(): void;
226
+ signal: AbortSignal;
227
+ } {
228
+ const controller = new AbortController();
229
+ let disposed = false;
230
+
231
+ const dispose = (): void => {
232
+ if (disposed) {
233
+ return;
234
+ }
235
+ disposed = true;
236
+ outgoing.off("close", onClose);
237
+ outgoing.off("error", onError);
238
+ };
239
+ const abort = (reason: Error): void => {
240
+ controller.abort(reason);
241
+ dispose();
242
+ };
243
+ const onClose = (): void => {
244
+ abort(new Error("The outgoing HTTP response closed during streaming."));
245
+ };
246
+ const onError = (error: Error): void => {
247
+ abort(error);
248
+ };
249
+
250
+ outgoing.once("close", onClose);
251
+ outgoing.once("error", onError);
252
+ if (outgoing.destroyed) {
253
+ onClose();
254
+ }
255
+
256
+ return { dispose, signal: controller.signal };
257
+ }
258
+
259
+ function readWithAbort(
260
+ reader: ReadableStreamDefaultReader<Uint8Array>,
261
+ signal: AbortSignal,
262
+ ): Promise<ReadableStreamReadResult<Uint8Array>> {
263
+ if (signal.aborted) {
264
+ return Promise.reject(abortReason(signal));
265
+ }
266
+
267
+ return new Promise((resolve, reject) => {
268
+ const onAbort = (): void => {
269
+ signal.removeEventListener("abort", onAbort);
270
+ reject(abortReason(signal));
271
+ };
272
+ signal.addEventListener("abort", onAbort, { once: true });
273
+ void reader.read().then(
274
+ (result) => {
275
+ signal.removeEventListener("abort", onAbort);
276
+ resolve(result);
277
+ },
278
+ (error: unknown) => {
279
+ signal.removeEventListener("abort", onAbort);
280
+ reject(error);
281
+ },
282
+ );
283
+ });
284
+ }
285
+
286
+ function waitForDrain(outgoing: ServerResponse, signal: AbortSignal): Promise<void> {
287
+ if (signal.aborted) {
288
+ return Promise.reject(abortReason(signal));
289
+ }
290
+
291
+ return new Promise((resolve, reject) => {
292
+ const dispose = (): void => {
293
+ outgoing.off("drain", onDrain);
294
+ signal.removeEventListener("abort", onAbort);
295
+ };
296
+ const onDrain = (): void => {
297
+ dispose();
298
+ resolve();
299
+ };
300
+ const onAbort = (): void => {
301
+ dispose();
302
+ reject(abortReason(signal));
303
+ };
304
+ outgoing.once("drain", onDrain);
305
+ signal.addEventListener("abort", onAbort, { once: true });
306
+ });
307
+ }
308
+
309
+ function abortReason(signal: AbortSignal): Error {
310
+ return signal.reason instanceof Error ? signal.reason : new Error("HTTP streaming was aborted.");
311
+ }
312
+
147
313
  function responseSetCookieHeaders(headers: Headers): string[] {
148
314
  const getSetCookie = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
149
315
 
package/src/index.ts CHANGED
@@ -33,7 +33,17 @@ export type {
33
33
  } from "./multipart.js";
34
34
  export { getNavigationState, subscribeNavigationState } from "./navigation-state.js";
35
35
  export { getRouterRuntimeCacheStats } from "./runtime-cache.js";
36
- export type { HttpUpgradeHandler } from "./upgrade.js";
36
+ export { validateHttpUpgradeOrigin } from "./upgrade.js";
37
+ export type {
38
+ HttpUpgradeContext,
39
+ HttpUpgradeDisposition,
40
+ HttpUpgradeHandler,
41
+ HttpUpgradeOriginFailureReason,
42
+ HttpUpgradeOriginPolicy,
43
+ HttpUpgradeOriginValidation,
44
+ ManagedHttpUpgradeHandler,
45
+ ValidateHttpUpgradeOriginOptions,
46
+ } from "./upgrade.js";
37
47
  export {
38
48
  cookies,
39
49
  headers,
@@ -250,6 +260,8 @@ export type {
250
260
  AppRouterRenderTimingLogEvent,
251
261
  AppRouterRequestStartLogEvent,
252
262
  AppRouterRequestTimingLogEvent,
263
+ AppRouterUpgradeErrorLogEvent,
264
+ AppRouterUpgradeRejectedLogEvent,
253
265
  } from "./logger.js";
254
266
  export type { DetectedLocale, LocaleRoutingOptions, MessageTree } from "./i18n.js";
255
267
  export {
@@ -288,6 +300,7 @@ export type {
288
300
  AppRouterRenderPreload,
289
301
  AppRouterResponseHook,
290
302
  AppRouterResponseHookContext,
303
+ AppRouterServerRenderArtifactLoader,
291
304
  RenderAppRequestOptions,
292
305
  } from "./render.js";
293
306
  export { parseTraceContext, traceContextFromRequest } from "./trace.js";
package/src/logger.ts CHANGED
@@ -27,7 +27,9 @@ export type AppRouterLogEvent =
27
27
  | AppRouterRequestErrorLogEvent
28
28
  | AppRouterRequestTimingLogEvent
29
29
  | AppRouterRenderTimingLogEvent
30
- | AppRouterCspInlineNonceWarningLogEvent;
30
+ | AppRouterCspInlineNonceWarningLogEvent
31
+ | AppRouterUpgradeErrorLogEvent
32
+ | AppRouterUpgradeRejectedLogEvent;
31
33
 
32
34
  /**
33
35
  * Logs the start of an app-router request.
@@ -63,6 +65,29 @@ export interface AppRouterRequestErrorLogEvent {
63
65
  type: "router:request:error";
64
66
  }
65
67
 
68
+ /**
69
+ * Logs a failed or timed-out HTTP upgrade handler.
70
+ */
71
+ export interface AppRouterUpgradeErrorLogEvent {
72
+ durationMs: number;
73
+ error: AppRouterLogError;
74
+ method: string;
75
+ path: string;
76
+ runtime: "node";
77
+ type: "router:upgrade:error";
78
+ }
79
+
80
+ /**
81
+ * Logs an HTTP upgrade rejected before application ownership.
82
+ */
83
+ export interface AppRouterUpgradeRejectedLogEvent {
84
+ method: string;
85
+ path: string;
86
+ reason: "disallowed-origin" | "malformed-origin" | "missing-origin" | "opaque-origin";
87
+ runtime: "node";
88
+ type: "router:upgrade:rejected";
89
+ }
90
+
66
91
  /**
67
92
  * Logs per-phase timings for a complete app-router request.
68
93
  */
@@ -116,7 +141,12 @@ export function emitRouterLog(
116
141
  level: AppRouterLogLevel,
117
142
  event: AppRouterLogEvent,
118
143
  ): void {
119
- const sink = logger?.[level];
144
+ let sink: AppRouterLogger[AppRouterLogLevel];
145
+ try {
146
+ sink = logger?.[level];
147
+ } catch {
148
+ return;
149
+ }
120
150
 
121
151
  if (sink === undefined) {
122
152
  return;
@@ -176,15 +206,22 @@ export function logDurationMs(startedAt: number): number {
176
206
  }
177
207
 
178
208
  export function logError(error: unknown): AppRouterLogError {
179
- if (error instanceof Error) {
209
+ try {
210
+ if (error instanceof Error) {
211
+ return {
212
+ message: String(error.message),
213
+ name: String(error.name),
214
+ };
215
+ }
216
+
180
217
  return {
181
- message: error.message,
182
- name: error.name,
218
+ message: String(error),
219
+ name: "Error",
220
+ };
221
+ } catch {
222
+ return {
223
+ message: "Unknown non-coercible error",
224
+ name: "Error",
183
225
  };
184
226
  }
185
-
186
- return {
187
- message: String(error),
188
- name: "Error",
189
- };
190
227
  }