@zudojs/http 1.1.0 → 1.3.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 (137) hide show
  1. package/README.md +53 -3
  2. package/dist/httpAdapter/http.adapter.d.ts +34 -3
  3. package/dist/httpAdapter/http.adapter.js +22 -6
  4. package/dist/httpAdapter/http.adapters.d.ts +24 -3
  5. package/dist/httpAdapter/http.adapters.js +24 -45
  6. package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +2 -2
  7. package/dist/httpAdapter/httpAdapter.errorResponse.js +26 -9
  8. package/dist/httpAdapter/httpAdapter.logger.d.ts +19 -0
  9. package/dist/httpAdapter/httpAdapter.logger.js +21 -0
  10. package/dist/httpAdapter/node/httpNode.adapter.d.ts +12 -1
  11. package/dist/httpAdapter/node/httpNode.adapter.js +28 -14
  12. package/dist/httpAdapter/node/httpNode.request.d.ts +8 -7
  13. package/dist/httpAdapter/node/httpNode.request.js +17 -27
  14. package/dist/httpAdapter/node/httpNode.server.d.ts +6 -0
  15. package/dist/httpAdapter/node/httpNode.server.js +6 -0
  16. package/dist/httpAdapter/node/httpNode.type.d.ts +2 -1
  17. package/dist/httpAgent/http.agent.d.ts +22 -3
  18. package/dist/httpAgent/http.agent.js +52 -14
  19. package/dist/httpCacheControl/httpCacheControl.freshness.d.ts +7 -1
  20. package/dist/httpCacheControl/httpCacheControl.freshness.js +30 -3
  21. package/dist/httpCookies/http.cookies.d.ts +29 -2
  22. package/dist/httpCookies/http.cookies.js +44 -24
  23. package/dist/httpCookies/httpCookies.defaults.d.ts +40 -0
  24. package/dist/httpCookies/httpCookies.defaults.js +42 -0
  25. package/dist/httpCookies/index.d.ts +1 -0
  26. package/dist/httpCookies/index.js +1 -0
  27. package/dist/httpKeepAlive/httpKeepAlive.core.js +14 -2
  28. package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.d.ts +22 -1
  29. package/dist/httpMiddleware/builtin/conditional/httpMiddleware.conditional.js +19 -4
  30. package/dist/httpMiddleware/builtin/conditional/index.d.ts +1 -0
  31. package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.d.ts +8 -0
  32. package/dist/httpMiddleware/builtin/cors/httpMiddleware.cors.js +25 -22
  33. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.accessor.js +2 -6
  34. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.d.ts +33 -0
  35. package/dist/httpMiddleware/builtin/helpers/httpMiddleware.media.js +59 -0
  36. package/dist/httpMiddleware/builtin/helpers/index.d.ts +1 -0
  37. package/dist/httpMiddleware/builtin/helpers/index.js +1 -0
  38. package/dist/httpMiddleware/builtin/image/httpMiddleware.image.d.ts +13 -0
  39. package/dist/httpMiddleware/builtin/image/httpMiddleware.image.js +31 -53
  40. package/dist/httpMiddleware/builtin/index.d.ts +1 -0
  41. package/dist/httpMiddleware/builtin/index.js +1 -0
  42. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.d.ts +18 -0
  43. package/dist/httpMiddleware/builtin/logging/httpMiddleware.logging.js +17 -1
  44. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.d.ts +47 -0
  45. package/dist/httpMiddleware/builtin/rateLimit/httpMiddleware.rateLimit.js +61 -0
  46. package/dist/httpMiddleware/builtin/rateLimit/index.d.ts +7 -0
  47. package/dist/httpMiddleware/builtin/rateLimit/index.js +7 -0
  48. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.d.ts +11 -0
  49. package/dist/httpMiddleware/builtin/security/httpMiddleware.security.js +25 -12
  50. package/dist/httpMiddleware/builtin/static/httpMiddleware.static.js +18 -4
  51. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.d.ts +19 -1
  52. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.js +38 -59
  53. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.d.ts +12 -0
  54. package/dist/httpMiddleware/builtin/video/httpMiddleware.video.options.js +31 -0
  55. package/dist/httpMiddleware/httpMiddleware.error.d.ts +5 -31
  56. package/dist/httpMiddleware/httpMiddleware.error.js +5 -53
  57. package/dist/httpNegotiation/httpNegotiation.core.d.ts +13 -0
  58. package/dist/httpNegotiation/httpNegotiation.core.js +60 -8
  59. package/dist/httpProxy/http.proxy.d.ts +16 -0
  60. package/dist/httpProxy/http.proxy.js +44 -3
  61. package/dist/httpProxy/httpProxy.pathGuard.d.ts +23 -0
  62. package/dist/httpProxy/httpProxy.pathGuard.js +43 -0
  63. package/dist/httpProxy/index.d.ts +1 -0
  64. package/dist/httpProxy/index.js +1 -0
  65. package/dist/httpQuery/index.d.ts +11 -2
  66. package/dist/httpQuery/index.js +11 -2
  67. package/dist/httpQuery/queryParse/index.d.ts +10 -0
  68. package/dist/httpQuery/queryParse/index.js +10 -0
  69. package/dist/httpQuery/queryParse/queryParse.flat.d.ts +13 -0
  70. package/dist/httpQuery/queryParse/queryParse.flat.js +44 -0
  71. package/dist/httpQuery/queryParse/queryParse.nested.d.ts +25 -0
  72. package/dist/httpQuery/queryParse/queryParse.nested.js +112 -0
  73. package/dist/httpQuery/queryParse/queryParse.tokenizer.d.ts +37 -0
  74. package/dist/httpQuery/queryParse/queryParse.tokenizer.js +95 -0
  75. package/dist/httpQuery/queryRequest/index.d.ts +9 -0
  76. package/dist/httpQuery/queryRequest/index.js +9 -0
  77. package/dist/httpQuery/queryRequest/query.request.d.ts +43 -0
  78. package/dist/httpQuery/queryRequest/query.request.js +96 -0
  79. package/dist/httpQuery/querySerialize/index.d.ts +10 -0
  80. package/dist/httpQuery/querySerialize/index.js +10 -0
  81. package/dist/httpQuery/querySerialize/query.util.d.ts +21 -0
  82. package/dist/httpQuery/querySerialize/query.util.js +67 -0
  83. package/dist/httpQuery/querySerialize/querySerialize.core.d.ts +12 -0
  84. package/dist/httpQuery/querySerialize/querySerialize.core.js +97 -0
  85. package/dist/httpQuery/queryTypes/index.d.ts +11 -0
  86. package/dist/httpQuery/queryTypes/index.js +9 -0
  87. package/dist/httpQuery/queryTypes/query.container.d.ts +16 -0
  88. package/dist/httpQuery/queryTypes/query.container.js +51 -0
  89. package/dist/httpQuery/queryTypes/query.limit.d.ts +25 -0
  90. package/dist/httpQuery/queryTypes/query.limit.js +32 -0
  91. package/dist/httpQuery/queryTypes/query.type.d.ts +62 -0
  92. package/dist/httpQuery/queryTypes/query.type.js +2 -0
  93. package/dist/httpRedirect/http.redirect.d.ts +6 -0
  94. package/dist/httpRedirect/http.redirect.js +53 -2
  95. package/dist/httpRequest/http.request.d.ts +61 -2
  96. package/dist/httpRequest/http.request.js +86 -35
  97. package/dist/httpRequest/httpRequest.context.d.ts +7 -0
  98. package/dist/httpRequest/httpRequest.context.js +26 -34
  99. package/dist/httpRequest/index.d.ts +1 -0
  100. package/dist/httpRequest/index.js +1 -0
  101. package/dist/httpRequest/target/httpRequest.target.d.ts +45 -0
  102. package/dist/httpRequest/target/httpRequest.target.js +111 -0
  103. package/dist/httpRequest/target/index.d.ts +9 -0
  104. package/dist/httpRequest/target/index.js +9 -0
  105. package/dist/httpResponse/httpResponse.helper.js +2 -1
  106. package/dist/httpRouter/core/factory/httpRoute.factory.base.d.ts +18 -1
  107. package/dist/httpRouter/core/factory/httpRoute.factory.base.js +49 -6
  108. package/dist/httpRouter/core/factory/httpRoute.factory.js +3 -3
  109. package/dist/httpRouter/core/register/httpRouter.register.js +16 -21
  110. package/dist/httpRouter/core/types/httpRouter.type.d.ts +6 -0
  111. package/dist/httpRouter/core/util/httpRoute.util.d.ts +61 -0
  112. package/dist/httpRouter/core/util/httpRoute.util.js +102 -17
  113. package/dist/httpRouter/dispatch/httpRoute.dispatcher.d.ts +11 -0
  114. package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +5 -3
  115. package/dist/httpRouter/matching/httpRoute.matcher.core.js +51 -6
  116. package/dist/httpRouter/matching/httpRoute.matcher.d.ts +0 -1
  117. package/dist/httpRouter/matching/httpRoute.matcher.js +64 -26
  118. package/dist/httpRouter/pattern/httpRoute.pattern.parse.d.ts +16 -0
  119. package/dist/httpRouter/pattern/httpRoute.pattern.parse.js +40 -11
  120. package/dist/httpRouter/pattern/index.d.ts +1 -1
  121. package/dist/httpRouter/pattern/index.js +1 -1
  122. package/dist/httpSecurity/httpSecurity.config.d.ts +6 -0
  123. package/dist/httpSecurity/httpSecurity.config.js +1 -0
  124. package/dist/httpSecurity/httpSecurity.guard.d.ts +7 -9
  125. package/dist/httpSecurity/httpSecurity.guard.js +7 -14
  126. package/dist/httpSecurity/httpSecurity.nodeGuard.d.ts +48 -0
  127. package/dist/httpSecurity/httpSecurity.nodeGuard.js +32 -0
  128. package/dist/httpSecurity/httpSecurity.validator.js +20 -9
  129. package/dist/httpSecurity/index.d.ts +2 -0
  130. package/dist/httpSecurity/index.js +1 -0
  131. package/dist/httpServer/core/httpServer.core.d.ts +11 -0
  132. package/dist/httpServer/core/httpServer.core.js +30 -4
  133. package/dist/httpTrustProxy/httpTrustProxy.compilation.d.ts +5 -2
  134. package/dist/httpTrustProxy/httpTrustProxy.compilation.js +19 -2
  135. package/dist/httpTrustProxy/httpTrustProxy.type.d.ts +3 -1
  136. package/dist/index.js +0 -1
  137. package/package.json +5 -5
@@ -6,7 +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 { decodeQueryComponent } from "../../httpQuery/http.query.js";
9
+ import { parseQueryString } from "../../httpQuery/index.js";
10
+ import { findRequestTargetViolation } from "../../httpRequest/target/httpRequest.target.js";
10
11
  /* -------------------------------------------------------------------------- */
11
12
  /* Proxy Trust */
12
13
  /* -------------------------------------------------------------------------- */
@@ -122,53 +123,42 @@ export function getNodeRemoteAddress(request, options = {}) {
122
123
  /**
123
124
  * Parses the request-target's query string into a flat record.
124
125
  *
125
- * Every value is attacker-controlled. `decodeURIComponent` throws on a
126
- * malformed sequence such as `%E0`, and this ran before the adapter's
127
- * try/catch, so one such request tore the connection down instead of being
128
- * answered. Decoding is delegated to the query module's non-throwing
129
- * decoder, which also gives `+` its form-encoding meaning. A pair is split on
130
- * its **first** `=` so `a=b=c` keeps the value `b=c`.
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.
131
133
  */
132
134
  export function parseNodeQuery(request) {
133
135
  const url = request.url;
134
136
  if (!url) {
135
- return Object.freeze({});
137
+ return Object.freeze(Object.create(null));
136
138
  }
137
139
  const questionIndex = url.indexOf("?");
138
140
  if (questionIndex === -1) {
139
- return Object.freeze({});
141
+ return Object.freeze(Object.create(null));
140
142
  }
141
143
  const hashIndex = url.indexOf("#", questionIndex + 1);
142
144
  const queryString = url.slice(questionIndex + 1, hashIndex === -1 ? undefined : hashIndex);
143
- if (!queryString) {
144
- return Object.freeze({});
145
- }
146
- const params = {};
147
- for (const pair of queryString.split("&")) {
148
- if (pair === "") {
149
- continue;
150
- }
151
- const separator = pair.indexOf("=");
152
- const rawKey = separator === -1 ? pair : pair.slice(0, separator);
153
- const rawValue = separator === -1 ? "" : pair.slice(separator + 1);
154
- const key = decodeQueryComponent(rawKey);
155
- if (key) {
156
- params[key] = decodeQueryComponent(rawValue);
157
- }
158
- }
159
- return Object.freeze(params);
145
+ return Object.freeze(parseQueryString(queryString));
160
146
  }
161
147
  /* -------------------------------------------------------------------------- */
162
148
  /* Request Context */
163
149
  /* -------------------------------------------------------------------------- */
164
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
+ }
165
156
  const headers = getNodeRequestHeaders(request);
166
157
  const protocol = getNodeRequestProtocol(request, options);
167
158
  const hostname = getNodeRequestHostname(request, options);
168
159
  const port = getNodeRequestPort(request, options);
169
160
  const remoteAddress = getNodeRemoteAddress(request, options);
170
161
  const query = parseNodeQuery(request);
171
- const url = request.url ?? "/";
172
162
  return createRequestContext({
173
163
  method: request.method?.toUpperCase() ?? "GET",
174
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;
@@ -46,9 +46,28 @@ export interface AgentRegistryKey {
46
46
  readonly name?: string;
47
47
  }
48
48
  export declare function getOrCreateAgent(key: string | AgentRegistryKey, options?: HTTPAgentConfig | HTTPSAgentConfig): HTTPAgentInstance;
49
- export declare function getAgent(key: string | AgentRegistryKey): HTTPAgentInstance | undefined;
50
- export declare function hasAgent(key: string | AgentRegistryKey): boolean;
51
- export declare function removeAgent(key: string | AgentRegistryKey, destroy?: boolean): boolean;
49
+ /**
50
+ * Looks up a registered agent.
51
+ *
52
+ * `options` must be the same TLS-relevant options `getOrCreateAgent` was
53
+ * given, because they are part of the key; omitting them looks up the agent
54
+ * created with no options.
55
+ */
56
+ export declare function getAgent(key: string | AgentRegistryKey, options?: HTTPAgentConfig | HTTPSAgentConfig): HTTPAgentInstance | undefined;
57
+ /**
58
+ * Whether an agent is registered for this key and TLS option set.
59
+ */
60
+ export declare function hasAgent(key: string | AgentRegistryKey, options?: HTTPAgentConfig | HTTPSAgentConfig): boolean;
61
+ /**
62
+ * Removes registered agents for a key.
63
+ *
64
+ * With `options` the single matching agent is removed; without them every
65
+ * agent registered for that host is removed, whatever TLS options it was
66
+ * created with, so a per-host teardown releases all of its sockets.
67
+ *
68
+ * @returns Whether anything was removed.
69
+ */
70
+ export declare function removeAgent(key: string | AgentRegistryKey, destroy?: boolean, options?: HTTPAgentConfig | HTTPSAgentConfig): boolean;
52
71
  export declare function clearAgents(destroy?: boolean): void;
53
72
  export declare function getRegisteredAgentKeys(): string[];
54
73
  export declare function getDefaultHTTPAgent(): HTTPAgent;
@@ -113,7 +113,7 @@ export function getOrCreateAgent(key, options = {}) {
113
113
  * passing `rejectUnauthorized: false` would have disabled certificate
114
114
  * verification for a caller that had pinned a CA, with no way to detect it.
115
115
  */
116
- const cacheKey = `${registryKey}|${tlsFingerprint(options)}`;
116
+ const cacheKey = agentCacheKey(registryKey, options);
117
117
  const existing = agentRegistry.get(cacheKey);
118
118
  if (existing) {
119
119
  return existing;
@@ -127,6 +127,18 @@ export function getOrCreateAgent(key, options = {}) {
127
127
  agentRegistry.set(cacheKey, agent);
128
128
  return agent;
129
129
  }
130
+ /**
131
+ * Builds the registry key an agent is stored under.
132
+ *
133
+ * Every read and write goes through this one helper. `getOrCreateAgent` used
134
+ * to append the TLS fingerprint while `getAgent`, `hasAgent` and
135
+ * `removeAgent` looked up the bare registry key, so every lookup missed and
136
+ * the documented per-host teardown silently leaked the agent and its
137
+ * keep-alive sockets.
138
+ */
139
+ function agentCacheKey(registryKey, options) {
140
+ return `${registryKey}|${tlsFingerprint(options)}`;
141
+ }
130
142
  /**
131
143
  * Builds a stable fingerprint of the TLS-relevant fields of an agent config.
132
144
  */
@@ -156,23 +168,49 @@ function describeCredential(value) {
156
168
  }
157
169
  return "opaque";
158
170
  }
159
- export function getAgent(key) {
160
- return agentRegistry.get(normalizeRegistryKey(key));
171
+ /**
172
+ * Looks up a registered agent.
173
+ *
174
+ * `options` must be the same TLS-relevant options `getOrCreateAgent` was
175
+ * given, because they are part of the key; omitting them looks up the agent
176
+ * created with no options.
177
+ */
178
+ export function getAgent(key, options = {}) {
179
+ return agentRegistry.get(agentCacheKey(normalizeRegistryKey(key), options));
161
180
  }
162
- export function hasAgent(key) {
163
- return agentRegistry.has(normalizeRegistryKey(key));
181
+ /**
182
+ * Whether an agent is registered for this key and TLS option set.
183
+ */
184
+ export function hasAgent(key, options = {}) {
185
+ return agentRegistry.has(agentCacheKey(normalizeRegistryKey(key), options));
164
186
  }
165
- export function removeAgent(key, destroy = true) {
187
+ /**
188
+ * Removes registered agents for a key.
189
+ *
190
+ * With `options` the single matching agent is removed; without them every
191
+ * agent registered for that host is removed, whatever TLS options it was
192
+ * created with, so a per-host teardown releases all of its sockets.
193
+ *
194
+ * @returns Whether anything was removed.
195
+ */
196
+ export function removeAgent(key, destroy = true, options) {
166
197
  const registryKey = normalizeRegistryKey(key);
167
- const agent = agentRegistry.get(registryKey);
168
- if (!agent) {
169
- return false;
170
- }
171
- agentRegistry.delete(registryKey);
172
- if (destroy) {
173
- agent.destroy();
198
+ const cacheKeys = options === undefined
199
+ ? Array.from(agentRegistry.keys()).filter((candidate) => candidate.startsWith(`${registryKey}|`))
200
+ : [agentCacheKey(registryKey, options)];
201
+ let removed = false;
202
+ for (const cacheKey of cacheKeys) {
203
+ const agent = agentRegistry.get(cacheKey);
204
+ if (!agent) {
205
+ continue;
206
+ }
207
+ agentRegistry.delete(cacheKey);
208
+ if (destroy) {
209
+ agent.destroy();
210
+ }
211
+ removed = true;
174
212
  }
175
- return true;
213
+ return removed;
176
214
  }
177
215
  export function clearAgents(destroy = true) {
178
216
  if (destroy) {
@@ -12,11 +12,17 @@ import type { CacheFreshness } from "./core/httpCacheControl.type.js";
12
12
  * the origin marked must-revalidate-before-reuse must never be reported
13
13
  * fresh.
14
14
  *
15
+ * The response's current age follows RFC 9111 section 4.2.3: the `Age`
16
+ * header plus the time elapsed since the response's `Date`. Reading `Age`
17
+ * alone — as this used to — left every response without that header aged
18
+ * `0` forever, so `isFresh()` answered `true` for a response of any age.
19
+ *
15
20
  * @param responseHeaders - The cached response's headers.
16
21
  * @param responseDate - The response's `Date`, if already parsed.
22
+ * @param now - The current time, defaulting to `Date.now()`.
17
23
  * @returns The freshness calculation.
18
24
  */
19
- export declare function calculateFreshness(responseHeaders: Readonly<Record<string, string>>, responseDate?: Date): CacheFreshness;
25
+ export declare function calculateFreshness(responseHeaders: Readonly<Record<string, string>>, responseDate?: Date, now?: Date): CacheFreshness;
20
26
  /**
21
27
  * Determines if a cached response is still fresh.
22
28
  */
@@ -12,24 +12,37 @@ import { parseCacheControl } from "./core/httpCacheControl.parse.js";
12
12
  * the origin marked must-revalidate-before-reuse must never be reported
13
13
  * fresh.
14
14
  *
15
+ * The response's current age follows RFC 9111 section 4.2.3: the `Age`
16
+ * header plus the time elapsed since the response's `Date`. Reading `Age`
17
+ * alone — as this used to — left every response without that header aged
18
+ * `0` forever, so `isFresh()` answered `true` for a response of any age.
19
+ *
15
20
  * @param responseHeaders - The cached response's headers.
16
21
  * @param responseDate - The response's `Date`, if already parsed.
22
+ * @param now - The current time, defaulting to `Date.now()`.
17
23
  * @returns The freshness calculation.
18
24
  */
19
- export function calculateFreshness(responseHeaders, responseDate) {
25
+ export function calculateFreshness(responseHeaders, responseDate, now = new Date()) {
20
26
  const cacheControl = responseHeaders["cache-control"];
21
27
  const directives = parseCacheControl(cacheControl);
22
- const date = responseDate ?? new Date(responseHeaders["date"] ?? Date.now());
28
+ const date = responseDate ?? parseDateHeader(responseHeaders["date"], now);
23
29
  /*
24
30
  * RFC 9111 section 5.1: Age is a non-negative delta-seconds. A negative or
25
31
  * suffixed value would otherwise inflate freshness without bound, which
26
32
  * pins a poisoned response in cache far past the origin's TTL.
27
33
  */
28
34
  const rawAge = (responseHeaders["age"] ?? "").trim();
29
- const age = /^\d+$/.test(rawAge) ? Number(rawAge) : 0;
35
+ const ageHeader = /^\d+$/.test(rawAge) ? Number(rawAge) : 0;
36
+ const residentSeconds = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1_000));
37
+ const age = ageHeader + residentSeconds;
30
38
  const expiresHeader = responseHeaders["expires"];
31
39
  const expires = expiresHeader ? parseExpires(expiresHeader) : undefined;
32
40
  const effectiveMaxAge = resolveMaxAge(directives, expires, date);
41
+ /*
42
+ * With an `Expires`-derived lifetime this reduces to `expires - now`,
43
+ * because the lifetime is measured from `Date` and the age is measured to
44
+ * `now`.
45
+ */
33
46
  const remaining = Math.max(0, effectiveMaxAge - age);
34
47
  const stale = remaining <= 0 ||
35
48
  directives.noCache === true ||
@@ -43,6 +56,20 @@ export function calculateFreshness(responseHeaders, responseDate) {
43
56
  remaining,
44
57
  };
45
58
  }
59
+ /**
60
+ * Parses a `Date` header, falling back to the current time.
61
+ *
62
+ * @param value - The raw `Date` value.
63
+ * @param now - The fallback instant.
64
+ * @returns The parsed date.
65
+ */
66
+ function parseDateHeader(value, now) {
67
+ if (value === undefined) {
68
+ return now;
69
+ }
70
+ const parsed = new Date(value);
71
+ return Number.isNaN(parsed.getTime()) ? now : parsed;
72
+ }
46
73
  /**
47
74
  * Parses an `Expires` header, treating an unparseable value as expired.
48
75
  *
@@ -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,6 +7,7 @@
7
7
  * Transport-specific socket management belongs to the server/client adapter
8
8
  * layer. This module only handles HTTP-level semantics.
9
9
  */
10
+ import { assertSafeHeaderValue, escapeHeaderQuotedString, } from "../httpHeaders/security/index.js";
10
11
  /* -------------------------------------------------------------------------- */
11
12
  /* Constants */
12
13
  /* -------------------------------------------------------------------------- */
@@ -121,7 +122,9 @@ export function formatKeepAliveHeader(parameters = {}) {
121
122
  parts.push(`${key}=${quoteIfNeeded(value)}`);
122
123
  }
123
124
  }
124
- return parts.join(", ");
125
+ const header = parts.join(", ");
126
+ assertSafeHeaderValue(header);
127
+ return header;
125
128
  }
126
129
  /* -------------------------------------------------------------------------- */
127
130
  /* Connection Header */
@@ -348,10 +351,19 @@ function unquote(value) {
348
351
  }
349
352
  return trimmed;
350
353
  }
354
+ /**
355
+ * Emits a Keep-Alive parameter value.
356
+ *
357
+ * Quoting does not neutralise a CR or LF, so the control character used to
358
+ * survive into the field value. `escapeHeaderQuotedString` rejects it, the
359
+ * same helper every other quoted-parameter emitter in this package uses.
360
+ *
361
+ * @throws {TypeError} If the value contains a forbidden control character.
362
+ */
351
363
  function quoteIfNeeded(value) {
352
364
  if (/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value)) {
353
365
  return value;
354
366
  }
355
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
367
+ return `"${escapeHeaderQuotedString(value)}"`;
356
368
  }
357
369
  //# sourceMappingURL=httpKeepAlive.core.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;