@askrjs/node 0.0.8 → 0.0.11

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/dist/index.js CHANGED
@@ -1,41 +1,150 @@
1
- import { Readable } from "node:stream";
1
+ import { isIP } from "node:net";
2
+ import { addAbortListener } from "node:events";
2
3
  import { createServer } from "node:http";
3
- import { WebSocketServer } from "ws";
4
+ import { WebSocket, WebSocketServer } from "ws";
4
5
  import { createReadStream } from "node:fs";
5
6
  import { realpath, stat } from "node:fs/promises";
6
7
  import { extname, resolve, sep } from "node:path";
8
+ import { pipeline } from "node:stream";
9
+ //#region src/client-address.ts
10
+ /**
11
+ * Reserved request header containing the TCP peer address authenticated by the Node adapter.
12
+ * Any value supplied by the HTTP client is overwritten before application dispatch.
13
+ */
14
+ const CLIENT_ADDRESS_HEADER = "x-askr-client-address";
15
+ /** Normalizes the socket peer address used for the adapter-authenticated request header. */
16
+ function normalizeClientAddress(address) {
17
+ if (!address) return "unknown";
18
+ if (address.toLowerCase().startsWith("::ffff:")) {
19
+ const mapped = address.slice(7);
20
+ if (isIP(mapped) === 4) return mapped;
21
+ }
22
+ return address;
23
+ }
24
+ //#endregion
7
25
  //#region src/request.ts
8
26
  var NodeRequestError = class extends TypeError {};
27
+ function explicitPort(authority) {
28
+ if (authority.startsWith("[")) {
29
+ const bracket = authority.indexOf("]");
30
+ return bracket >= 0 && authority[bracket + 1] === ":" ? authority.slice(bracket + 2) : void 0;
31
+ }
32
+ const first = authority.indexOf(":");
33
+ return first >= 0 && first === authority.lastIndexOf(":") ? authority.slice(first + 1) : void 0;
34
+ }
35
+ function parseAuthority(value, allowBareIpv6 = false) {
36
+ if (!value || value.trim() !== value) throw new NodeRequestError("Request host must be a non-empty HTTP authority.");
37
+ const bareIpv6 = isIP(value) === 6;
38
+ if (bareIpv6 && !allowBareIpv6) throw new NodeRequestError("IPv6 request hosts must use brackets.");
39
+ const authority = bareIpv6 ? `[${value}]` : value;
40
+ const port = explicitPort(authority);
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(`http://${authority}`);
44
+ } catch {
45
+ throw new NodeRequestError("Request host is not a valid HTTP authority.");
46
+ }
47
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || port !== void 0 && !/^\d+$/u.test(port)) throw new NodeRequestError("Request host is not a valid HTTP authority.");
48
+ return {
49
+ hostname: parsed.hostname,
50
+ host: port === void 0 ? parsed.hostname : `${parsed.hostname}:${Number(port)}`
51
+ };
52
+ }
53
+ function prepareBaseUrl(value) {
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(value);
57
+ } catch {
58
+ throw new TypeError("Node handler baseUrl must be a valid HTTP or HTTPS origin.");
59
+ }
60
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new TypeError("Node handler baseUrl must be an HTTP or HTTPS origin.");
61
+ return parsed.origin;
62
+ }
63
+ function prepareNodeHandlerOptions(options) {
64
+ const allowedHosts = (options.allowedHosts ?? []).map((value) => {
65
+ try {
66
+ const parsed = parseAuthority(value, true);
67
+ return explicitPort(isIP(value) === 6 ? `[${value}]` : value) === void 0 ? { hostname: parsed.hostname } : parsed;
68
+ } catch (error) {
69
+ throw new TypeError(`Node handler allowedHosts contains an invalid host: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ });
72
+ const baseUrl = options.baseUrl === void 0 ? void 0 : prepareBaseUrl(options.baseUrl);
73
+ if (!baseUrl && allowedHosts.length === 0) throw new TypeError("Node handling requires baseUrl or an allowedHosts allowlist.");
74
+ return {
75
+ allowedHosts,
76
+ baseUrl,
77
+ hostCache: {}
78
+ };
79
+ }
9
80
  function requestHeaders(request) {
10
81
  const headers = new Headers();
11
- for (const [key, value] of Object.entries(request.headers)) {
82
+ for (const key in request.headers) {
83
+ const value = request.headers[key];
12
84
  if (value === void 0) continue;
13
- headers.set(key, Array.isArray(value) ? value.join(", ") : value);
85
+ if (Array.isArray(value)) for (const item of value) headers.append(key, item);
86
+ else headers.set(key, value);
14
87
  }
88
+ headers.set(CLIENT_ADDRESS_HEADER, normalizeClientAddress(request.socket.remoteAddress));
15
89
  return headers;
16
90
  }
17
- function resolveNodeRequestUrl(request, options) {
91
+ function resolveNodeRequestHref(request, options) {
18
92
  const host = request.headers.host;
19
- if (!options.baseUrl && !options.allowedHosts?.length) throw new NodeRequestError("Node handling requires baseUrl or an allowedHosts allowlist.");
20
93
  if (!host) throw new NodeRequestError("Request Host header is required.");
21
- const hostName = host.startsWith("[") ? host.slice(1, host.indexOf("]")) : host.split(":", 1)[0];
22
- if (options.allowedHosts?.length && !options.allowedHosts.some((allowed) => allowed === host || allowed === hostName)) throw new NodeRequestError("Request Host header is not allowed.");
23
- const base = options.baseUrl ?? `http://${host}`;
24
- const target = new URL(request.url ?? "/", base);
25
- if (/^https?:\/\//i.test(request.url ?? "") && target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed.");
26
- return target;
94
+ let base = options.hostCache.value === host ? options.hostCache.base : void 0;
95
+ if (!base) {
96
+ const authority = parseAuthority(host);
97
+ if (options.allowedHosts.length > 0 && !options.allowedHosts.some((allowed) => allowed.host === void 0 ? allowed.hostname === authority.hostname : allowed.host === authority.host)) throw new NodeRequestError("Request Host header is not allowed.");
98
+ base = options.baseUrl ?? `http://${authority.host}`;
99
+ options.hostCache.value = host;
100
+ options.hostCache.base = base;
101
+ }
102
+ const requestTarget = request.url || "/";
103
+ if (requestTarget === "*") {
104
+ if (request.method === "OPTIONS") return `${base}/*`;
105
+ throw new NodeRequestError("Asterisk-form request targets require OPTIONS.");
106
+ }
107
+ if (requestTarget.startsWith("//")) throw new NodeRequestError("Network-path request targets are not allowed.");
108
+ if (requestTarget.startsWith("/")) {
109
+ if (requestTarget.includes("\\")) throw new NodeRequestError("Request targets must not contain backslashes.");
110
+ if (requestTarget.includes("#")) throw new NodeRequestError("Request targets must not contain fragments.");
111
+ return `${base}${requestTarget}`;
112
+ }
113
+ if (!/^https?:\/\//iu.test(requestTarget)) throw new NodeRequestError("Request target must use origin-form or absolute-form.");
114
+ let target;
115
+ try {
116
+ target = new URL(requestTarget, base);
117
+ } catch {
118
+ throw new NodeRequestError("Request target is not a valid HTTP URL.");
119
+ }
120
+ if (target.origin !== new URL(base).origin) throw new NodeRequestError("Absolute-form request target origin is not allowed.");
121
+ if (target.hash) throw new NodeRequestError("Request targets must not contain fragments.");
122
+ return target.href;
123
+ }
124
+ function resolveNodeRequestUrl(request, options) {
125
+ try {
126
+ return new URL(resolveNodeRequestHref(request, options));
127
+ } catch (error) {
128
+ if (error instanceof NodeRequestError) throw error;
129
+ throw new NodeRequestError("Request target is not a valid HTTP URL.");
130
+ }
27
131
  }
28
132
  function requestFromNode(request, options, signal) {
29
- const target = resolveNodeRequestUrl(request, options);
133
+ const target = resolveNodeRequestHref(request, options);
30
134
  const method = request.method ?? "GET";
31
- const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
32
- return new Request(target, {
33
- method,
34
- headers: requestHeaders(request),
35
- body,
36
- signal,
37
- ...body ? { duplex: "half" } : {}
38
- });
135
+ if (method === "GET" || method === "HEAD") request.resume();
136
+ const body = method === "GET" || method === "HEAD" ? void 0 : request;
137
+ try {
138
+ return new Request(target, {
139
+ method,
140
+ headers: requestHeaders(request),
141
+ body,
142
+ signal,
143
+ ...body ? { duplex: "half" } : {}
144
+ });
145
+ } catch {
146
+ throw new NodeRequestError("Node request cannot be represented as a Web Request.");
147
+ }
39
148
  }
40
149
  //#endregion
41
150
  //#region src/response.ts
@@ -79,7 +188,12 @@ async function writeNodeResponse(response, target, method) {
79
188
  target.statusCode = response.status;
80
189
  target.statusMessage = response.statusText;
81
190
  writeHeaders(response, target);
82
- if (!response.body || method === "HEAD") {
191
+ if (method === "HEAD") {
192
+ response.body?.cancel("HEAD responses do not include a body").catch(() => void 0);
193
+ target.end();
194
+ return;
195
+ }
196
+ if (!response.body) {
83
197
  target.end();
84
198
  return;
85
199
  }
@@ -95,7 +209,7 @@ async function writeNodeResponse(response, target, method) {
95
209
  if (disconnected) return;
96
210
  const part = await reader.read();
97
211
  if (part.done || disconnected) break;
98
- if (!target.write(Buffer.from(part.value))) await waitForDrain(target);
212
+ if (!target.write(part.value)) await waitForDrain(target);
99
213
  }
100
214
  if (!disconnected) target.end();
101
215
  } catch (error) {
@@ -104,20 +218,25 @@ async function writeNodeResponse(response, target, method) {
104
218
  target.destroy(error instanceof Error ? error : void 0);
105
219
  } finally {
106
220
  target.off("close", onClose);
221
+ reader.releaseLock();
107
222
  }
108
223
  }
109
224
  //#endregion
110
225
  //#region src/handler.ts
111
226
  function minimalError(response, error) {
112
- if (response.headersSent) {
227
+ if (response.headersSent || response.destroyed) {
113
228
  response.destroy(error instanceof Error ? error : void 0);
114
229
  return;
115
230
  }
116
- const clientError = error instanceof NodeRequestError;
117
- response.statusCode = clientError ? 400 : 500;
118
- response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
119
- response.setHeader("content-type", "text/plain; charset=utf-8");
120
- response.end(clientError ? "Bad Request" : "Internal Server Error");
231
+ try {
232
+ const clientError = error instanceof NodeRequestError;
233
+ response.statusCode = clientError ? 400 : 500;
234
+ response.statusMessage = clientError ? "Bad Request" : "Internal Server Error";
235
+ response.setHeader("content-type", "text/plain; charset=utf-8");
236
+ response.end(clientError ? "Bad Request" : "Internal Server Error");
237
+ } catch {
238
+ response.destroy(error instanceof Error ? error : void 0);
239
+ }
121
240
  }
122
241
  function attachAbort(request, response, controller) {
123
242
  const abort = () => controller.abort();
@@ -135,68 +254,148 @@ function attachAbort(request, response, controller) {
135
254
  response.off("close", abort);
136
255
  };
137
256
  }
138
- function createNodeHandler(app, options = {}) {
257
+ async function handleNodeRequest(app, preparedOptions, request, response, signal, cleanup, next) {
258
+ try {
259
+ const webRequest = requestFromNode(request, preparedOptions, signal);
260
+ await writeNodeResponse(await app.fetch(webRequest), response, request.method);
261
+ } catch (error) {
262
+ if (!next) {
263
+ minimalError(response, error);
264
+ return;
265
+ }
266
+ try {
267
+ next(error);
268
+ } catch (nextError) {
269
+ minimalError(response, nextError);
270
+ }
271
+ } finally {
272
+ cleanup();
273
+ }
274
+ }
275
+ /**
276
+ * Wraps an `@askrjs/server` application as a Node-style request handler.
277
+ *
278
+ * Converts each incoming `IncomingMessage`/`ServerResponse` pair into a web
279
+ * `Request`, dispatches it through `app.fetch`, and writes the resulting web
280
+ * `Response` back to Node. Errors are reported to `next` when provided,
281
+ * otherwise a minimal 400/500 response is written directly.
282
+ *
283
+ * @param app - The application to dispatch requests to.
284
+ * @param options - Options controlling base URL resolution and host validation.
285
+ * @returns A handler usable with `http.createServer` or Connect-style middleware.
286
+ */
287
+ function createNodeHandler(app, options) {
288
+ const preparedOptions = prepareNodeHandlerOptions(options);
139
289
  return (request, response, next) => {
140
290
  const controller = new AbortController();
141
291
  const cleanup = attachAbort(request, response, controller);
142
- Promise.resolve().then(() => requestFromNode(request, options, controller.signal)).then((webRequest) => app.fetch(webRequest)).then((webResponse) => writeNodeResponse(webResponse, response, request.method)).catch((error) => next ? next(error) : minimalError(response, error)).finally(cleanup);
292
+ handleNodeRequest(app, preparedOptions, request, response, controller.signal, cleanup, next);
143
293
  };
144
294
  }
145
295
  //#endregion
146
296
  //#region src/bind.ts
297
+ function isLoopbackHost(host) {
298
+ if (host.toLowerCase() === "localhost") return true;
299
+ const family = isIP(host);
300
+ if (family === 4) return host.split(".", 1)[0] === "127";
301
+ if (family !== 6) return false;
302
+ const normalized = new URL(`http://[${host}]`).hostname;
303
+ return normalized === "[::1]" || normalized.startsWith("[::ffff:7f");
304
+ }
147
305
  function resolveBindHost(options) {
148
306
  const host = options.host ?? "127.0.0.1";
149
- if (!(host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host)) && options.allowPublicBind !== true) throw new TypeError(`Refusing to bind non-loopback host ${host} without allowPublicBind: true.`);
307
+ if (!isLoopbackHost(host) && options.allowPublicBind !== true) throw new TypeError(`Refusing to bind non-loopback host ${host} without allowPublicBind: true.`);
150
308
  return host;
151
309
  }
310
+ function handlerOptionsForHost(options, host) {
311
+ return {
312
+ ...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl },
313
+ allowedHosts: [.../* @__PURE__ */ new Set([
314
+ host,
315
+ "localhost",
316
+ ...options.allowedHosts ?? []
317
+ ])]
318
+ };
319
+ }
152
320
  function formatHostForUrl(host) {
153
321
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
154
322
  }
155
323
  //#endregion
324
+ //#region src/server-options.ts
325
+ function timeout(value, name) {
326
+ if (value === void 0) return void 0;
327
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative safe integer.`);
328
+ return value;
329
+ }
330
+ function applyServerTimeouts(server, options) {
331
+ const requestTimeout = timeout(options.requestTimeout, "requestTimeout");
332
+ const headersTimeout = timeout(options.headersTimeout, "headersTimeout");
333
+ const keepAliveTimeout = timeout(options.keepAliveTimeout, "keepAliveTimeout");
334
+ if (requestTimeout !== void 0) server.requestTimeout = requestTimeout;
335
+ if (headersTimeout !== void 0) server.headersTimeout = headersTimeout;
336
+ if (keepAliveTimeout !== void 0) server.keepAliveTimeout = keepAliveTimeout;
337
+ }
338
+ //#endregion
156
339
  //#region src/websocket.ts
157
340
  function subscribe(socket, event, listener) {
158
341
  socket.on(event, listener);
159
342
  return () => socket.off(event, listener);
160
343
  }
344
+ function notify(socket, listener, ...values) {
345
+ try {
346
+ listener(...values);
347
+ } catch {
348
+ if (socket.readyState === WebSocket.OPEN) socket.close(1011, "WebSocket listener failed");
349
+ }
350
+ }
161
351
  function socketLike(socket) {
162
352
  return {
163
353
  send: (data) => socket.send(data),
164
354
  close: (code, reason) => socket.close(code, reason),
165
355
  onMessage: (listener) => subscribe(socket, "message", (data, binary) => {
166
- if (!binary) listener(data.toString());
356
+ if (!binary) notify(socket, listener, data.toString());
167
357
  else {
168
358
  const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
169
- listener(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
359
+ notify(socket, listener, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
170
360
  }
171
361
  }),
172
- onClose: (listener) => subscribe(socket, "close", (code, reason) => listener({
362
+ onClose: (listener) => subscribe(socket, "close", (code, reason) => notify(socket, listener, {
173
363
  code,
174
364
  reason: reason.toString(),
175
- wasClean: code === 1e3
365
+ wasClean: code !== 1006
176
366
  })),
177
- onError: (listener) => subscribe(socket, "error", listener)
367
+ onError: (listener) => subscribe(socket, "error", (error) => notify(socket, listener, error))
178
368
  };
179
369
  }
370
+ function normalizeHttpOrigin(value) {
371
+ const parsed = new URL(value);
372
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new TypeError("WebSocket origins must be HTTP or HTTPS origins.");
373
+ return parsed.origin;
374
+ }
180
375
  async function readRejectionBody(response, maxBytes) {
181
376
  const lengthHeader = response.headers.get("content-length");
182
377
  const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
183
378
  if (declaredLength !== void 0 && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
184
- await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes");
379
+ await response.body?.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes").catch(() => void 0);
185
380
  return;
186
381
  }
187
382
  if (!response.body) return Buffer.alloc(0);
188
383
  const reader = response.body.getReader();
189
384
  const chunks = [];
190
385
  let length = 0;
191
- while (true) {
192
- const part = await reader.read();
193
- if (part.done) return Buffer.concat(chunks, length);
194
- length += part.value.byteLength;
195
- if (length > maxBytes) {
196
- await reader.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes");
197
- return;
386
+ try {
387
+ while (true) {
388
+ const part = await reader.read();
389
+ if (part.done) return Buffer.concat(chunks, length);
390
+ length += part.value.byteLength;
391
+ if (length > maxBytes) {
392
+ await reader.cancel("WebSocket rejection body exceeded maxRejectionBodyBytes").catch(() => void 0);
393
+ return;
394
+ }
395
+ chunks.push(Buffer.from(part.value));
198
396
  }
199
- chunks.push(Buffer.from(part.value));
397
+ } finally {
398
+ reader.releaseLock();
200
399
  }
201
400
  }
202
401
  async function rejectUpgrade(socket, response, maxBodyBytes) {
@@ -204,31 +403,64 @@ async function rejectUpgrade(socket, response, maxBodyBytes) {
204
403
  if (!buffered) response = new Response("WebSocket rejection body exceeded configured limit", { status: 500 });
205
404
  const body = buffered ?? Buffer.from(await response.arrayBuffer());
206
405
  const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Rejected"}`];
406
+ const cookies = response.headers.getSetCookie();
207
407
  response.headers.forEach((value, name) => {
208
- if (name !== "content-length" && name !== "transfer-encoding") lines.push(`${name}: ${value}`);
408
+ if (name !== "connection" && name !== "content-length" && name !== "set-cookie" && name !== "transfer-encoding" && name !== "upgrade") lines.push(`${name}: ${value}`);
209
409
  });
410
+ for (const cookie of cookies) lines.push(`set-cookie: ${cookie}`);
210
411
  lines.push(`content-length: ${body.byteLength}`);
211
412
  lines.push("connection: close", "", "");
212
413
  socket.end(Buffer.concat([Buffer.from(lines.join("\r\n")), body]));
213
414
  }
214
- function installWebSockets(server, app, options = {}, handlerOptions = {}) {
415
+ function installWebSockets(server, app, options, handlerOptions) {
416
+ const closeTimeout = options.closeTimeout ?? 5e3;
417
+ if (!Number.isSafeInteger(closeTimeout) || closeTimeout < 0) throw new TypeError("WebSocket closeTimeout must be a non-negative safe integer.");
215
418
  const maxRejectionBodyBytes = options.maxRejectionBodyBytes ?? 65536;
216
- if (!Number.isInteger(maxRejectionBodyBytes) || maxRejectionBodyBytes <= 0) throw new TypeError("WebSocket maxRejectionBodyBytes must be a positive integer.");
419
+ if (!Number.isSafeInteger(maxRejectionBodyBytes) || maxRejectionBodyBytes <= 0) throw new TypeError("WebSocket maxRejectionBodyBytes must be a positive integer.");
420
+ const maxPayload = options.maxPayload ?? 1048576;
421
+ if (!Number.isSafeInteger(maxPayload) || maxPayload < 0) throw new TypeError("WebSocket maxPayload must be a non-negative safe integer.");
422
+ const preparedHandlerOptions = prepareNodeHandlerOptions(handlerOptions);
423
+ const configuredAllowedOrigins = options.allowedOrigins?.map((origin) => {
424
+ try {
425
+ return normalizeHttpOrigin(origin);
426
+ } catch {
427
+ throw new TypeError(`WebSocket allowedOrigins contains an invalid origin: ${origin}`);
428
+ }
429
+ });
217
430
  const webSockets = new WebSocketServer({
218
431
  noServer: true,
219
- maxPayload: options.maxPayload ?? 1048576,
432
+ maxPayload,
220
433
  perMessageDeflate: options.perMessageDeflate ?? false
221
434
  });
222
- server.on("upgrade", (request, socket, head) => {
435
+ const pending = /* @__PURE__ */ new Map();
436
+ let closing;
437
+ const onUpgrade = (request, socket, head) => {
438
+ if (closing) {
439
+ socket.destroy();
440
+ return;
441
+ }
223
442
  const controller = new AbortController();
443
+ const abort = () => controller.abort();
444
+ const cleanupPending = () => {
445
+ pending.delete(socket);
446
+ socket.off("close", abort);
447
+ socket.off("end", abort);
448
+ socket.off("error", abort);
449
+ };
450
+ pending.set(socket, controller);
451
+ socket.once("close", abort);
452
+ socket.once("end", abort);
453
+ socket.once("error", abort);
224
454
  let accepted;
225
- Promise.resolve().then(() => requestFromNode(request, handlerOptions, controller.signal)).then(async (webRequest) => {
455
+ let upgraded = false;
456
+ Promise.resolve().then(() => requestFromNode(request, preparedHandlerOptions, controller.signal)).then(async (webRequest) => {
457
+ if (controller.signal.aborted) return;
226
458
  const originHeader = request.headers.origin;
227
459
  const origin = Array.isArray(originHeader) ? void 0 : originHeader;
228
- const allowedOrigins = options.allowedOrigins ?? [new URL(webRequest.url).origin];
460
+ const allowedOrigins = configuredAllowedOrigins ?? [new URL(webRequest.url).origin];
229
461
  let normalizedOrigin;
230
462
  try {
231
- normalizedOrigin = origin ? new URL(origin).origin : void 0;
463
+ normalizedOrigin = origin ? normalizeHttpOrigin(origin) : void 0;
232
464
  } catch {
233
465
  normalizedOrigin = void 0;
234
466
  }
@@ -251,44 +483,105 @@ function installWebSockets(server, app, options = {}, handlerOptions = {}) {
251
483
  return;
252
484
  }
253
485
  webSockets.handleUpgrade(request, socket, head, (webSocket) => {
486
+ upgraded = true;
487
+ cleanupPending();
488
+ webSocket.on("error", () => void 0);
254
489
  webSocket.once("close", () => controller.abort());
255
- Promise.resolve(accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
490
+ Promise.resolve().then(() => accepted.handler(socketLike(webSocket), accepted.context)).catch(() => webSocket.close(1011, "WebSocket handler failed"));
256
491
  });
257
- }).catch(() => socket.destroy());
258
- });
492
+ }).catch(() => socket.destroy()).finally(() => {
493
+ if (!upgraded) {
494
+ cleanupPending();
495
+ controller.abort();
496
+ }
497
+ });
498
+ };
499
+ server.on("upgrade", onUpgrade);
259
500
  return { close() {
260
- for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
261
- webSockets.close();
501
+ return closing ??= new Promise((resolve, reject) => {
502
+ let settled = false;
503
+ let forceClose;
504
+ const complete = (error) => {
505
+ if (settled) return;
506
+ settled = true;
507
+ if (forceClose) clearTimeout(forceClose);
508
+ if (error) reject(error);
509
+ else resolve();
510
+ };
511
+ server.off("upgrade", onUpgrade);
512
+ for (const [socket, controller] of pending) {
513
+ controller.abort();
514
+ socket.destroy();
515
+ }
516
+ pending.clear();
517
+ for (const socket of webSockets.clients) socket.close(1001, "Server shutting down");
518
+ if (webSockets.clients.size > 0) {
519
+ forceClose = setTimeout(() => {
520
+ for (const socket of webSockets.clients) socket.terminate();
521
+ complete();
522
+ }, closeTimeout);
523
+ forceClose.unref();
524
+ }
525
+ webSockets.close(complete);
526
+ });
262
527
  } };
263
528
  }
264
529
  //#endregion
265
530
  //#region src/listen.ts
531
+ /**
532
+ * Starts a Node HTTP server for an `@askrjs/server` application and resolves once it is listening.
533
+ *
534
+ * Optionally installs WebSocket support and wires up graceful shutdown on
535
+ * `options.signal`. Unlike {@link serve}, this does not serve static assets
536
+ * or install OS signal handlers.
537
+ *
538
+ * @param app - The application to serve.
539
+ * @param options - Listen options such as port, host, timeouts, and WebSocket support.
540
+ * @returns A promise resolving to the listening server once it has bound successfully.
541
+ * @example
542
+ * const server = await listen(app, { port: 3000 });
543
+ */
266
544
  function listen(app, options = {}) {
545
+ options.signal?.throwIfAborted();
267
546
  const host = resolveBindHost(options);
268
- const handlerOptions = { allowedHosts: [host, "localhost"] };
547
+ const handlerOptions = handlerOptionsForHost(options, host);
269
548
  const server = createServer(createNodeHandler(app, handlerOptions));
270
- if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
271
- if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
272
- if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
549
+ applyServerTimeouts(server, options);
273
550
  const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
274
551
  if (webSockets) {
275
552
  const nativeClose = server.close.bind(server);
276
553
  server.close = ((callback) => {
277
- webSockets.close();
278
- return nativeClose(callback);
554
+ let remaining = 2;
555
+ let closeError;
556
+ const complete = (error) => {
557
+ closeError ??= error;
558
+ remaining -= 1;
559
+ if (remaining === 0) callback?.(closeError);
560
+ };
561
+ nativeClose(complete);
562
+ webSockets.close().then(() => complete(), complete);
563
+ return server;
279
564
  });
280
565
  }
281
566
  const close = () => server.close();
282
- options.signal?.addEventListener("abort", close, { once: true });
283
- server.once("close", () => options.signal?.removeEventListener("abort", close));
567
+ const abortListener = options.signal ? addAbortListener(options.signal, close) : void 0;
568
+ const disposeAbortListener = () => abortListener?.[Symbol.dispose]();
569
+ server.once("close", disposeAbortListener);
284
570
  return new Promise((resolve, reject) => {
571
+ const onClose = () => {
572
+ server.off("error", onError);
573
+ reject(options.signal?.reason ?? /* @__PURE__ */ new Error("Node server closed before it started listening."));
574
+ };
285
575
  const onError = (error) => {
286
- options.signal?.removeEventListener("abort", close);
576
+ server.off("close", onClose);
577
+ disposeAbortListener();
287
578
  reject(error);
288
579
  };
289
580
  server.once("error", onError);
581
+ server.once("close", onClose);
290
582
  server.listen(options.port ?? 0, host, options.backlog, () => {
291
583
  server.off("error", onError);
584
+ server.off("close", onClose);
292
585
  resolve(server);
293
586
  });
294
587
  });
@@ -296,7 +589,9 @@ function listen(app, options = {}) {
296
589
  //#endregion
297
590
  //#region src/serve.ts
298
591
  const mimeTypes = {
592
+ ".avif": "image/avif",
299
593
  ".css": "text/css; charset=utf-8",
594
+ ".gif": "image/gif",
300
595
  ".html": "text/html; charset=utf-8",
301
596
  ".ico": "image/x-icon",
302
597
  ".jpeg": "image/jpeg",
@@ -304,8 +599,14 @@ const mimeTypes = {
304
599
  ".js": "text/javascript; charset=utf-8",
305
600
  ".json": "application/json; charset=utf-8",
306
601
  ".mjs": "text/javascript; charset=utf-8",
602
+ ".mp4": "video/mp4",
603
+ ".pdf": "application/pdf",
307
604
  ".png": "image/png",
308
605
  ".svg": "image/svg+xml",
606
+ ".txt": "text/plain; charset=utf-8",
607
+ ".wasm": "application/wasm",
608
+ ".webm": "video/webm",
609
+ ".webmanifest": "application/manifest+json; charset=utf-8",
309
610
  ".webp": "image/webp",
310
611
  ".woff": "font/woff",
311
612
  ".woff2": "font/woff2"
@@ -317,13 +618,36 @@ function isWithinRoot(root, candidate) {
317
618
  const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
318
619
  return candidate === root || candidate.startsWith(prefix);
319
620
  }
621
+ /**
622
+ * Serves an `@askrjs/server` application over Node HTTP, with optional static
623
+ * asset serving, WebSocket support, and graceful shutdown on OS signals or an
624
+ * abort signal.
625
+ *
626
+ * Requests for paths with a file extension are first checked against
627
+ * `options.assets.root` (path-traversal safe, following symlinks) and served
628
+ * directly with appropriate `content-type`/`cache-control` headers before
629
+ * falling back to the application handler. HTML responses from the
630
+ * application get a `no-cache` header when they don't already set
631
+ * `cache-control`.
632
+ *
633
+ * @param app - The application to serve; may expose an optional `close()` for cleanup.
634
+ * @param options - Serve options such as port, host, static assets, and shutdown signals.
635
+ * @returns The served application, including its bound `url` and a `close()` for shutdown.
636
+ * @example
637
+ * const app = await serve(myApp, { port: 3000, assets: { root: "./public" } });
638
+ * // ...
639
+ * await app.close();
640
+ */
320
641
  async function serve(app, options = {}) {
642
+ options.signal?.throwIfAborted();
321
643
  const host = resolveBindHost(options);
322
644
  const root = options.assets ? await realpath(resolve(options.assets.root)) : void 0;
323
- const handlerOptions = { allowedHosts: [host, "localhost"] };
645
+ const handlerOptions = handlerOptionsForHost(options, host);
646
+ const preparedHandlerOptions = prepareNodeHandlerOptions(handlerOptions);
324
647
  const applicationHandler = createNodeHandler({ async fetch(request, dispatchOptions) {
325
648
  const result = await app.fetch(request, dispatchOptions);
326
- if (!result.headers.has("cache-control") && result.headers.get("content-type")?.includes("text/html")) {
649
+ const contentType = result.headers.get("content-type");
650
+ if (!result.headers.has("cache-control") && contentType !== null && /^text\/html(?:;|$)/iu.test(contentType)) {
327
651
  const headers = new Headers(result.headers);
328
652
  headers.set("cache-control", "no-cache");
329
653
  return new Response(result.body, {
@@ -335,25 +659,29 @@ async function serve(app, options = {}) {
335
659
  return result;
336
660
  } }, handlerOptions);
337
661
  const server = createServer(async (request, response) => {
662
+ if (!root) {
663
+ applicationHandler(request, response);
664
+ return;
665
+ }
338
666
  let pathname;
339
667
  try {
340
- pathname = decodeURIComponent(resolveNodeRequestUrl(request, handlerOptions).pathname);
668
+ pathname = decodeURIComponent(resolveNodeRequestUrl(request, preparedHandlerOptions).pathname);
341
669
  } catch {
342
670
  response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end("Bad Request");
343
671
  return;
344
672
  }
345
673
  const method = request.method ?? "GET";
346
- if (root && (method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
674
+ if ((method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
347
675
  const extension = extname(pathname).toLowerCase();
348
676
  const unresolvedCandidate = resolve(root, `.${pathname}`);
349
677
  const inside = isWithinRoot(root, unresolvedCandidate);
350
678
  let candidate;
351
679
  let file;
352
680
  if (inside && extension !== ".map") try {
353
- const resolvedCandidate = await realpath(unresolvedCandidate);
681
+ const [resolvedCandidate, candidateFile] = await Promise.all([realpath(unresolvedCandidate), stat(unresolvedCandidate)]);
354
682
  if (isWithinRoot(root, resolvedCandidate)) {
355
683
  candidate = resolvedCandidate;
356
- file = await stat(candidate);
684
+ file = candidateFile;
357
685
  }
358
686
  } catch {
359
687
  candidate = void 0;
@@ -373,47 +701,64 @@ async function serve(app, options = {}) {
373
701
  "x-content-type-options": "nosniff"
374
702
  });
375
703
  if (method === "HEAD") response.end();
376
- else createReadStream(candidate).pipe(response);
704
+ else pipeline(createReadStream(candidate), response, () => void 0);
377
705
  return;
378
706
  }
379
707
  applicationHandler(request, response);
380
708
  });
381
- if (options.requestTimeout !== void 0) server.requestTimeout = options.requestTimeout;
382
- if (options.headersTimeout !== void 0) server.headersTimeout = options.headersTimeout;
383
- if (options.keepAliveTimeout !== void 0) server.keepAliveTimeout = options.keepAliveTimeout;
709
+ applyServerTimeouts(server, options);
384
710
  const webSockets = options.websocket ? installWebSockets(server, app, options.websocket === true ? {} : options.websocket, handlerOptions) : void 0;
385
711
  let closing;
712
+ let serverClosed = false;
713
+ server.once("close", () => {
714
+ serverClosed = true;
715
+ });
386
716
  const signals = options.signals === false ? [] : options.signals ?? ["SIGINT", "SIGTERM"];
717
+ let abortListener;
387
718
  const removeListeners = () => {
388
- options.signal?.removeEventListener("abort", shutdown);
719
+ abortListener?.[Symbol.dispose]();
389
720
  for (const signal of signals) process.removeListener(signal, shutdown);
390
721
  };
391
- const close = () => closing ??= new Promise((resolveClose, rejectClose) => {
722
+ const close = () => closing ??= (async () => {
392
723
  removeListeners();
393
- webSockets?.close();
394
- const finish = async (serverError) => {
395
- try {
396
- await app.close?.();
397
- if (serverError) rejectClose(serverError);
398
- else resolveClose();
399
- } catch (error) {
400
- rejectClose(error);
724
+ const closeServer = new Promise((resolveClose, rejectClose) => {
725
+ if (serverClosed) {
726
+ resolveClose();
727
+ return;
401
728
  }
402
- };
403
- if (!server.listening) finish();
404
- else server.close((error) => void finish(error));
405
- });
729
+ server.close((error) => error ? rejectClose(error) : resolveClose());
730
+ });
731
+ const errors = (await Promise.allSettled([closeServer, webSockets?.close()])).filter((result) => result.status === "rejected").map((result) => result.reason);
732
+ try {
733
+ await app.close?.();
734
+ } catch (error) {
735
+ errors.push(error);
736
+ }
737
+ if (errors.length === 1) throw errors[0];
738
+ if (errors.length > 1) throw new AggregateError(errors, "Failed to close served application.");
739
+ })();
406
740
  const shutdown = () => void close().catch(() => void 0);
407
- options.signal?.addEventListener("abort", shutdown, { once: true });
741
+ if (options.signal) abortListener = addAbortListener(options.signal, shutdown);
408
742
  for (const signal of signals) process.once(signal, shutdown);
409
743
  await new Promise((resolveListen, rejectListen) => {
410
- server.once("error", rejectListen);
744
+ const onError = (error) => {
745
+ server.off("close", onClose);
746
+ rejectListen(error);
747
+ };
748
+ const onClose = () => {
749
+ server.off("error", onError);
750
+ rejectListen(options.signal?.reason ?? /* @__PURE__ */ new Error("Node server closed before it started listening."));
751
+ };
752
+ server.once("error", onError);
753
+ server.once("close", onClose);
411
754
  server.listen(options.port ?? 0, host, options.backlog, () => {
412
- server.removeListener("error", rejectListen);
755
+ server.off("error", onError);
756
+ server.off("close", onClose);
413
757
  resolveListen();
414
758
  });
415
- }).catch((error) => {
759
+ }).catch(async (error) => {
416
760
  removeListeners();
761
+ await close().catch(() => void 0);
417
762
  throw error;
418
763
  });
419
764
  const address = server.address();
@@ -428,4 +773,4 @@ async function serve(app, options = {}) {
428
773
  });
429
774
  }
430
775
  //#endregion
431
- export { createNodeHandler, listen, serve };
776
+ export { CLIENT_ADDRESS_HEADER, createNodeHandler, listen, normalizeClientAddress, serve };