@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 +15 -7
- package/dist/httpAdapter/http.adapter.js +6 -4
- package/dist/httpAdapter/httpAdapter.errorResponse.d.ts +35 -0
- package/dist/httpAdapter/httpAdapter.errorResponse.js +120 -0
- package/dist/httpAdapter/node/httpNode.adapter.d.ts +8 -0
- package/dist/httpAdapter/node/httpNode.adapter.js +52 -6
- package/dist/httpAdapter/node/httpNode.request.d.ts +10 -0
- package/dist/httpAdapter/node/httpNode.request.js +21 -4
- package/dist/httpClient/httpClient.executor.js +14 -3
- package/dist/httpMiddleware/builtin/static/httpMiddleware.static.js +13 -1
- package/dist/httpResponse/httpResponse.context.d.ts +10 -0
- package/dist/httpResponse/httpResponse.context.js +13 -1
- package/dist/httpResponse/httpResponse.helper.d.ts +22 -0
- package/dist/httpResponse/httpResponse.helper.js +93 -24
- package/dist/httpRouter/core/registry/core/httpRegistry.helper.js +1 -1
- package/dist/httpRouter/dispatch/httpRoute.dispatcher.js +12 -1
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -11,19 +11,27 @@ npm install @zudojs/http
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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"] =
|
|
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
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (opts.
|
|
79
|
-
|
|
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 (
|
|
85
|
-
|
|
118
|
+
if (opts.domain) {
|
|
119
|
+
throw new TypeError("A __Host- cookie must not set a Domain attribute.");
|
|
86
120
|
}
|
|
87
|
-
if (opts.
|
|
88
|
-
|
|
121
|
+
if ((opts.path ?? "/") !== "/") {
|
|
122
|
+
throw new TypeError("A __Host- cookie requires Path=/.");
|
|
89
123
|
}
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@zudojs/errors": "1.0.
|
|
25
|
-
"@zudojs/logger": "1.
|
|
26
|
-
"@zudojs/security": "1.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",
|