@zudojs/http 1.0.0 → 1.2.0

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 (85) hide show
  1. package/README.md +64 -6
  2. package/dist/httpAdapter/http.adapter.d.ts +34 -3
  3. package/dist/httpAdapter/http.adapter.js +28 -10
  4. package/dist/httpAdapter/http.adapters.js +4 -34
  5. package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +35 -0
  6. package/dist/httpAdapter/httpAdapter.errorResponse.js +137 -0
  7. package/dist/httpAdapter/httpAdapter.logger.d.ts +19 -0
  8. package/dist/httpAdapter/httpAdapter.logger.js +21 -0
  9. package/dist/httpAdapter/node/httpNode.adapter.d.ts +20 -1
  10. package/dist/httpAdapter/node/httpNode.adapter.js +80 -20
  11. package/dist/httpAdapter/node/httpNode.request.d.ts +12 -1
  12. package/dist/httpAdapter/node/httpNode.request.js +23 -16
  13. package/dist/httpAdapter/node/httpNode.server.d.ts +6 -0
  14. package/dist/httpAdapter/node/httpNode.server.js +6 -0
  15. package/dist/httpAdapter/node/httpNode.type.d.ts +2 -1
  16. package/dist/httpClient/httpClient.executor.js +14 -3
  17. package/dist/httpCookies/http.cookies.d.ts +29 -2
  18. package/dist/httpCookies/http.cookies.js +44 -24
  19. package/dist/httpCookies/httpCookies.defaults.d.ts +40 -0
  20. package/dist/httpCookies/httpCookies.defaults.js +42 -0
  21. package/dist/httpCookies/index.d.ts +1 -0
  22. package/dist/httpCookies/index.js +1 -0
  23. package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.d.ts +22 -1
  24. package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.js +19 -4
  25. package/dist/httpMiddleware/builtin/conditional/index.d.ts +1 -0
  26. package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.d.ts +8 -0
  27. package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.js +25 -22
  28. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.accessor.js +2 -6
  29. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.d.ts +33 -0
  30. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.js +59 -0
  31. package/dist/httpMiddleware/builtin/helpers/index.d.ts +1 -0
  32. package/dist/httpMiddleware/builtin/helpers/index.js +1 -0
  33. package/dist/httpMiddleware/builtin/image/httpMiddleware.image.d.ts +13 -0
  34. package/dist/httpMiddleware/builtin/image/httpMiddleware.image.js +31 -53
  35. package/dist/httpMiddleware/builtin/index.d.ts +1 -0
  36. package/dist/httpMiddleware/builtin/index.js +1 -0
  37. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +47 -0
  38. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +61 -0
  39. package/dist/httpMiddleware/builtin/rateLimit/index.d.ts +7 -0
  40. package/dist/httpMiddleware/builtin/rateLimit/index.js +7 -0
  41. package/dist/httpMiddleware/builtin/static/httpMiddleware.static.js +30 -4
  42. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.d.ts +19 -1
  43. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.js +38 -59
  44. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.d.ts +12 -0
  45. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.js +31 -0
  46. package/dist/httpMiddleware/httpMiddleware.error.d.ts +5 -31
  47. package/dist/httpMiddleware/httpMiddleware.error.js +5 -53
  48. package/dist/httpProxy/http.proxy.d.ts +7 -0
  49. package/dist/httpProxy/http.proxy.js +9 -0
  50. package/dist/httpProxy/httpProxy.pathGuard.d.ts +23 -0
  51. package/dist/httpProxy/httpProxy.pathGuard.js +43 -0
  52. package/dist/httpProxy/index.d.ts +1 -0
  53. package/dist/httpProxy/index.js +1 -0
  54. package/dist/httpRequest/httpRequest.context.d.ts +7 -0
  55. package/dist/httpRequest/httpRequest.context.js +15 -16
  56. package/dist/httpRequest/index.d.ts +1 -0
  57. package/dist/httpRequest/index.js +1 -0
  58. package/dist/httpRequest/target/httpRequest.target.d.ts +45 -0
  59. package/dist/httpRequest/target/httpRequest.target.js +93 -0
  60. package/dist/httpRequest/target/index.d.ts +9 -0
  61. package/dist/httpRequest/target/index.js +9 -0
  62. package/dist/httpResponse/httpResponse.context.d.ts +10 -0
  63. package/dist/httpResponse/httpResponse.context.js +13 -1
  64. package/dist/httpResponse/httpResponse.helper.d.ts +22 -0
  65. package/dist/httpResponse/httpResponse.helper.js +94 -24
  66. package/dist/httpRouter/core/registry/core/httpRegistry.helper.js +1 -1
  67. package/dist/httpRouter/core/util/httpRoute.util.d.ts +14 -0
  68. package/dist/httpRouter/core/util/httpRoute.util.js +18 -14
  69. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +12 -1
  70. package/dist/httpSecurity/httpSecurity.config.d.ts +6 -0
  71. package/dist/httpSecurity/httpSecurity.config.js +1 -0
  72. package/dist/httpSecurity/httpSecurity.guard.d.ts +7 -9
  73. package/dist/httpSecurity/httpSecurity.guard.js +7 -14
  74. package/dist/httpSecurity/httpSecurity.nodeGuard.d.ts +48 -0
  75. package/dist/httpSecurity/httpSecurity.nodeGuard.js +32 -0
  76. package/dist/httpSecurity/httpSecurity.validator.js +4 -2
  77. package/dist/httpSecurity/index.d.ts +2 -0
  78. package/dist/httpSecurity/index.js +1 -0
  79. package/dist/httpServer/core/httpServer.core.d.ts +11 -0
  80. package/dist/httpServer/core/httpServer.core.js +30 -4
  81. package/dist/httpTrustProxy/httpTrustProxy.compilation.d.ts +5 -2
  82. package/dist/httpTrustProxy/httpTrustProxy.compilation.js +19 -2
  83. package/dist/httpTrustProxy/httpTrustProxy.type.d.ts +3 -1
  84. package/dist/index.js +0 -1
  85. package/package.json +9 -5
@@ -9,12 +9,15 @@
9
9
  import { IncomingMessage, Server, ServerResponse, createServer, } from "node:http";
10
10
  import { HttpRequestContext } from "../../httpRequest/httpRequest.context.js";
11
11
  import { HttpResponseContext, createResponseContext, } from "../../httpResponse/httpResponse.context.js";
12
- import { BaseHttpAdapter } from "../http.adapter.js";
12
+ import { BaseHttpAdapter, normalizeHandlerResult } from "../http.adapter.js";
13
13
  import { writeResponse } from "../../httpResponse/httpResponse.writer.js";
14
14
  import { DEFAULT_HOST, DEFAULT_PORT, DEFAULT_MAX_BODY_SIZE, NODE_DEFAULT_HEADERS_TIMEOUT, NODE_DEFAULT_REQUEST_TIMEOUT, NODE_DEFAULT_KEEP_ALIVE_TIMEOUT, validatePort, validateMaxBodySize, } from "./httpNode.type.js";
15
15
  import { NodeResponseWriter } from "./httpNode.response.js";
16
+ import { compileTrustProxy } from "../../httpTrustProxy/httpTrustProxy.compilation.js";
17
+ import { createNodeRequestGuard } from "../../httpSecurity/httpSecurity.nodeGuard.js";
16
18
  import { createNodeRequestContext } from "./httpNode.request.js";
17
- import { isIncomingMessage, isServerResponse, isNodeRequestResponsePair, configureServer, listen, closeServer, readNodeRequestBody, NodeRequestBodyTooLargeError, isResponseContextLike, } from "./httpNode.server.js";
19
+ import { resolveErrorResponse } from "../httpAdapter.errorResponse.js";
20
+ import { isIncomingMessage, isServerResponse, isNodeRequestResponsePair, configureServer, listen, closeServer, readNodeRequestBody, NodeRequestBodyTooLargeError, } from "./httpNode.server.js";
18
21
  /* -------------------------------------------------------------------------- */
19
22
  /* Node HTTP Adapter */
20
23
  /* -------------------------------------------------------------------------- */
@@ -31,8 +34,16 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
31
34
  connectionsCheckingInterval;
32
35
  shutdownGraceMs;
33
36
  events;
37
+ requestGuard;
34
38
  server;
35
39
  ownsServer = false;
40
+ /**
41
+ * The `clientError` listener installed by `start()`, kept so `stop()` can
42
+ * remove it. On an externally supplied server the instance survives a
43
+ * stop/start cycle, and re-adding the listener on every start leaked one
44
+ * per restart.
45
+ */
46
+ clientErrorListener;
36
47
  constructor(options = {}) {
37
48
  super({
38
49
  ...options,
@@ -65,7 +76,11 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
65
76
  Math.min(30_000, this.headersTimeout);
66
77
  this.shutdownGraceMs = options.shutdownGraceMs;
67
78
  this.trustProxy = options.trustProxy;
79
+ if (options.trustProxy !== undefined) {
80
+ compileTrustProxy(options.trustProxy);
81
+ }
68
82
  this.events = options.events ?? {};
83
+ this.requestGuard = createNodeRequestGuard(options.security);
69
84
  this.server = options.server;
70
85
  this.ownsServer = !options.server;
71
86
  }
@@ -124,7 +139,25 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
124
139
  }
125
140
  const request = input.request;
126
141
  const response = input.response;
127
- const context = this.createRequest(request);
142
+ if (this.requestGuard && !this.requestGuard(request).allowed) {
143
+ await this.writeBadRequest(response);
144
+ return;
145
+ }
146
+ let context;
147
+ try {
148
+ context = this.createRequest(request);
149
+ }
150
+ catch (error) {
151
+ /*
152
+ * The request could not even be described (an unparseable request
153
+ * target, a header the context refuses). It is the client's fault, so
154
+ * answer 400 rather than letting the rejection destroy the socket
155
+ * without a response.
156
+ */
157
+ this.emitAdapterError(error);
158
+ await this.writeBadRequest(response);
159
+ return;
160
+ }
128
161
  try {
129
162
  await this.attachNodeBody(request, context);
130
163
  const result = await this.executeNodeHandler(context);
@@ -161,17 +194,12 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
161
194
  }
162
195
  return this.handler(request);
163
196
  }
197
+ /**
198
+ * Plain objects are data and are sent as JSON; see
199
+ * `normalizeHandlerResult`.
200
+ */
164
201
  normalizeResult(result) {
165
- if (result instanceof HttpResponseContext) {
166
- return result;
167
- }
168
- if (result === undefined || result === null) {
169
- return createResponseContext();
170
- }
171
- if (isResponseContextLike(result)) {
172
- return createResponseContext(result);
173
- }
174
- return createResponseContext().json(result);
202
+ return normalizeHandlerResult(result);
175
203
  }
176
204
  async handleNodeError(error, request, response) {
177
205
  if (response.headersSent) {
@@ -199,11 +227,27 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
199
227
  // Fall through to the safe internal server error response.
200
228
  }
201
229
  }
202
- context.internalServerError().json({
203
- error: "Internal Server Error",
204
- });
230
+ /*
231
+ * A thrown `HttpError` (or one buried under the middleware pipeline's
232
+ * wrappers) is answered with its own status, exposed message and headers;
233
+ * anything else stays a generic 500.
234
+ */
235
+ const resolved = resolveErrorResponse(error);
236
+ for (const [name, value] of Object.entries(resolved.headers)) {
237
+ context.setHeader(name, value);
238
+ }
239
+ context.setStatus(resolved.status).json(resolved.body);
205
240
  await this.writeNodeResponse(response, context);
206
241
  }
242
+ async writeBadRequest(response) {
243
+ if (response.headersSent) {
244
+ response.destroy();
245
+ return;
246
+ }
247
+ const context = createResponseContext();
248
+ context.setHeader("connection", "close");
249
+ await this.writeNodeResponse(response, context.setStatus(400).json({ error: "Bad Request" }));
250
+ }
207
251
  async writeNodeResponse(response, context) {
208
252
  const writer = this.createWriter(response);
209
253
  await writeResponse(context, writer);
@@ -265,13 +309,17 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
265
309
  connectionTimeout: this.connectionTimeout,
266
310
  maxConnections: this.maxConnections,
267
311
  });
268
- this.server.on("clientError", (error, socket) => {
312
+ if (this.clientErrorListener) {
313
+ this.server.off("clientError", this.clientErrorListener);
314
+ }
315
+ this.clientErrorListener = (error, socket) => {
269
316
  this.emitAdapterError(error);
270
317
  if (socket.writable) {
271
318
  socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
272
319
  }
273
320
  socket.destroy();
274
- });
321
+ };
322
+ this.server.on("clientError", this.clientErrorListener);
275
323
  await listen(this.server, this.port, this.host);
276
324
  const address = this.address;
277
325
  if (address && this.events.onListening) {
@@ -284,12 +332,24 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
284
332
  }
285
333
  await super.start();
286
334
  }
287
- async stop() {
335
+ /**
336
+ * Closes the server. The grace period is `options.graceMs` (the server's
337
+ * `gracefulShutdownTimeout`), capped by an explicit `shutdownGraceMs`;
338
+ * with neither it is 10 s.
339
+ */
340
+ async stop(options = {}) {
288
341
  if (!this.server || !this.server.listening) {
289
342
  await super.stop();
290
343
  return;
291
344
  }
292
- await closeServer(this.server, { graceMs: this.shutdownGraceMs });
345
+ const graceMs = options.graceMs === undefined
346
+ ? this.shutdownGraceMs
347
+ : Math.min(options.graceMs, this.shutdownGraceMs ?? Infinity);
348
+ await closeServer(this.server, { graceMs });
349
+ if (this.clientErrorListener) {
350
+ this.server.off("clientError", this.clientErrorListener);
351
+ this.clientErrorListener = undefined;
352
+ }
293
353
  if (this.ownsServer) {
294
354
  this.server = undefined;
295
355
  }
@@ -18,6 +18,17 @@ export declare function getNodeRequestPort(request: IncomingMessage, options?: N
18
18
  * `getClientIp` so there is a single implementation of the hop logic.
19
19
  */
20
20
  export declare function getNodeRemoteAddress(request: IncomingMessage, options?: NodeRequestOptions): string | undefined;
21
- export declare function parseNodeQuery(request: IncomingMessage): Readonly<Record<string, string>>;
21
+ /**
22
+ * Parses the request-target's query string into a flat record.
23
+ *
24
+ * Every value is attacker-controlled, so decoding never throws (a malformed
25
+ * `%E0` is kept raw) and `+` has its form-encoding meaning. This uses the same
26
+ * parser as the router's `ctx.query` (`parseQueryString`), so both agree: a
27
+ * repeated name is an array (`?role=user&role=admin` gives
28
+ * `["user", "admin"]`), the record has a `null` prototype, and
29
+ * `__proto__` / `constructor` / `prototype` are dropped. Previously the last
30
+ * value silently won here while the router returned the array.
31
+ */
32
+ export declare function parseNodeQuery(request: IncomingMessage): Readonly<Record<string, string | readonly string[]>>;
22
33
  export declare function createNodeRequestContext(request: IncomingMessage, options?: NodeRequestOptions): HttpRequestContext;
23
34
  //# sourceMappingURL=httpNode.request.d.ts.map
@@ -6,6 +6,8 @@
6
6
  import { HttpRequestContext, createRequestContext, } from "../../httpRequest/httpRequest.context.js";
7
7
  import { getClientIp, isTrustedProxy, } from "../../httpTrustProxy/httpTrustProxy.core.js";
8
8
  import { removePort, extractPort } from "./httpNode.server.js";
9
+ import { parseQueryString } from "../../httpQuery/http.query.js";
10
+ import { findRequestTargetViolation } from "../../httpRequest/target/httpRequest.target.js";
9
11
  /* -------------------------------------------------------------------------- */
10
12
  /* Proxy Trust */
11
13
  /* -------------------------------------------------------------------------- */
@@ -118,40 +120,45 @@ export function getNodeRemoteAddress(request, options = {}) {
118
120
  };
119
121
  return getClientIp(proxyRequest, trustProxy) ?? peer;
120
122
  }
123
+ /**
124
+ * Parses the request-target's query string into a flat record.
125
+ *
126
+ * Every value is attacker-controlled, so decoding never throws (a malformed
127
+ * `%E0` is kept raw) and `+` has its form-encoding meaning. This uses the same
128
+ * parser as the router's `ctx.query` (`parseQueryString`), so both agree: a
129
+ * repeated name is an array (`?role=user&role=admin` gives
130
+ * `["user", "admin"]`), the record has a `null` prototype, and
131
+ * `__proto__` / `constructor` / `prototype` are dropped. Previously the last
132
+ * value silently won here while the router returned the array.
133
+ */
121
134
  export function parseNodeQuery(request) {
122
135
  const url = request.url;
123
136
  if (!url) {
124
- return Object.freeze({});
137
+ return Object.freeze(Object.create(null));
125
138
  }
126
139
  const questionIndex = url.indexOf("?");
127
140
  if (questionIndex === -1) {
128
- return Object.freeze({});
141
+ return Object.freeze(Object.create(null));
129
142
  }
130
- const queryString = url.slice(questionIndex + 1);
131
- if (!queryString) {
132
- return Object.freeze({});
133
- }
134
- const params = {};
135
- for (const pair of queryString.split("&")) {
136
- const [key, value] = pair.split("=");
137
- if (key) {
138
- params[decodeURIComponent(key)] =
139
- value !== undefined ? decodeURIComponent(value) : "";
140
- }
141
- }
142
- return Object.freeze(params);
143
+ const hashIndex = url.indexOf("#", questionIndex + 1);
144
+ const queryString = url.slice(questionIndex + 1, hashIndex === -1 ? undefined : hashIndex);
145
+ return Object.freeze(parseQueryString(queryString));
143
146
  }
144
147
  /* -------------------------------------------------------------------------- */
145
148
  /* Request Context */
146
149
  /* -------------------------------------------------------------------------- */
147
150
  export function createNodeRequestContext(request, options = {}) {
151
+ const url = request.url ?? "/";
152
+ const violation = findRequestTargetViolation(url);
153
+ if (violation !== undefined) {
154
+ throw new TypeError(violation);
155
+ }
148
156
  const headers = getNodeRequestHeaders(request);
149
157
  const protocol = getNodeRequestProtocol(request, options);
150
158
  const hostname = getNodeRequestHostname(request, options);
151
159
  const port = getNodeRequestPort(request, options);
152
160
  const remoteAddress = getNodeRemoteAddress(request, options);
153
161
  const query = parseNodeQuery(request);
154
- const url = request.url ?? "/";
155
162
  return createRequestContext({
156
163
  method: request.method?.toUpperCase() ?? "GET",
157
164
  url,
@@ -43,6 +43,12 @@ export declare function getSearchPart(url: string): string;
43
43
  import { RequestBodyTooLargeError as NodeRequestBodyTooLargeError } from "@zudojs/errors";
44
44
  export { NodeRequestBodyTooLargeError };
45
45
  export declare function readNodeRequestBody(request: IncomingMessage, maxBodySize?: number): Promise<Uint8Array>;
46
+ /**
47
+ * Whether a value has the shape of a response init.
48
+ *
49
+ * @deprecated No longer used by the Node adapter: a plain object returned
50
+ * from a handler is always sent as JSON. Kept for API compatibility.
51
+ */
46
52
  export declare function isResponseContextLike(value: unknown): value is {
47
53
  readonly status?: number;
48
54
  readonly statusText?: string;
@@ -250,6 +250,12 @@ export function readNodeRequestBody(request, maxBodySize = DEFAULT_MAX_BODY_SIZE
250
250
  /* -------------------------------------------------------------------------- */
251
251
  /* Response Result Detection */
252
252
  /* -------------------------------------------------------------------------- */
253
+ /**
254
+ * Whether a value has the shape of a response init.
255
+ *
256
+ * @deprecated No longer used by the Node adapter: a plain object returned
257
+ * from a handler is always sent as JSON. Kept for API compatibility.
258
+ */
253
259
  export function isResponseContextLike(value) {
254
260
  if (value === null || typeof value !== "object") {
255
261
  return false;
@@ -6,7 +6,8 @@
6
6
  import type { Server } from "node:http";
7
7
  import type { HttpAdapterOptions } from "../http.adapter.js";
8
8
  import type { TrustProxy } from "../../httpTrustProxy/httpTrustProxy.core.js";
9
- export interface NodeAdapterOptions extends HttpAdapterOptions {
9
+ import type { NodeAdapterSecurityOptions } from "../../httpSecurity/httpSecurity.nodeGuard.js";
10
+ export interface NodeAdapterOptions extends HttpAdapterOptions, NodeAdapterSecurityOptions {
10
11
  readonly host?: string;
11
12
  readonly port?: number;
12
13
  readonly server?: Server;
@@ -60,9 +60,20 @@ async function executeOnce(context, client) {
60
60
  }, timeout);
61
61
  }
62
62
  const signal = combineAbortSignals(context.config.signal, controller?.signal);
63
- const request = signal === context.request.signal
64
- ? context.request
65
- : new Request(context.request, { signal });
63
+ /*
64
+ * A `Request` body can be dispatched once. Every attempt used to be built
65
+ * from `context.request` itself, which consumed its body on the first try
66
+ * and made the first *retry* of any body-bearing request fail with
67
+ * "Request object that has already been used" — so `retryMethods: ["POST"]`
68
+ * could never retry. Each attempt now works on a clone, and the original
69
+ * is left untouched for the next one.
70
+ */
71
+ const attemptRequest = context.request.body !== null && !context.request.bodyUsed
72
+ ? context.request.clone()
73
+ : context.request;
74
+ const request = signal === attemptRequest.signal
75
+ ? attemptRequest
76
+ : new Request(attemptRequest, { signal });
66
77
  try {
67
78
  const raw = await fetchFollowingRedirects(request, client);
68
79
  const response = await parseResponse(raw, context.config.responseType ?? "auto");
@@ -57,7 +57,14 @@ export declare const MAX_COOKIE_HEADER_LENGTH: number;
57
57
  * @returns The parsed cookie jar.
58
58
  */
59
59
  export declare function parseCookies(header: string | undefined): CookieCollection;
60
- export declare function serializeCookie(name: string, value: CookieValue, options?: CookieOptions): string;
60
+ /**
61
+ * Serializes a `Set-Cookie` header value.
62
+ *
63
+ * Unset attributes take the secure defaults (`Path=/; HttpOnly; Secure;
64
+ * SameSite=Lax`, see `DEFAULT_COOKIE_ATTRIBUTES`); pass `httpOnly: false`,
65
+ * `secure: false`, another `sameSite` or `path` to override them.
66
+ */
67
+ export declare function serializeCookie(name: string, value: CookieValue, cookieOptions?: CookieOptions): string;
61
68
  export interface CookieManager {
62
69
  get(name: string): string | undefined;
63
70
  has(name: string): boolean;
@@ -78,11 +85,31 @@ export interface SignedCookie {
78
85
  readonly value: string;
79
86
  readonly signature: string;
80
87
  }
88
+ /**
89
+ * Serializes a signed cookie. The MAC covers the cookie **name** as well as
90
+ * the value, so a signature minted for one cookie is not accepted for
91
+ * another; read it back with `parseSignedCookie(value, secret, name)`.
92
+ */
81
93
  export declare function serializeSignedCookie(name: string, value: string, options: SignedCookieOptions): string;
82
- export declare function parseSignedCookie(value: string | undefined, secret: string): string | undefined;
94
+ /**
95
+ * Verifies a signed cookie value and returns the original value, or
96
+ * `undefined` when the signature does not match.
97
+ *
98
+ * Pass the cookie's `name`: the signature produced by
99
+ * `serializeSignedCookie` is bound to it, so a value lifted from another
100
+ * signed cookie (for example a user-chosen display name replayed as
101
+ * `session_user`) is rejected. Without `name` only a legacy, value-only
102
+ * signature (from `signCookieValue(value, secret)`) verifies, and
103
+ * name-bound signatures never do.
104
+ */
105
+ export declare function parseSignedCookie(value: string | undefined, secret: string, name?: string): string | undefined;
83
106
  /**
84
107
  * Signs a cookie value with HMAC-SHA256.
85
108
  *
109
+ * Synchronous by contract, so the MAC is computed with `node:crypto`: every
110
+ * `@zudojs/crypto` HMAC helper is asynchronous. Verification compares with
111
+ * `@zudojs/crypto`'s constant-time `timingSafeEqualString`.
112
+ *
86
113
  * @param value - The value to authenticate.
87
114
  * @param secret - The signing key.
88
115
  * @returns The base64url signature.
@@ -1,4 +1,6 @@
1
- import { createHmac, timingSafeEqual as cryptoTimingSafeEqual, } from "node:crypto";
1
+ import { createHmac } from "node:crypto";
2
+ import { timingSafeEqualString } from "@zudojs/crypto";
3
+ import { withSecureCookieDefaults } from "./httpCookies.defaults.js";
2
4
  /* -------------------------------------------------------------------------- */
3
5
  /* Cookie Collection */
4
6
  /* -------------------------------------------------------------------------- */
@@ -123,7 +125,15 @@ export function parseCookies(header) {
123
125
  /* -------------------------------------------------------------------------- */
124
126
  /* Serialize Cookie */
125
127
  /* -------------------------------------------------------------------------- */
126
- export function serializeCookie(name, value, options = {}) {
128
+ /**
129
+ * Serializes a `Set-Cookie` header value.
130
+ *
131
+ * Unset attributes take the secure defaults (`Path=/; HttpOnly; Secure;
132
+ * SameSite=Lax`, see `DEFAULT_COOKIE_ATTRIBUTES`); pass `httpOnly: false`,
133
+ * `secure: false`, another `sameSite` or `path` to override them.
134
+ */
135
+ export function serializeCookie(name, value, cookieOptions = {}) {
136
+ const options = withSecureCookieDefaults(cookieOptions, "lax");
127
137
  validateCookieName(name);
128
138
  validateCookiePrefix(name, options);
129
139
  const encodedName = name;
@@ -229,11 +239,27 @@ export function createCookieManager(request, response) {
229
239
  },
230
240
  };
231
241
  }
242
+ /**
243
+ * Serializes a signed cookie. The MAC covers the cookie **name** as well as
244
+ * the value, so a signature minted for one cookie is not accepted for
245
+ * another; read it back with `parseSignedCookie(value, secret, name)`.
246
+ */
232
247
  export function serializeSignedCookie(name, value, options) {
233
- const signature = signCookieValue(value, options.secret);
248
+ const signature = signCookieValue(bindCookieName(name, value), options.secret);
234
249
  return serializeCookie(name, `${value}.${signature}`, options);
235
250
  }
236
- export function parseSignedCookie(value, secret) {
251
+ /**
252
+ * Verifies a signed cookie value and returns the original value, or
253
+ * `undefined` when the signature does not match.
254
+ *
255
+ * Pass the cookie's `name`: the signature produced by
256
+ * `serializeSignedCookie` is bound to it, so a value lifted from another
257
+ * signed cookie (for example a user-chosen display name replayed as
258
+ * `session_user`) is rejected. Without `name` only a legacy, value-only
259
+ * signature (from `signCookieValue(value, secret)`) verifies, and
260
+ * name-bound signatures never do.
261
+ */
262
+ export function parseSignedCookie(value, secret, name) {
237
263
  if (!value) {
238
264
  return undefined;
239
265
  }
@@ -243,15 +269,27 @@ export function parseSignedCookie(value, secret) {
243
269
  }
244
270
  const originalValue = value.slice(0, separator);
245
271
  const signature = value.slice(separator + 1);
246
- const expected = signCookieValue(originalValue, secret);
247
- if (!timingSafeEqual(signature, expected)) {
272
+ const expected = signCookieValue(name === undefined ? originalValue : bindCookieName(name, originalValue), secret);
273
+ if (!timingSafeEqualString(signature, expected)) {
248
274
  return undefined;
249
275
  }
250
276
  return originalValue;
251
277
  }
278
+ /**
279
+ * The MAC input for a name-bound signature. A cookie name is an RFC 6265
280
+ * token and cannot contain `=`, so the encoding is unambiguous.
281
+ */
282
+ function bindCookieName(name, value) {
283
+ validateCookieName(name);
284
+ return `${name}=${value}`;
285
+ }
252
286
  /**
253
287
  * Signs a cookie value with HMAC-SHA256.
254
288
  *
289
+ * Synchronous by contract, so the MAC is computed with `node:crypto`: every
290
+ * `@zudojs/crypto` HMAC helper is asynchronous. Verification compares with
291
+ * `@zudojs/crypto`'s constant-time `timingSafeEqualString`.
292
+ *
255
293
  * @param value - The value to authenticate.
256
294
  * @param secret - The signing key.
257
295
  * @returns The base64url signature.
@@ -396,22 +434,4 @@ function normalizePriority(value) {
396
434
  throw new TypeError(`Invalid cookie priority: ${String(value)}`);
397
435
  }
398
436
  }
399
- /* -------------------------------------------------------------------------- */
400
- /* Hash Helpers */
401
- /* -------------------------------------------------------------------------- */
402
- /**
403
- * Compares two signatures in constant time.
404
- *
405
- * @param left - The candidate signature.
406
- * @param right - The expected signature.
407
- * @returns `true` if the two are byte-identical.
408
- */
409
- function timingSafeEqual(left, right) {
410
- const leftBuffer = Buffer.from(left, "utf8");
411
- const rightBuffer = Buffer.from(right, "utf8");
412
- if (leftBuffer.length !== rightBuffer.length) {
413
- return false;
414
- }
415
- return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
416
- }
417
437
  //# sourceMappingURL=http.cookies.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Secure cookie defaults.
3
+ *
4
+ * Both cookie serializers (`serializeCookie` in this module and the
5
+ * `serializeResponseCookie` the adapters use for `response.cookie()`) start
6
+ * from these defaults, so a cookie set "the obvious way" is `HttpOnly`,
7
+ * `Secure`, `SameSite=Lax` and `Path=/`, matching the defaults of
8
+ * `@zudojs/security`'s `serializeCookie` and the AGENTS.md secure-defaults
9
+ * rule. Every default can be overridden explicitly (`httpOnly: false`,
10
+ * `secure: false`, `sameSite: "none"`, `path: "/app"`); an option left
11
+ * `undefined` keeps the default.
12
+ *
13
+ * @module httpCookies/defaults
14
+ */
15
+ /**
16
+ * The attributes every cookie gets unless the caller overrides them.
17
+ */
18
+ export declare const DEFAULT_COOKIE_ATTRIBUTES: Readonly<{
19
+ readonly path: "/";
20
+ readonly httpOnly: true;
21
+ readonly secure: true;
22
+ readonly sameSite: "lax";
23
+ }>;
24
+ interface DefaultableCookieOptions<S extends string> {
25
+ readonly path?: string;
26
+ readonly httpOnly?: boolean;
27
+ readonly secure?: boolean;
28
+ readonly sameSite?: S;
29
+ }
30
+ /**
31
+ * Returns `options` with the secure defaults filled in for every attribute
32
+ * the caller left `undefined`.
33
+ *
34
+ * @param options - The caller's cookie options.
35
+ * @param sameSite - The default `SameSite` value in the option type's own
36
+ * spelling (`"lax"` or `"Lax"`).
37
+ */
38
+ export declare function withSecureCookieDefaults<S extends string, T extends DefaultableCookieOptions<S>>(options: T | undefined, sameSite: S): T;
39
+ export {};
40
+ //# sourceMappingURL=httpCookies.defaults.d.ts.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Secure cookie defaults.
3
+ *
4
+ * Both cookie serializers (`serializeCookie` in this module and the
5
+ * `serializeResponseCookie` the adapters use for `response.cookie()`) start
6
+ * from these defaults, so a cookie set "the obvious way" is `HttpOnly`,
7
+ * `Secure`, `SameSite=Lax` and `Path=/`, matching the defaults of
8
+ * `@zudojs/security`'s `serializeCookie` and the AGENTS.md secure-defaults
9
+ * rule. Every default can be overridden explicitly (`httpOnly: false`,
10
+ * `secure: false`, `sameSite: "none"`, `path: "/app"`); an option left
11
+ * `undefined` keeps the default.
12
+ *
13
+ * @module httpCookies/defaults
14
+ */
15
+ /**
16
+ * The attributes every cookie gets unless the caller overrides them.
17
+ */
18
+ export const DEFAULT_COOKIE_ATTRIBUTES = Object.freeze({
19
+ path: "/",
20
+ httpOnly: true,
21
+ secure: true,
22
+ sameSite: "lax",
23
+ });
24
+ /**
25
+ * Returns `options` with the secure defaults filled in for every attribute
26
+ * the caller left `undefined`.
27
+ *
28
+ * @param options - The caller's cookie options.
29
+ * @param sameSite - The default `SameSite` value in the option type's own
30
+ * spelling (`"lax"` or `"Lax"`).
31
+ */
32
+ export function withSecureCookieDefaults(options, sameSite) {
33
+ const source = (options ?? {});
34
+ return {
35
+ ...source,
36
+ path: source.path ?? DEFAULT_COOKIE_ATTRIBUTES.path,
37
+ httpOnly: source.httpOnly ?? DEFAULT_COOKIE_ATTRIBUTES.httpOnly,
38
+ secure: source.secure ?? DEFAULT_COOKIE_ATTRIBUTES.secure,
39
+ sameSite: source.sameSite ?? sameSite,
40
+ };
41
+ }
42
+ //# sourceMappingURL=httpCookies.defaults.js.map
@@ -4,4 +4,5 @@
4
4
  * HTTP httpCookies module.
5
5
  */
6
6
  export * from "./http.cookies.js";
7
+ export * from "./httpCookies.defaults.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,5 @@
4
4
  * HTTP httpCookies module.
5
5
  */
6
6
  export * from "./http.cookies.js";
7
+ export * from "./httpCookies.defaults.js";
7
8
  //# sourceMappingURL=index.js.map
@@ -7,7 +7,28 @@ import type { HttpMiddleware, HttpMiddlewareContext } from "../../httpMiddleware
7
7
  import type { HttpResponseContext as ResponseContext } from "../../../httpResponse/httpResponse.context.js";
8
8
  export declare function createAsyncMiddleware(factory: (context: HttpMiddlewareContext) => Promise<HttpMiddleware>): HttpMiddleware;
9
9
  export declare function createConditionalMiddleware(predicate: (context: HttpMiddlewareContext) => boolean | Promise<boolean>, middleware: HttpMiddleware): HttpMiddleware;
10
- export declare function createPathMiddleware(path: string, middleware: HttpMiddleware): HttpMiddleware;
10
+ /**
11
+ * Options for {@link createPathMiddleware}.
12
+ */
13
+ export interface PathMiddlewareOptions {
14
+ /**
15
+ * Match the path case-sensitively. Defaults to `false`, the router's
16
+ * default, so a guard scoped to `/admin` also covers `/Admin`, which the
17
+ * router would dispatch to the same route.
18
+ */
19
+ readonly caseSensitive?: boolean;
20
+ }
21
+ /**
22
+ * Runs `middleware` only for requests addressed to `path`.
23
+ *
24
+ * The request path is normalised exactly as the router normalises it before
25
+ * matching: it is read with the canonical request-target parser, repeated
26
+ * slashes are collapsed, a trailing slash is ignored and (by default) case is
27
+ * ignored. An exact, case-sensitive comparison let `/Admin`, `/admin/` and
28
+ * `/admin//` skip a guard on `/admin` while the router still served the
29
+ * protected route.
30
+ */
31
+ export declare function createPathMiddleware(path: string, middleware: HttpMiddleware, options?: PathMiddlewareOptions): HttpMiddleware;
11
32
  export declare function createMethodMiddleware(method: string, middleware: HttpMiddleware): HttpMiddleware;
12
33
  export declare function createResponseMiddleware(response: Response | ResponseContext): HttpMiddleware;
13
34
  export declare function createShortCircuitMiddleware(predicate: (context: HttpMiddlewareContext) => boolean | Promise<boolean>, response: Response | ResponseContext): HttpMiddleware;
@@ -4,6 +4,7 @@
4
4
  * @module httpMiddleware/builtin/conditional
5
5
  */
6
6
  import { getRequestMethod, getRequestUrl, extractPathname, } from "../helpers/index.js";
7
+ import { normalizePath } from "../../../httpRouter/core/util/httpRoute.util.js";
7
8
  import { isWebResponse, bufferWebResponse, } from "../../../httpResponse/httpResponse.fromWeb.js";
8
9
  export function createAsyncMiddleware(factory) {
9
10
  return async (context, next) => {
@@ -19,12 +20,26 @@ export function createConditionalMiddleware(predicate, middleware) {
19
20
  return next();
20
21
  };
21
22
  }
22
- export function createPathMiddleware(path, middleware) {
23
- const targetPath = extractPathname(path);
23
+ /**
24
+ * Runs `middleware` only for requests addressed to `path`.
25
+ *
26
+ * The request path is normalised exactly as the router normalises it before
27
+ * matching: it is read with the canonical request-target parser, repeated
28
+ * slashes are collapsed, a trailing slash is ignored and (by default) case is
29
+ * ignored. An exact, case-sensitive comparison let `/Admin`, `/admin/` and
30
+ * `/admin//` skip a guard on `/admin` while the router still served the
31
+ * protected route.
32
+ */
33
+ export function createPathMiddleware(path, middleware, options = {}) {
34
+ const caseSensitive = options.caseSensitive === true;
35
+ const canonical = (value) => {
36
+ const normalized = normalizePath(value);
37
+ return caseSensitive ? normalized : normalized.toLowerCase();
38
+ };
39
+ const targetPath = canonical(extractPathname(path));
24
40
  return createConditionalMiddleware((context) => {
25
41
  const url = getRequestUrl(context.request);
26
- const pathname = extractPathname(url);
27
- return pathname === targetPath;
42
+ return canonical(extractPathname(url)) === targetPath;
28
43
  }, middleware);
29
44
  }
30
45
  export function createMethodMiddleware(method, middleware) {
@@ -4,4 +4,5 @@
4
4
  * @module httpMiddleware/builtin/conditional
5
5
  */
6
6
  export { createAsyncMiddleware, createConditionalMiddleware, createPathMiddleware, createMethodMiddleware, createResponseMiddleware, createShortCircuitMiddleware, } from "./httpMiddleware.conditional.js";
7
+ export type { PathMiddlewareOptions } from "./httpMiddleware.conditional.js";
7
8
  //# sourceMappingURL=index.d.ts.map