@zudojs/http 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -11,19 +11,27 @@ npm install @zudojs/http
11
11
  ## Quick Start
12
12
 
13
13
  ```typescript
14
- import { createHTTPServer } from "@zudojs/http";
15
-
16
- const server = createHTTPServer({
17
- handler: {
18
- async fetch(request) {
19
- return new Response("Hello from Zudojs");
20
- },
14
+ import {
15
+ createHttpServer,
16
+ createNodeHttpAdapter,
17
+ createResponseContext,
18
+ } from "@zudojs/http";
19
+
20
+ const server = createHttpServer({
21
+ adapter: createNodeHttpAdapter({ host: "127.0.0.1", port: 3000 }),
22
+ handler: async (request) => {
23
+ return createResponseContext().text(`Hello from Zudojs (${request.path})`);
21
24
  },
22
25
  });
23
26
 
24
27
  await server.start();
25
28
  ```
26
29
 
30
+ A handler receives an `HttpRequestContext` and may return an
31
+ `HttpResponseContext`, a plain `{ status, headers, body }` object, or any JSON
32
+ value (which is sent as `application/json`). Throwing an `HttpError` created by
33
+ `notFound()`, `unauthorized()` and friends answers with that error's status.
34
+
27
35
  ## Features
28
36
 
29
37
  - Runtime-independent HTTP server abstraction
@@ -11,6 +11,7 @@
11
11
  import { HttpRequestContext, createRequestContext, } from "../httpRequest/httpRequest.context.js";
12
12
  import { HttpResponseContext, createResponseContext, } from "../httpResponse/httpResponse.context.js";
13
13
  import { writeResponse } from "../httpResponse/httpResponse.writer.js";
14
+ import { resolveErrorResponse } from "./httpAdapter.errorResponse.js";
14
15
  /* -------------------------------------------------------------------------- */
15
16
  /* Adapter Error */
16
17
  /* -------------------------------------------------------------------------- */
@@ -85,10 +86,11 @@ export class BaseHttpAdapter {
85
86
  mergeResponseContext(response, context);
86
87
  }
87
88
  else {
88
- response.internalServerError();
89
- response.json({
90
- error: "Internal Server Error",
91
- });
89
+ const resolved = resolveErrorResponse(error);
90
+ for (const [name, value] of Object.entries(resolved.headers)) {
91
+ response.setHeader(name, value);
92
+ }
93
+ response.setStatus(resolved.status).json(resolved.body);
92
94
  }
93
95
  await this.write(input, response);
94
96
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Default error-to-response mapping shared by the adapters.
3
+ *
4
+ * An error thrown from a handler used to be answered with a generic 500 no
5
+ * matter what it was, so `throw notFound()` — the documented way to fail a
6
+ * request — reached the client as `500 Internal Server Error` with the
7
+ * error's status, message and headers (`WWW-Authenticate`, `Allow`,
8
+ * `Retry-After`) all discarded. Middleware wrappers made it worse: the
9
+ * pipeline wraps every failure in `HttpMiddlewareError` /
10
+ * `HttpMiddlewarePipelineError`, both of which report status 500, so the
11
+ * original error's status was hidden even from a custom error handler that
12
+ * only looked one level deep.
13
+ *
14
+ * This module is deliberately internal: it is not part of the public API.
15
+ *
16
+ * @module httpAdapter/errorResponse
17
+ */
18
+ export interface ResolvedErrorResponse {
19
+ readonly status: number;
20
+ readonly body: Readonly<Record<string, unknown>>;
21
+ readonly headers: Readonly<Record<string, string>>;
22
+ }
23
+ /**
24
+ * Builds the default response for an unhandled error.
25
+ *
26
+ * - An error (or any error in its `cause` / `errors` chain) with a 4xx/5xx
27
+ * `statusCode` is answered with that status.
28
+ * - Its `message` and `code` are included only when the error opts in with
29
+ * `expose: true` (the `@zudojs/errors` default for 4xx); otherwise the
30
+ * body carries the status text alone, so a 5xx never leaks internals.
31
+ * - Its `headers` are applied when they are valid header values.
32
+ * - Anything else is a generic `500 Internal Server Error`.
33
+ */
34
+ export declare function resolveErrorResponse(error: unknown): ResolvedErrorResponse;
35
+ //# sourceMappingURL=httpAdapter.errorResponse.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Default error-to-response mapping shared by the adapters.
3
+ *
4
+ * An error thrown from a handler used to be answered with a generic 500 no
5
+ * matter what it was, so `throw notFound()` — the documented way to fail a
6
+ * request — reached the client as `500 Internal Server Error` with the
7
+ * error's status, message and headers (`WWW-Authenticate`, `Allow`,
8
+ * `Retry-After`) all discarded. Middleware wrappers made it worse: the
9
+ * pipeline wraps every failure in `HttpMiddlewareError` /
10
+ * `HttpMiddlewarePipelineError`, both of which report status 500, so the
11
+ * original error's status was hidden even from a custom error handler that
12
+ * only looked one level deep.
13
+ *
14
+ * This module is deliberately internal: it is not part of the public API.
15
+ *
16
+ * @module httpAdapter/errorResponse
17
+ */
18
+ import { getStatusText } from "../httpResponse/core/httpResponse.statusText.js";
19
+ import { isValidHeaderFieldValue } from "../httpHeaders/security/index.js";
20
+ /* -------------------------------------------------------------------------- */
21
+ /* Constants */
22
+ /* -------------------------------------------------------------------------- */
23
+ /**
24
+ * How many levels of `cause` / `errors` nesting are inspected. Deep enough
25
+ * for the pipeline's two wrappers plus a few application layers; bounded so a
26
+ * cyclic `cause` chain cannot loop.
27
+ */
28
+ const MAX_UNWRAP_DEPTH = 8;
29
+ /* -------------------------------------------------------------------------- */
30
+ /* Resolution */
31
+ /* -------------------------------------------------------------------------- */
32
+ /**
33
+ * Locates the innermost error that carries an HTTP status.
34
+ *
35
+ * The innermost one wins because outer errors are wrappers added on the way
36
+ * out (middleware, pipeline, adapter); the error the application actually
37
+ * threw sits at the bottom of the chain.
38
+ */
39
+ function findStatusError(error, depth, seen) {
40
+ if (error === null || typeof error !== "object" || depth > MAX_UNWRAP_DEPTH) {
41
+ return undefined;
42
+ }
43
+ if (seen.has(error)) {
44
+ return undefined;
45
+ }
46
+ seen.add(error);
47
+ const candidate = error;
48
+ const nested = [];
49
+ if (Array.isArray(candidate.errors)) {
50
+ nested.push(...candidate.errors);
51
+ }
52
+ if (candidate.cause !== undefined) {
53
+ nested.push(candidate.cause);
54
+ }
55
+ for (const inner of nested) {
56
+ const found = findStatusError(inner, depth + 1, seen);
57
+ if (found) {
58
+ return found;
59
+ }
60
+ }
61
+ if (isHttpStatus(candidate.statusCode)) {
62
+ return candidate;
63
+ }
64
+ return undefined;
65
+ }
66
+ function isHttpStatus(value) {
67
+ return (typeof value === "number" &&
68
+ Number.isInteger(value) &&
69
+ value >= 400 &&
70
+ value <= 599);
71
+ }
72
+ /**
73
+ * Builds the default response for an unhandled error.
74
+ *
75
+ * - An error (or any error in its `cause` / `errors` chain) with a 4xx/5xx
76
+ * `statusCode` is answered with that status.
77
+ * - Its `message` and `code` are included only when the error opts in with
78
+ * `expose: true` (the `@zudojs/errors` default for 4xx); otherwise the
79
+ * body carries the status text alone, so a 5xx never leaks internals.
80
+ * - Its `headers` are applied when they are valid header values.
81
+ * - Anything else is a generic `500 Internal Server Error`.
82
+ */
83
+ export function resolveErrorResponse(error) {
84
+ const statusError = findStatusError(error, 0, new Set());
85
+ if (!statusError) {
86
+ return {
87
+ status: 500,
88
+ body: { error: "Internal Server Error" },
89
+ headers: {},
90
+ };
91
+ }
92
+ const status = statusError.statusCode;
93
+ const expose = statusError.expose === true;
94
+ const message = expose && typeof statusError.message === "string" && statusError.message
95
+ ? statusError.message
96
+ : getStatusText(status);
97
+ const body = { error: message };
98
+ if (expose && typeof statusError.code === "string" && statusError.code) {
99
+ body.code = statusError.code;
100
+ }
101
+ return {
102
+ status,
103
+ body,
104
+ headers: collectHeaders(statusError.headers),
105
+ };
106
+ }
107
+ function collectHeaders(value) {
108
+ if (value === null || typeof value !== "object") {
109
+ return {};
110
+ }
111
+ const headers = {};
112
+ for (const [name, entry] of Object.entries(value)) {
113
+ if (typeof entry !== "string" || !isValidHeaderFieldValue(entry)) {
114
+ continue;
115
+ }
116
+ headers[name] = entry;
117
+ }
118
+ return headers;
119
+ }
120
+ //# sourceMappingURL=httpAdapter.errorResponse.js.map
@@ -27,6 +27,13 @@ export declare class NodeHttpAdapter extends BaseHttpAdapter {
27
27
  private readonly events;
28
28
  private server;
29
29
  private ownsServer;
30
+ /**
31
+ * The `clientError` listener installed by `start()`, kept so `stop()` can
32
+ * remove it. On an externally supplied server the instance survives a
33
+ * stop/start cycle, and re-adding the listener on every start leaked one
34
+ * per restart.
35
+ */
36
+ private clientErrorListener;
30
37
  constructor(options?: NodeAdapterOptions);
31
38
  get httpServer(): Server | undefined;
32
39
  get address(): NodeServerAddress | undefined;
@@ -45,6 +52,7 @@ export declare class NodeHttpAdapter extends BaseHttpAdapter {
45
52
  private executeNodeHandler;
46
53
  private normalizeResult;
47
54
  private handleNodeError;
55
+ private writeBadRequest;
48
56
  private writeNodeResponse;
49
57
  /**
50
58
  * Runs `handle` for a Node request/response pair without ever letting the
@@ -14,6 +14,7 @@ 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
16
  import { createNodeRequestContext } from "./httpNode.request.js";
17
+ import { resolveErrorResponse } from "../httpAdapter.errorResponse.js";
17
18
  import { isIncomingMessage, isServerResponse, isNodeRequestResponsePair, configureServer, listen, closeServer, readNodeRequestBody, NodeRequestBodyTooLargeError, isResponseContextLike, } from "./httpNode.server.js";
18
19
  /* -------------------------------------------------------------------------- */
19
20
  /* Node HTTP Adapter */
@@ -33,6 +34,13 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
33
34
  events;
34
35
  server;
35
36
  ownsServer = false;
37
+ /**
38
+ * The `clientError` listener installed by `start()`, kept so `stop()` can
39
+ * remove it. On an externally supplied server the instance survives a
40
+ * stop/start cycle, and re-adding the listener on every start leaked one
41
+ * per restart.
42
+ */
43
+ clientErrorListener;
36
44
  constructor(options = {}) {
37
45
  super({
38
46
  ...options,
@@ -124,7 +132,21 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
124
132
  }
125
133
  const request = input.request;
126
134
  const response = input.response;
127
- const context = this.createRequest(request);
135
+ let context;
136
+ try {
137
+ context = this.createRequest(request);
138
+ }
139
+ catch (error) {
140
+ /*
141
+ * The request could not even be described (an unparseable request
142
+ * target, a header the context refuses). It is the client's fault, so
143
+ * answer 400 rather than letting the rejection destroy the socket
144
+ * without a response.
145
+ */
146
+ this.emitAdapterError(error);
147
+ await this.writeBadRequest(response);
148
+ return;
149
+ }
128
150
  try {
129
151
  await this.attachNodeBody(request, context);
130
152
  const result = await this.executeNodeHandler(context);
@@ -199,11 +221,27 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
199
221
  // Fall through to the safe internal server error response.
200
222
  }
201
223
  }
202
- context.internalServerError().json({
203
- error: "Internal Server Error",
204
- });
224
+ /*
225
+ * A thrown `HttpError` (or one buried under the middleware pipeline's
226
+ * wrappers) is answered with its own status, exposed message and headers;
227
+ * anything else stays a generic 500.
228
+ */
229
+ const resolved = resolveErrorResponse(error);
230
+ for (const [name, value] of Object.entries(resolved.headers)) {
231
+ context.setHeader(name, value);
232
+ }
233
+ context.setStatus(resolved.status).json(resolved.body);
205
234
  await this.writeNodeResponse(response, context);
206
235
  }
236
+ async writeBadRequest(response) {
237
+ if (response.headersSent) {
238
+ response.destroy();
239
+ return;
240
+ }
241
+ const context = createResponseContext();
242
+ context.setHeader("connection", "close");
243
+ await this.writeNodeResponse(response, context.setStatus(400).json({ error: "Bad Request" }));
244
+ }
207
245
  async writeNodeResponse(response, context) {
208
246
  const writer = this.createWriter(response);
209
247
  await writeResponse(context, writer);
@@ -265,13 +303,17 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
265
303
  connectionTimeout: this.connectionTimeout,
266
304
  maxConnections: this.maxConnections,
267
305
  });
268
- this.server.on("clientError", (error, socket) => {
306
+ if (this.clientErrorListener) {
307
+ this.server.off("clientError", this.clientErrorListener);
308
+ }
309
+ this.clientErrorListener = (error, socket) => {
269
310
  this.emitAdapterError(error);
270
311
  if (socket.writable) {
271
312
  socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
272
313
  }
273
314
  socket.destroy();
274
- });
315
+ };
316
+ this.server.on("clientError", this.clientErrorListener);
275
317
  await listen(this.server, this.port, this.host);
276
318
  const address = this.address;
277
319
  if (address && this.events.onListening) {
@@ -290,6 +332,10 @@ export class NodeHttpAdapter extends BaseHttpAdapter {
290
332
  return;
291
333
  }
292
334
  await closeServer(this.server, { graceMs: this.shutdownGraceMs });
335
+ if (this.clientErrorListener) {
336
+ this.server.off("clientError", this.clientErrorListener);
337
+ this.clientErrorListener = undefined;
338
+ }
293
339
  if (this.ownsServer) {
294
340
  this.server = undefined;
295
341
  }
@@ -18,6 +18,16 @@ 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
+ /**
22
+ * Parses the request-target's query string into a flat record.
23
+ *
24
+ * Every value is attacker-controlled. `decodeURIComponent` throws on a
25
+ * malformed sequence such as `%E0`, and this ran before the adapter's
26
+ * try/catch, so one such request tore the connection down instead of being
27
+ * answered. Decoding is delegated to the query module's non-throwing
28
+ * decoder, which also gives `+` its form-encoding meaning. A pair is split on
29
+ * its **first** `=` so `a=b=c` keeps the value `b=c`.
30
+ */
21
31
  export declare function parseNodeQuery(request: IncomingMessage): Readonly<Record<string, string>>;
22
32
  export declare function createNodeRequestContext(request: IncomingMessage, options?: NodeRequestOptions): HttpRequestContext;
23
33
  //# sourceMappingURL=httpNode.request.d.ts.map
@@ -6,6 +6,7 @@
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
10
  /* -------------------------------------------------------------------------- */
10
11
  /* Proxy Trust */
11
12
  /* -------------------------------------------------------------------------- */
@@ -118,6 +119,16 @@ export function getNodeRemoteAddress(request, options = {}) {
118
119
  };
119
120
  return getClientIp(proxyRequest, trustProxy) ?? peer;
120
121
  }
122
+ /**
123
+ * Parses the request-target's query string into a flat record.
124
+ *
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`.
131
+ */
121
132
  export function parseNodeQuery(request) {
122
133
  const url = request.url;
123
134
  if (!url) {
@@ -127,16 +138,22 @@ export function parseNodeQuery(request) {
127
138
  if (questionIndex === -1) {
128
139
  return Object.freeze({});
129
140
  }
130
- const queryString = url.slice(questionIndex + 1);
141
+ const hashIndex = url.indexOf("#", questionIndex + 1);
142
+ const queryString = url.slice(questionIndex + 1, hashIndex === -1 ? undefined : hashIndex);
131
143
  if (!queryString) {
132
144
  return Object.freeze({});
133
145
  }
134
146
  const params = {};
135
147
  for (const pair of queryString.split("&")) {
136
- const [key, value] = pair.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);
137
155
  if (key) {
138
- params[decodeURIComponent(key)] =
139
- value !== undefined ? decodeURIComponent(value) : "";
156
+ params[key] = decodeQueryComponent(rawValue);
140
157
  }
141
158
  }
142
159
  return Object.freeze(params);
@@ -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");
@@ -133,7 +133,19 @@ export function createStaticMiddleware(options) {
133
133
  if (method !== undefined && method !== "GET" && method !== "HEAD") {
134
134
  return next();
135
135
  }
136
- const url = new URL(context.request.url);
136
+ /*
137
+ * `context.request.url` is the request-target as the adapter received
138
+ * it — `/pub/app.js`, not an absolute URL — so it must be parsed against
139
+ * a base. `new URL(url)` alone threw `Invalid URL` for every request the
140
+ * Node adapter delivered, and the middleware answered 500 to all of them.
141
+ */
142
+ let url;
143
+ try {
144
+ url = new URL(context.request.url, "http://zudojs.invalid");
145
+ }
146
+ catch {
147
+ return next();
148
+ }
137
149
  const decodedPathname = decodePathname(url.pathname);
138
150
  if (decodedPathname === undefined) {
139
151
  return next();
@@ -29,6 +29,16 @@ export declare class HttpResponseContext {
29
29
  html(content: string): this;
30
30
  cookie(name: string, value: string, options?: ResponseCookie["options"]): this;
31
31
  removeCookie(name: string): this;
32
+ /**
33
+ * Redirects to `url`.
34
+ *
35
+ * The destination goes through `assertSafeRedirect`: a `javascript:` or
36
+ * `data:` URL, a scheme-relative `//evil.com`, or a value carrying a
37
+ * control character throws instead of being emitted as `Location`. An
38
+ * absolute `http(s)` URL and a same-origin path reference are accepted.
39
+ *
40
+ * @throws {TypeError} If the destination is not a safe redirect target.
41
+ */
32
42
  redirect(url: string, status?: number): this;
33
43
  markSent(): void;
34
44
  assertMutable(): void;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { DEFAULT_RESPONSE_STATUS } from "./core/httpResponse.type.js";
7
7
  import { getStatusText } from "./core/httpResponse.statusText.js";
8
+ import { assertSafeRedirect } from "../httpRedirect/http.redirect.js";
8
9
  export class HttpResponseContext {
9
10
  _status;
10
11
  _statusText;
@@ -86,10 +87,21 @@ export class HttpResponseContext {
86
87
  this._cookies = this._cookies.filter((c) => c.name !== name);
87
88
  return this;
88
89
  }
90
+ /**
91
+ * Redirects to `url`.
92
+ *
93
+ * The destination goes through `assertSafeRedirect`: a `javascript:` or
94
+ * `data:` URL, a scheme-relative `//evil.com`, or a value carrying a
95
+ * control character throws instead of being emitted as `Location`. An
96
+ * absolute `http(s)` URL and a same-origin path reference are accepted.
97
+ *
98
+ * @throws {TypeError} If the destination is not a safe redirect target.
99
+ */
89
100
  redirect(url, status = 302) {
101
+ const location = assertSafeRedirect(url);
90
102
  this._status = status;
91
103
  this._statusText = getStatusText(status);
92
- this._headers["location"] = url;
104
+ this._headers["location"] = location;
93
105
  return this;
94
106
  }
95
107
  markSent() {
@@ -30,6 +30,12 @@ export declare function htmlResponse(html: string, status?: number, headers?: Re
30
30
  };
31
31
  /**
32
32
  * Creates a redirect response.
33
+ *
34
+ * The destination goes through {@link assertSafeRedirect}: a `javascript:` or
35
+ * `data:` URL, a scheme-relative `//evil.com`, or a value carrying a control
36
+ * character throws instead of being emitted as `Location`.
37
+ *
38
+ * @throws {TypeError} If the destination is not a safe redirect target.
33
39
  */
34
40
  export declare function redirectResponse(url: string, status?: number, headers?: ResponseHeaders): {
35
41
  readonly status: number;
@@ -46,6 +52,22 @@ export declare function emptyResponse(status?: number, headers?: ResponseHeaders
46
52
  };
47
53
  /**
48
54
  * Serializes a response cookie into a Set-Cookie header string.
55
+ *
56
+ * This is the serializer the adapters use for `response.cookie()` and it
57
+ * previously concatenated every field verbatim, so a value such as
58
+ * `x; Domain=evil.com` became a second attribute and a `__Host-` cookie
59
+ * missing `Secure` was emitted (and silently dropped by the browser). It now
60
+ * applies the same rules as `serializeCookie()` in the cookies module:
61
+ *
62
+ * - the name must be an RFC 6265 token;
63
+ * - `__Host-` / `__Secure-` prefix constraints are enforced;
64
+ * - `Domain` and `Path` may not contain `;` or a control character;
65
+ * - a value that is not made only of `cookie-octet` characters is
66
+ * percent-encoded, so it can neither inject an attribute nor split the
67
+ * header (a value that is already valid is emitted unchanged);
68
+ * - `maxAge` must be finite and `expires` must be a valid `Date`.
69
+ *
70
+ * @throws {TypeError} If the name, a prefix rule or an attribute is invalid.
49
71
  */
50
72
  export declare function serializeResponseCookie(cookie: ResponseCookie): string;
51
73
  //# sourceMappingURL=httpResponse.helper.d.ts.map
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @module httpResponse/helpers
5
5
  */
6
+ import { assertSafeRedirect } from "../httpRedirect/http.redirect.js";
6
7
  /**
7
8
  * Creates a JSON response.
8
9
  */
@@ -44,12 +45,18 @@ export function htmlResponse(html, status = 200, headers = {}) {
44
45
  }
45
46
  /**
46
47
  * Creates a redirect response.
48
+ *
49
+ * The destination goes through {@link assertSafeRedirect}: a `javascript:` or
50
+ * `data:` URL, a scheme-relative `//evil.com`, or a value carrying a control
51
+ * character throws instead of being emitted as `Location`.
52
+ *
53
+ * @throws {TypeError} If the destination is not a safe redirect target.
47
54
  */
48
55
  export function redirectResponse(url, status = 302, headers = {}) {
49
56
  return {
50
57
  status,
51
58
  headers: {
52
- location: url,
59
+ location: assertSafeRedirect(url),
53
60
  ...headers,
54
61
  },
55
62
  body: undefined,
@@ -65,40 +72,102 @@ export function emptyResponse(status = 204, headers = {}) {
65
72
  body: undefined,
66
73
  };
67
74
  }
75
+ /**
76
+ * RFC 6265 `cookie-name`, which is an RFC 9110 `token`.
77
+ */
78
+ const COOKIE_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
79
+ /**
80
+ * RFC 6265 `cookie-octet`: printable US-ASCII minus CTLs, SP, DQUOTE, comma,
81
+ * semicolon and backslash.
82
+ */
83
+ const COOKIE_OCTETS = /^[\u0021\u0023-\u002b\u002d-\u003a\u003c-\u005b\u005d-\u007e]*$/;
84
+ /**
85
+ * Characters that must never appear in a cookie attribute value: `;` starts
86
+ * the next attribute, and any control character can split the header.
87
+ */
88
+ const COOKIE_ATTRIBUTE_FORBIDDEN = /[;\u0000-\u001f\u007f]/;
68
89
  /**
69
90
  * Serializes a response cookie into a Set-Cookie header string.
91
+ *
92
+ * This is the serializer the adapters use for `response.cookie()` and it
93
+ * previously concatenated every field verbatim, so a value such as
94
+ * `x; Domain=evil.com` became a second attribute and a `__Host-` cookie
95
+ * missing `Secure` was emitted (and silently dropped by the browser). It now
96
+ * applies the same rules as `serializeCookie()` in the cookies module:
97
+ *
98
+ * - the name must be an RFC 6265 token;
99
+ * - `__Host-` / `__Secure-` prefix constraints are enforced;
100
+ * - `Domain` and `Path` may not contain `;` or a control character;
101
+ * - a value that is not made only of `cookie-octet` characters is
102
+ * percent-encoded, so it can neither inject an attribute nor split the
103
+ * header (a value that is already valid is emitted unchanged);
104
+ * - `maxAge` must be finite and `expires` must be a valid `Date`.
105
+ *
106
+ * @throws {TypeError} If the name, a prefix rule or an attribute is invalid.
70
107
  */
71
108
  export function serializeResponseCookie(cookie) {
72
- let str = `${cookie.name}=${cookie.value}`;
73
- if (cookie.options) {
74
- const opts = cookie.options;
75
- if (opts.domain) {
76
- str += `; Domain=${opts.domain}`;
77
- }
78
- if (opts.path) {
79
- str += `; Path=${opts.path}`;
80
- }
81
- if (opts.expires instanceof Date) {
82
- str += `; Expires=${opts.expires.toUTCString()}`;
109
+ const name = cookie.name;
110
+ if (!name || !COOKIE_NAME_TOKEN.test(name)) {
111
+ throw new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`);
112
+ }
113
+ const opts = cookie.options ?? {};
114
+ if (name.startsWith("__Host-")) {
115
+ if (!opts.secure) {
116
+ throw new TypeError("A __Host- cookie requires the Secure attribute.");
83
117
  }
84
- if (typeof opts.maxAge === "number") {
85
- str += `; Max-Age=${opts.maxAge}`;
118
+ if (opts.domain) {
119
+ throw new TypeError("A __Host- cookie must not set a Domain attribute.");
86
120
  }
87
- if (opts.httpOnly) {
88
- str += "; HttpOnly";
121
+ if ((opts.path ?? "/") !== "/") {
122
+ throw new TypeError("A __Host- cookie requires Path=/.");
89
123
  }
90
- if (opts.secure) {
91
- str += "; Secure";
124
+ }
125
+ else if (name.startsWith("__Secure-") && !opts.secure) {
126
+ throw new TypeError("A __Secure- cookie requires the Secure attribute.");
127
+ }
128
+ const rawValue = String(cookie.value);
129
+ const value = COOKIE_OCTETS.test(rawValue)
130
+ ? rawValue
131
+ : encodeURIComponent(rawValue);
132
+ let str = `${name}=${value}`;
133
+ if (opts.domain) {
134
+ if (COOKIE_ATTRIBUTE_FORBIDDEN.test(opts.domain)) {
135
+ throw new TypeError("Invalid cookie domain.");
92
136
  }
93
- if (opts.sameSite) {
94
- str += `; SameSite=${opts.sameSite}`;
137
+ str += `; Domain=${opts.domain}`;
138
+ }
139
+ if (opts.path) {
140
+ if (COOKIE_ATTRIBUTE_FORBIDDEN.test(opts.path)) {
141
+ throw new TypeError("Invalid cookie path.");
95
142
  }
96
- if (opts.priority) {
97
- str += `; Priority=${opts.priority}`;
143
+ str += `; Path=${opts.path}`;
144
+ }
145
+ if (opts.expires instanceof Date) {
146
+ if (Number.isNaN(opts.expires.getTime())) {
147
+ throw new TypeError("Cookie expires must be a valid Date.");
98
148
  }
99
- if (opts.partitioned) {
100
- str += "; Partitioned";
149
+ str += `; Expires=${opts.expires.toUTCString()}`;
150
+ }
151
+ if (typeof opts.maxAge === "number") {
152
+ if (!Number.isFinite(opts.maxAge)) {
153
+ throw new TypeError("Cookie maxAge must be a finite number.");
101
154
  }
155
+ str += `; Max-Age=${Math.floor(opts.maxAge)}`;
156
+ }
157
+ if (opts.httpOnly) {
158
+ str += "; HttpOnly";
159
+ }
160
+ if (opts.secure) {
161
+ str += "; Secure";
162
+ }
163
+ if (opts.sameSite) {
164
+ str += `; SameSite=${opts.sameSite}`;
165
+ }
166
+ if (opts.priority) {
167
+ str += `; Priority=${opts.priority}`;
168
+ }
169
+ if (opts.partitioned) {
170
+ str += "; Partitioned";
102
171
  }
103
172
  return str;
104
173
  }
@@ -42,7 +42,7 @@ export function normalizePath(path, options) {
42
42
  normalized = normalized.toLowerCase();
43
43
  }
44
44
  if (!options.strict) {
45
- normalized = normalized.replace(/\/+$/, "") || "/";
45
+ normalized = normalized.replace(/(?<!\/)\/+$/, "") || "/";
46
46
  }
47
47
  return normalized;
48
48
  }
@@ -229,8 +229,19 @@ function toDispatchHandler(handler, context) {
229
229
  signal: getRequestSignal(request),
230
230
  }));
231
231
  if (result instanceof HttpResponseContext && result !== response) {
232
- response.status_code(result.status);
232
+ /*
233
+ * Everything the handler put on its response must survive the merge.
234
+ * Copying only status, headers and body dropped every cookie the
235
+ * handler set, its custom status text and its metadata.
236
+ */
237
+ response.setStatus(result.status, result.statusText);
233
238
  response.headers_obj(result.headers);
239
+ for (const cookie of result.cookies) {
240
+ response.setCookie(cookie);
241
+ }
242
+ for (const [key, value] of Object.entries(result.metadata)) {
243
+ response.setMetadata(key, value);
244
+ }
234
245
  response.setBody(result.body);
235
246
  }
236
247
  };
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/http",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "HTTP primitives, request handling, routing, middleware, and server infrastructure for Zudojs applications.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -20,10 +24,10 @@
20
24
  "!dist/.tsbuildinfo"
21
25
  ],
22
26
  "dependencies": {
23
- "@zudojs/core": "1.0.0",
24
- "@zudojs/errors": "1.0.0",
25
- "@zudojs/logger": "1.0.0",
26
- "@zudojs/security": "1.0.0"
27
+ "@zudojs/core": "1.1.0",
28
+ "@zudojs/errors": "1.0.1",
29
+ "@zudojs/logger": "1.1.0",
30
+ "@zudojs/security": "1.0.1"
27
31
  },
28
32
  "devDependencies": {
29
33
  "@types/node": "^26.4.1",