@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.5
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 +103 -41
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +41 -5
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +104 -19
- package/dist/app.d.ts +131 -11
- package/dist/app.js +305 -217
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +41 -1
- package/dist/client.d.ts +64 -18
- package/dist/client.js +36 -6
- package/dist/combine.d.ts +11 -11
- package/dist/combine.js +90 -47
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/docs.d.ts +5 -9
- package/dist/docs.js +36 -14
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +12 -3
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +13 -1
- package/dist/idempotency.js +2 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/internal-response.d.ts +15 -0
- package/dist/internal-response.js +27 -0
- package/dist/jwk.d.ts +11 -7
- package/dist/jwk.js +11 -7
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +21 -15
- package/dist/middleware.d.ts +48 -7
- package/dist/middleware.js +129 -43
- package/dist/mtls.d.ts +6 -5
- package/dist/mtls.js +8 -9
- package/dist/openapi.js +1 -1
- package/dist/pagination.js +4 -1
- package/dist/response-cache.js +2 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +9 -2
- package/dist/safe-redirect.js +29 -4
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +62 -0
- package/dist/security.js +220 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/tenancy.d.ts +2 -2
- package/dist/time-claims.js +3 -1
- package/dist/types.d.ts +85 -20
- package/dist/types.js +16 -1
- package/dist/waf.js +86 -26
- package/package.json +11 -4
package/dist/adapters/lambda.js
CHANGED
|
@@ -1,17 +1,56 @@
|
|
|
1
|
+
import { setConnInfo } from "../conn-info.js";
|
|
1
2
|
const TEXT_TYPE_RE = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|.*\+json|.*\+xml))/i;
|
|
2
3
|
/**
|
|
3
4
|
* Wrap an {@link App} as a Lambda/Netlify handler accepting either v1.0 or v2.0 event payloads.
|
|
4
5
|
*
|
|
6
|
+
* A malformed event (e.g. a `Host`/path combination that cannot form a valid
|
|
7
|
+
* URL) is answered with a clean `400` problem+json instead of throwing out of
|
|
8
|
+
* the handler, which API Gateway would otherwise surface as an opaque `502`.
|
|
9
|
+
*
|
|
5
10
|
* @param app - The DaloyJS {@link App} that serves each translated request.
|
|
6
11
|
* @returns A {@link LambdaHandler} that converts the event to a `Request`, calls {@link App.fetch}, and emits the matching v1.0/v2.0 response shape.
|
|
7
12
|
*/
|
|
8
13
|
export function toLambdaHandler(app) {
|
|
9
14
|
return async (event) => {
|
|
10
|
-
|
|
15
|
+
let request;
|
|
16
|
+
try {
|
|
17
|
+
request = eventToRequest(event);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return responseToLambda(badRequestResponse(), isV2Event(event));
|
|
21
|
+
}
|
|
11
22
|
const response = await app.fetch(request);
|
|
12
23
|
return responseToLambda(response, isV2Event(event));
|
|
13
24
|
};
|
|
14
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Wrap an {@link App} as an AWS Lambda response-streaming handler.
|
|
28
|
+
*
|
|
29
|
+
* The returned handler is decorated with the managed Node.js runtime's
|
|
30
|
+
* `awslambda.streamifyResponse()` helper, attaches status/headers with
|
|
31
|
+
* `HttpResponseStream.from()`, and pumps the web-standard response body while
|
|
32
|
+
* honoring writable-stream backpressure. The function throws during startup
|
|
33
|
+
* outside an AWS Lambda Node.js runtime so an accidentally buffered or broken
|
|
34
|
+
* deployment cannot start silently.
|
|
35
|
+
*
|
|
36
|
+
* @param app - The DaloyJS {@link App} that serves each translated request.
|
|
37
|
+
* @returns A response-streaming Lambda handler for Function URLs, API Gateway streaming proxy integrations, or `InvokeWithResponseStream`.
|
|
38
|
+
* @throws {Error} If the AWS Lambda response-streaming globals are unavailable.
|
|
39
|
+
*/
|
|
40
|
+
export function toLambdaStreamHandler(app) {
|
|
41
|
+
const runtime = lambdaStreamingRuntime();
|
|
42
|
+
return runtime.streamifyResponse(async (event, rawStream) => {
|
|
43
|
+
let request;
|
|
44
|
+
try {
|
|
45
|
+
request = eventToRequest(event);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
await streamLambdaResponse(badRequestResponse(), rawStream, runtime);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await streamLambdaResponse(await app.fetch(request), rawStream, runtime);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
15
54
|
function eventToRequest(event) {
|
|
16
55
|
const headers = new Headers();
|
|
17
56
|
for (const [k, v] of Object.entries(event.headers ?? {})) {
|
|
@@ -28,35 +67,37 @@ function eventToRequest(event) {
|
|
|
28
67
|
}
|
|
29
68
|
if ("cookies" in event && event.cookies?.length)
|
|
30
69
|
headers.set("cookie", event.cookies.join("; "));
|
|
31
|
-
const method = isV2Event(event)
|
|
70
|
+
const method = isV2Event(event)
|
|
71
|
+
? (event.requestContext?.http?.method ?? "GET")
|
|
72
|
+
: (event.httpMethod ?? "GET");
|
|
32
73
|
const rawPath = isV2Event(event)
|
|
33
|
-
? event.rawPath ?? event.requestContext?.http?.path ?? "/"
|
|
34
|
-
: event.path ?? event.requestContext?.path ?? "/";
|
|
74
|
+
? (event.rawPath ?? event.requestContext?.http?.path ?? "/")
|
|
75
|
+
: (event.path ?? event.requestContext?.path ?? "/");
|
|
35
76
|
const host = headers.get("host") ?? event.requestContext?.domainName ?? "localhost";
|
|
36
77
|
const proto = headers.get("x-forwarded-proto") ?? "https";
|
|
37
|
-
const rawQueryString = isV2Event(event) ? event.rawQueryString ?? "" : queryStringForV1(event);
|
|
78
|
+
const rawQueryString = isV2Event(event) ? (event.rawQueryString ?? "") : queryStringForV1(event);
|
|
38
79
|
const qs = rawQueryString ? `?${rawQueryString}` : "";
|
|
39
80
|
const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
40
81
|
const url = `${proto}://${host}${path}${qs}`;
|
|
41
82
|
const init = { method, headers };
|
|
42
83
|
if (method !== "GET" && method !== "HEAD" && event.body != null) {
|
|
43
|
-
init.body = event.isBase64Encoded
|
|
44
|
-
|
|
45
|
-
|
|
84
|
+
init.body = event.isBase64Encoded ? base64ToBytes(event.body) : event.body;
|
|
85
|
+
}
|
|
86
|
+
const request = new Request(url, init);
|
|
87
|
+
// Fulfil the conn-info contract with the caller address API Gateway saw
|
|
88
|
+
// (v2: `requestContext.http.sourceIp`, v1: `requestContext.identity.sourceIp`),
|
|
89
|
+
// so `getConnInfo` / `resolveClientIp` work on Lambda. API Gateway and
|
|
90
|
+
// Function URLs only serve TLS.
|
|
91
|
+
const sourceIp = isV2Event(event)
|
|
92
|
+
? event.requestContext?.http?.sourceIp
|
|
93
|
+
: event.requestContext?.identity?.sourceIp;
|
|
94
|
+
if (sourceIp) {
|
|
95
|
+
setConnInfo(request, { remoteAddress: sourceIp, tls: true });
|
|
46
96
|
}
|
|
47
|
-
return
|
|
97
|
+
return request;
|
|
48
98
|
}
|
|
49
99
|
async function responseToLambda(res, useV2Response) {
|
|
50
|
-
const headers =
|
|
51
|
-
const getSetCookie = res.headers.getSetCookie;
|
|
52
|
-
const cookies = typeof getSetCookie === "function"
|
|
53
|
-
? getSetCookie.call(res.headers)
|
|
54
|
-
: cookieFallback(res.headers);
|
|
55
|
-
res.headers.forEach((value, key) => {
|
|
56
|
-
if (key.toLowerCase() === "set-cookie")
|
|
57
|
-
return;
|
|
58
|
-
headers[key] = value;
|
|
59
|
-
});
|
|
100
|
+
const { headers, cookies } = responseHeaders(res);
|
|
60
101
|
const contentType = res.headers.get("content-type") ?? "";
|
|
61
102
|
const isText = TEXT_TYPE_RE.test(contentType);
|
|
62
103
|
let body = "";
|
|
@@ -87,7 +128,10 @@ async function responseToLambda(res, useV2Response) {
|
|
|
87
128
|
}
|
|
88
129
|
function isV2Event(event) {
|
|
89
130
|
const requestContext = event.requestContext;
|
|
90
|
-
return event.version === "2.0" ||
|
|
131
|
+
return (event.version === "2.0" ||
|
|
132
|
+
"rawPath" in event ||
|
|
133
|
+
"rawQueryString" in event ||
|
|
134
|
+
!!requestContext?.http);
|
|
91
135
|
}
|
|
92
136
|
function queryStringForV1(event) {
|
|
93
137
|
const values = new URLSearchParams();
|
|
@@ -107,6 +151,78 @@ function cookieFallback(headers) {
|
|
|
107
151
|
const cookie = headers.get("set-cookie");
|
|
108
152
|
return cookie ? [cookie] : [];
|
|
109
153
|
}
|
|
154
|
+
function responseHeaders(res) {
|
|
155
|
+
const headers = {};
|
|
156
|
+
const getSetCookie = res.headers.getSetCookie;
|
|
157
|
+
const cookies = typeof getSetCookie === "function"
|
|
158
|
+
? getSetCookie.call(res.headers)
|
|
159
|
+
: cookieFallback(res.headers);
|
|
160
|
+
res.headers.forEach((value, key) => {
|
|
161
|
+
if (key.toLowerCase() !== "set-cookie")
|
|
162
|
+
headers[key] = value;
|
|
163
|
+
});
|
|
164
|
+
return { headers, cookies };
|
|
165
|
+
}
|
|
166
|
+
function badRequestResponse() {
|
|
167
|
+
return Response.json({
|
|
168
|
+
type: "https://daloyjs.dev/errors/bad-request",
|
|
169
|
+
title: "Bad Request",
|
|
170
|
+
status: 400,
|
|
171
|
+
}, { status: 400, headers: { "content-type": "application/problem+json" } });
|
|
172
|
+
}
|
|
173
|
+
function lambdaStreamingRuntime() {
|
|
174
|
+
const runtime = globalThis
|
|
175
|
+
.awslambda;
|
|
176
|
+
if (!runtime ||
|
|
177
|
+
typeof runtime.streamifyResponse !== "function" ||
|
|
178
|
+
typeof runtime.HttpResponseStream?.from !== "function") {
|
|
179
|
+
throw new Error("AWS Lambda response streaming runtime not detected; toLambdaStreamHandler requires the managed Node.js awslambda globals");
|
|
180
|
+
}
|
|
181
|
+
return runtime;
|
|
182
|
+
}
|
|
183
|
+
async function streamLambdaResponse(response, rawStream, runtime) {
|
|
184
|
+
const { headers, cookies } = responseHeaders(response);
|
|
185
|
+
const metadata = { statusCode: response.status, headers };
|
|
186
|
+
if (cookies.length)
|
|
187
|
+
metadata.multiValueHeaders = { "set-cookie": cookies };
|
|
188
|
+
const responseStream = runtime.HttpResponseStream.from(rawStream, metadata);
|
|
189
|
+
if (response.body) {
|
|
190
|
+
const reader = response.body.getReader();
|
|
191
|
+
try {
|
|
192
|
+
for (;;) {
|
|
193
|
+
const chunk = await reader.read();
|
|
194
|
+
if (chunk.done)
|
|
195
|
+
break;
|
|
196
|
+
if (!responseStream.write(chunk.value))
|
|
197
|
+
await waitForDrain(responseStream);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
await reader.cancel(error).catch(() => undefined);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
reader.releaseLock();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
responseStream.end();
|
|
209
|
+
if (responseStream.finished)
|
|
210
|
+
await responseStream.finished();
|
|
211
|
+
}
|
|
212
|
+
function waitForDrain(stream) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const onDrain = () => {
|
|
215
|
+
stream.off?.("error", onError);
|
|
216
|
+
resolve();
|
|
217
|
+
};
|
|
218
|
+
const onError = (error) => {
|
|
219
|
+
stream.off?.("drain", onDrain);
|
|
220
|
+
reject(error);
|
|
221
|
+
};
|
|
222
|
+
stream.once("drain", onDrain);
|
|
223
|
+
stream.once("error", onError);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
110
226
|
function base64ToBytes(b64) {
|
|
111
227
|
const binary = atob(b64);
|
|
112
228
|
const bytes = new Uint8Array(binary.length);
|
package/dist/adapters/node.d.ts
CHANGED
|
@@ -76,7 +76,14 @@ export interface NodeServerOptions {
|
|
|
76
76
|
export interface NodeServerHandle {
|
|
77
77
|
/** The underlying `node:http` `Server` instance, for advanced wiring (extra listeners, address introspection). */
|
|
78
78
|
server: Server;
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Bound TCP port once the server emits `listening`.
|
|
81
|
+
*
|
|
82
|
+
* Before the listener is ready, this is the requested
|
|
83
|
+
* {@link NodeServerOptions.port} (default `3000`). In particular, callers
|
|
84
|
+
* using `port: 0` must await the server's `listening` event before reading
|
|
85
|
+
* this property to receive the OS-assigned ephemeral port.
|
|
86
|
+
*/
|
|
80
87
|
port: number;
|
|
81
88
|
/** Graceful shutdown: drains {@link App.shutdown} hooks, destroys WebSocket sockets, then closes the server. Idempotent. */
|
|
82
89
|
close(): Promise<void>;
|
package/dist/adapters/node.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { createServer, } from "node:http";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
|
-
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, } from "../app.js";
|
|
7
|
+
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY, DALOY_LIGHT_RESPONSE_OK, DALOY_REQUEST_ABORT, } from "../app.js";
|
|
8
|
+
import { BadRequestError } from "../errors.js";
|
|
8
9
|
import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
|
|
10
|
+
import { setConnInfo } from "../conn-info.js";
|
|
9
11
|
import { FrameSink, encodeFrame, encodeClosePayload, encodeSendPayload, validateUpgrade, validateSelectedSubprotocol, checkWebSocketOrigin, WS_OPCODE, WS_CLOSE_CODE, WS_READY_STATE, WS_MAX_CONTROL_PAYLOAD, WebSocketProtocolError, WebSocketPayloadTooLargeError, } from "../websocket.js";
|
|
10
12
|
/**
|
|
11
13
|
* Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}.
|
|
@@ -85,11 +87,23 @@ export function serve(app, opts = {}) {
|
|
|
85
87
|
server.on("upgrade", (req, socket, head) => {
|
|
86
88
|
wsSockets.add(socket);
|
|
87
89
|
socket.on("close", () => wsSockets.delete(socket));
|
|
88
|
-
|
|
90
|
+
// Safety net: a rejection here would otherwise be unhandled and, under
|
|
91
|
+
// the production crash-on-unhandledRejection posture, kill the process
|
|
92
|
+
// from a single malformed upgrade request.
|
|
93
|
+
handleUpgrade(app, req, socket, head, trustProxy).catch((err) => {
|
|
94
|
+
app.log.error({ err }, "WebSocket upgrade failed");
|
|
95
|
+
try {
|
|
96
|
+
writeUpgradeError(socket, 400, "Bad Request");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* socket already closed */
|
|
100
|
+
}
|
|
101
|
+
socket.destroy();
|
|
102
|
+
});
|
|
89
103
|
});
|
|
90
104
|
}
|
|
91
|
-
const
|
|
92
|
-
server.listen(
|
|
105
|
+
const requestedPort = opts.port ?? 3000;
|
|
106
|
+
server.listen(requestedPort, opts.hostname ?? "0.0.0.0");
|
|
93
107
|
// Kill idle keep-alive sockets immediately when draining begins.
|
|
94
108
|
// In-flight requests keep their socket because Node's
|
|
95
109
|
// `closeIdleConnections()` is a no-op for sockets with an in-flight request.
|
|
@@ -115,7 +129,16 @@ export function serve(app, opts = {}) {
|
|
|
115
129
|
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
116
130
|
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
117
131
|
}
|
|
118
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
server,
|
|
134
|
+
get port() {
|
|
135
|
+
const address = server.address();
|
|
136
|
+
return address !== null && typeof address === "object"
|
|
137
|
+
? address.port
|
|
138
|
+
: requestedPort;
|
|
139
|
+
},
|
|
140
|
+
close,
|
|
141
|
+
};
|
|
119
142
|
}
|
|
120
143
|
/**
|
|
121
144
|
* Default pre-buffer ceiling for the Node adapter. 256 KiB is a compromise:
|
|
@@ -135,6 +158,14 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
|
135
158
|
writeAdapterError(res, e);
|
|
136
159
|
return;
|
|
137
160
|
}
|
|
161
|
+
// Fulfil the conn-info contract: the immediate TCP peer, so
|
|
162
|
+
// `getConnInfo` / `resolveClientIp` / `behindProxy` and WAF client-IP
|
|
163
|
+
// attribution work on Node. Never derived from spoofable headers.
|
|
164
|
+
setConnInfo(request, {
|
|
165
|
+
remoteAddress: req.socket.remoteAddress,
|
|
166
|
+
remotePort: req.socket.remotePort,
|
|
167
|
+
tls: req.socket.encrypted === true,
|
|
168
|
+
});
|
|
138
169
|
attachClientCertificate(req, request);
|
|
139
170
|
const responseOrPromise = app.fetch(request);
|
|
140
171
|
if (responseOrPromise instanceof Promise) {
|
|
@@ -279,12 +310,15 @@ function writeMethodRefused(res) {
|
|
|
279
310
|
}
|
|
280
311
|
function writeAdapterError(res, e) {
|
|
281
312
|
if (!res.headersSent) {
|
|
282
|
-
|
|
313
|
+
const clientError = e instanceof BadRequestError;
|
|
314
|
+
res.statusCode = clientError ? 400 : 500;
|
|
283
315
|
res.setHeader("content-type", "application/problem+json");
|
|
284
316
|
res.end(JSON.stringify({
|
|
285
|
-
type:
|
|
286
|
-
|
|
287
|
-
|
|
317
|
+
type: clientError
|
|
318
|
+
? "https://daloyjs.dev/errors/bad-request"
|
|
319
|
+
: "https://daloyjs.dev/errors/internal",
|
|
320
|
+
title: clientError ? "Bad Request" : "Internal Server Error",
|
|
321
|
+
status: clientError ? 400 : 500,
|
|
288
322
|
}));
|
|
289
323
|
}
|
|
290
324
|
else {
|
|
@@ -307,9 +341,11 @@ function writeAdapterError(res, e) {
|
|
|
307
341
|
* - `instanceof Request` holds (prototype chain is re-rooted onto
|
|
308
342
|
* `Request.prototype`), and every WHATWG method/getter is overridden here,
|
|
309
343
|
* so nothing hits undici's brand-checked prototype accessors.
|
|
310
|
-
* - `signal` is
|
|
311
|
-
*
|
|
312
|
-
*
|
|
344
|
+
* - `signal` is a lazily-created per-instance `AbortSignal`. The framework
|
|
345
|
+
* aborts it (via the {@link DALOY_REQUEST_ABORT} hook) when the request
|
|
346
|
+
* exceeds `requestTimeoutMs`, so a handler that forwards `ctx.request.signal`
|
|
347
|
+
* to downstream `fetch`/DB calls sees them cancel on timeout. It is still NOT
|
|
348
|
+
* wired to client socket-disconnect — that teardown never fires the signal.
|
|
313
349
|
* - Passing this object directly to `fetch()` is not supported (undici
|
|
314
350
|
* brand-checks its input) — forward with `request.clone()` instead, which
|
|
315
351
|
* returns a real `Request`. This mirrors @hono/node-server's shim.
|
|
@@ -327,7 +363,7 @@ class LightRequest {
|
|
|
327
363
|
#headers;
|
|
328
364
|
#bodyBytes;
|
|
329
365
|
#real;
|
|
330
|
-
#
|
|
366
|
+
#controller;
|
|
331
367
|
constructor(url, method, headers, bodyBytes) {
|
|
332
368
|
this.#url = url;
|
|
333
369
|
this.#method = method;
|
|
@@ -355,10 +391,22 @@ class LightRequest {
|
|
|
355
391
|
return this.#headers;
|
|
356
392
|
}
|
|
357
393
|
get signal() {
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
394
|
+
// Lazily created so only handlers that actually read `signal` pay for the
|
|
395
|
+
// controller. The framework aborts it via the DALOY_REQUEST_ABORT hook
|
|
396
|
+
// below when the request exceeds `requestTimeoutMs`; client
|
|
397
|
+
// socket-disconnect is not wired into it.
|
|
398
|
+
return (this.#controller ??= new AbortController()).signal;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Framework abort hook ({@link DALOY_REQUEST_ABORT}). Invoked by the core on
|
|
402
|
+
* request timeout so `ctx.request.signal` fires for cooperative teardown.
|
|
403
|
+
* A no-op when no handler ever read `signal` — there is no controller to
|
|
404
|
+
* abort and nothing observing it.
|
|
405
|
+
*
|
|
406
|
+
* @param reason - Abort reason surfaced on `signal.reason` (a `TimeoutError`).
|
|
407
|
+
*/
|
|
408
|
+
[DALOY_REQUEST_ABORT](reason) {
|
|
409
|
+
this.#controller?.abort(reason);
|
|
362
410
|
}
|
|
363
411
|
get body() {
|
|
364
412
|
return this.#materialize().body;
|
|
@@ -506,6 +554,13 @@ function toWebRequest(req, trustProxy, bufferedBody) {
|
|
|
506
554
|
const proto = forwardedProto ??
|
|
507
555
|
(req.socket.encrypted ? "https" : "http");
|
|
508
556
|
const url = `${proto}://${host}${normalizeRequestTarget(req.url)}`;
|
|
557
|
+
// Reject malformed Host / request-target combinations at the adapter
|
|
558
|
+
// boundary instead of letting the invalid URL propagate as a 500 later.
|
|
559
|
+
// URL.canParse applies WHATWG validation without allocating and immediately
|
|
560
|
+
// discarding a URL object on every request.
|
|
561
|
+
if (!URL.canParse(url)) {
|
|
562
|
+
throw new BadRequestError("Invalid request target or Host header");
|
|
563
|
+
}
|
|
509
564
|
// Build headers from `rawHeaders` (a flat [k0,v0,k1,v1,...] array) instead
|
|
510
565
|
// of the parsed `req.headers` object. This matches @hono/node-server's
|
|
511
566
|
// `newHeadersFromIncoming`: one `new Headers([[k,v],...])` constructor
|
|
@@ -578,7 +633,21 @@ function normalizeRequestTarget(target) {
|
|
|
578
633
|
}
|
|
579
634
|
function sendWebResponse(res, out) {
|
|
580
635
|
out.statusCode = res.status;
|
|
581
|
-
|
|
636
|
+
// `Headers.forEach` yields each `Set-Cookie` as a separate callback while
|
|
637
|
+
// `ServerResponse.setHeader` overwrites repeated keys — copying naively
|
|
638
|
+
// keeps only the LAST cookie (e.g. dropping the session cookie when
|
|
639
|
+
// `csrf()` also sets its token cookie). Collect them via `getSetCookie()`
|
|
640
|
+
// and set the array once so every cookie reaches the wire.
|
|
641
|
+
let hasSetCookie = false;
|
|
642
|
+
res.headers.forEach((v, k) => {
|
|
643
|
+
if (k === "set-cookie") {
|
|
644
|
+
hasSetCookie = true;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
out.setHeader(k, v);
|
|
648
|
+
});
|
|
649
|
+
if (hasSetCookie)
|
|
650
|
+
out.setHeader("set-cookie", res.headers.getSetCookie());
|
|
582
651
|
// Fast-path: response was produced by serializeResult and carries the raw
|
|
583
652
|
// body bytes via the DALOY_RAW_BODY Symbol. Skip arrayBuffer() and the
|
|
584
653
|
// reader-loop microtask churn entirely for buffer-backed responses.
|
|
@@ -661,7 +730,18 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
|
|
|
661
730
|
: undefined;
|
|
662
731
|
const proto = forwardedProto ??
|
|
663
732
|
(req.socket.encrypted ? "https" : "http");
|
|
664
|
-
|
|
733
|
+
// A malformed `Host` header (e.g. containing a space) reaches this point:
|
|
734
|
+
// Node's HTTP parser accepts it and fires `upgrade`, but WHATWG URL
|
|
735
|
+
// parsing throws. Reject it as the client error it is instead of letting
|
|
736
|
+
// the throw escape the adapter.
|
|
737
|
+
let url;
|
|
738
|
+
try {
|
|
739
|
+
url = new URL(`${proto}://${host}${req.url ?? "/"}`);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
writeUpgradeError(socket, 400, "Bad Request");
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
665
745
|
const match = app.webSocketRoutes.find(url.pathname);
|
|
666
746
|
if (!match) {
|
|
667
747
|
writeUpgradeError(socket, 404, "Not Found");
|
|
@@ -682,6 +762,11 @@ async function handleUpgrade(app, req, socket, head, trustProxy) {
|
|
|
682
762
|
method: "GET",
|
|
683
763
|
headers,
|
|
684
764
|
});
|
|
765
|
+
setConnInfo(request, {
|
|
766
|
+
remoteAddress: req.socket.remoteAddress,
|
|
767
|
+
remotePort: req.socket.remotePort,
|
|
768
|
+
tls: req.socket.encrypted === true,
|
|
769
|
+
});
|
|
685
770
|
const ctx = {
|
|
686
771
|
request,
|
|
687
772
|
params: match.params,
|
package/dist/app.d.ts
CHANGED
|
@@ -78,7 +78,12 @@ export interface AppOptions {
|
|
|
78
78
|
bodyLimitBytes?: number;
|
|
79
79
|
/** Reject requests whose Content-Type isn't in this allowlist (when a body schema is declared). */
|
|
80
80
|
allowedContentTypes?: string[];
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Per-request timeout in ms (handler + hooks). Default: 30000. Set 0 to
|
|
83
|
+
* disable. On timeout the framework aborts `ctx.request.signal` (cooperative
|
|
84
|
+
* cancellation of downstream I/O) and responds `408`; see
|
|
85
|
+
* {@link RequestTimeoutError}. It does not forcibly stop CPU-bound work.
|
|
86
|
+
*/
|
|
82
87
|
requestTimeoutMs?: number;
|
|
83
88
|
/**
|
|
84
89
|
* Maximum number of distinct request header fields accepted before the
|
|
@@ -93,6 +98,29 @@ export interface AppOptions {
|
|
|
93
98
|
* @since 0.38.0
|
|
94
99
|
*/
|
|
95
100
|
maxHeaderCount?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Maximum number of keys (summed across every object in the tree) permitted
|
|
103
|
+
* when parsing a JSON request body. This bounds "hash-flood" / wide-object
|
|
104
|
+
* attacks that easily fit inside `bodyLimitBytes` (e.g. 40 000 tiny keys).
|
|
105
|
+
* Applies to top-level objects and all nested objects. Set to 0 to disable.
|
|
106
|
+
* Default: 10 000.
|
|
107
|
+
*
|
|
108
|
+
* Exposed in `getSecurityPosture()` and audited by `daloy doctor`.
|
|
109
|
+
*
|
|
110
|
+
* @since 1.0.0
|
|
111
|
+
*/
|
|
112
|
+
jsonMaxKeys?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Maximum nesting depth permitted for JSON request bodies (objects and arrays).
|
|
115
|
+
* Prevents deeply-nested structures that can consume excessive CPU/memory
|
|
116
|
+
* during schema validation or handler processing. Set to 0 to disable.
|
|
117
|
+
* Default: 50.
|
|
118
|
+
*
|
|
119
|
+
* Exposed in `getSecurityPosture()` and audited by `daloy doctor`.
|
|
120
|
+
*
|
|
121
|
+
* @since 1.0.0
|
|
122
|
+
*/
|
|
123
|
+
jsonMaxDepth?: number;
|
|
96
124
|
/**
|
|
97
125
|
* Per-request limits applied when parsing `multipart/form-data` bodies.
|
|
98
126
|
* These run in addition to `bodyLimitBytes`. Use them to cap the size of
|
|
@@ -516,7 +544,7 @@ export interface PluginExtension {
|
|
|
516
544
|
/** Unique extension name. Referenced by `before` / `after` on siblings. */
|
|
517
545
|
name: string;
|
|
518
546
|
/** Lifecycle event the handler attaches to. */
|
|
519
|
-
event: "onRequest" | "beforeHandle" | "afterHandle" | "onSend" | "onError";
|
|
547
|
+
event: "onRequest" | "preBody" | "beforeHandle" | "afterHandle" | "onSend" | "onError";
|
|
520
548
|
/** Hook handler. The shape mirrors the matching {@link Hooks} entry. */
|
|
521
549
|
handler: (...args: any[]) => any;
|
|
522
550
|
/** Extension names this one must run before. */
|
|
@@ -748,6 +776,23 @@ export declare const DALOY_RAW_BODY: unique symbol;
|
|
|
748
776
|
* so first-party adapters can opt in; not part of the userland API surface.
|
|
749
777
|
*/
|
|
750
778
|
export declare const DALOY_REQUEST_RAW_BODY: unique symbol;
|
|
779
|
+
/**
|
|
780
|
+
* Internal Symbol an adapter sets (on its request shim) to expose the request's
|
|
781
|
+
* abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
|
|
782
|
+
* backing `request.signal`. The core invokes it when a request exceeds
|
|
783
|
+
* {@link AppOptions.requestTimeoutMs} so a handler that forwarded
|
|
784
|
+
* `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
|
|
785
|
+
* calls cancel — cooperative teardown, since single-threaded JS cannot preempt
|
|
786
|
+
* a running handler.
|
|
787
|
+
*
|
|
788
|
+
* The hook is invoked as a method on the request (`this` stays bound to the
|
|
789
|
+
* shim) so it can reach the shim's private controller. Absent on runtimes
|
|
790
|
+
* whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
|
|
791
|
+
* on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
|
|
792
|
+
* and the timeout still resolves as a `408`. Module-public so first-party
|
|
793
|
+
* adapters can opt in; not part of the userland API surface.
|
|
794
|
+
*/
|
|
795
|
+
export declare const DALOY_REQUEST_ABORT: unique symbol;
|
|
751
796
|
/**
|
|
752
797
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
753
798
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -828,6 +873,18 @@ export declare const DALOY_LIGHT_RESPONSE_OK: unique symbol;
|
|
|
828
873
|
* @typeParam R - The route definition being registered.
|
|
829
874
|
*/
|
|
830
875
|
type AppendRoute<Routes extends readonly RouteDefinition<any, any, any, any>[], R extends RouteDefinition<any, any, any, any>> = readonly RouteDefinition<any, any, any, any>[] extends Routes ? readonly [R] : readonly [...Routes, R];
|
|
876
|
+
/** Append a literal tuple of route contracts to an App's accumulated routes. */
|
|
877
|
+
type AppendRoutes<Routes extends readonly RouteDefinition<any, any, any, any>[], Added extends readonly RouteDefinition<any, any, any, any>[]> = readonly RouteDefinition<any, any, any, any>[] extends Routes ? Added : readonly [...Routes, ...Added];
|
|
878
|
+
type PascalWords<S extends string> = S extends `${infer Head}-${infer Tail}` ? `${PascalWords<Head>}${PascalWords<Tail>}` : S extends `${infer Head}_${infer Tail}` ? `${Capitalize<Head>}${PascalWords<Tail>}` : Capitalize<S>;
|
|
879
|
+
type OperationSegment<S extends string> = S extends `:${infer Param}` ? `By${PascalWords<Param>}` : PascalWords<S>;
|
|
880
|
+
type OperationPathTail<P extends string> = P extends `${infer Segment}/${infer Rest}` ? `${OperationSegment<Segment>}${OperationPathTail<Rest>}` : OperationSegment<P>;
|
|
881
|
+
type AutoOperationId<M extends HttpMethod, P extends PathString> = `${Lowercase<M>}${P extends "/" ? "Root" : P extends `/${infer Tail}` ? OperationPathTail<Tail> : never}`;
|
|
882
|
+
type ShorthandOptions<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, Op extends string | undefined> = Omit<RouteDefinition<P, M, Req, Res>, "method" | "path" | "operationId" | "handler"> & {
|
|
883
|
+
operationId?: Op;
|
|
884
|
+
};
|
|
885
|
+
type ShorthandRoute<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, Op extends string | undefined> = RouteDefinition<P, M, Req, Res> & {
|
|
886
|
+
operationId: Op extends string ? Op : AutoOperationId<M, P>;
|
|
887
|
+
};
|
|
831
888
|
/**
|
|
832
889
|
* The DaloyJS application: a contract-first router plus a web-standard
|
|
833
890
|
* `fetch(Request): Promise<Response>` handler that runs unchanged on Node,
|
|
@@ -1007,6 +1064,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1007
1064
|
bodyLimitBytes: number;
|
|
1008
1065
|
requestTimeoutMs: number;
|
|
1009
1066
|
maxHeaderCount: number;
|
|
1067
|
+
jsonMaxKeys: number;
|
|
1068
|
+
jsonMaxDepth: number;
|
|
1010
1069
|
stripServerHeaders: boolean;
|
|
1011
1070
|
production: boolean;
|
|
1012
1071
|
};
|
|
@@ -1204,6 +1263,74 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1204
1263
|
}): App<AppendRoute<Routes, RouteDefinition<P, M, Req, Res> & {
|
|
1205
1264
|
operationId: Op;
|
|
1206
1265
|
}>>;
|
|
1266
|
+
/**
|
|
1267
|
+
* Register a literal tuple of independently defined route contracts.
|
|
1268
|
+
*
|
|
1269
|
+
* Unlike repeated statements against an already-declared `App` variable,
|
|
1270
|
+
* this method returns an App whose route tuple includes every supplied
|
|
1271
|
+
* contract. That preserves the exact no-codegen client surface across route
|
|
1272
|
+
* files and feature modules.
|
|
1273
|
+
*
|
|
1274
|
+
* @param definitions - Readonly literal tuple of route definitions.
|
|
1275
|
+
* @returns This App instance widened with every supplied route contract.
|
|
1276
|
+
* @since 1.0.0
|
|
1277
|
+
*/
|
|
1278
|
+
registerRoutes<const Added extends readonly RouteDefinition<any, any, any, any>[]>(definitions: Added): App<AppendRoutes<Routes, Added>>;
|
|
1279
|
+
/**
|
|
1280
|
+
* Register a contract-backed `GET` route with validation and typed responses.
|
|
1281
|
+
* @param path - Literal route path.
|
|
1282
|
+
* @param options - Route schemas, responses, metadata, hooks, and security.
|
|
1283
|
+
* @param handler - Handler contextually typed from the contract options.
|
|
1284
|
+
* @returns This App widened with the registered route.
|
|
1285
|
+
* @since 1.0.0
|
|
1286
|
+
*/
|
|
1287
|
+
get<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "GET", Req, Res, Op>, handler: RouteDefinition<P, "GET", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "GET", Req, Res, Op>>>;
|
|
1288
|
+
/**
|
|
1289
|
+
* Register a contract-backed `POST` route with validation and typed responses.
|
|
1290
|
+
* @param path - Literal route path.
|
|
1291
|
+
* @param options - Route schemas, responses, metadata, hooks, and security.
|
|
1292
|
+
* @param handler - Handler contextually typed from the contract options.
|
|
1293
|
+
* @returns This App widened with the registered route.
|
|
1294
|
+
* @since 1.0.0
|
|
1295
|
+
*/
|
|
1296
|
+
post<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "POST", Req, Res, Op>, handler: RouteDefinition<P, "POST", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "POST", Req, Res, Op>>>;
|
|
1297
|
+
/**
|
|
1298
|
+
* Register a contract-backed `PUT` route with validation and typed responses.
|
|
1299
|
+
* @param path - Literal route path.
|
|
1300
|
+
* @param options - Route schemas, responses, metadata, hooks, and security.
|
|
1301
|
+
* @param handler - Handler contextually typed from the contract options.
|
|
1302
|
+
* @returns This App widened with the registered route.
|
|
1303
|
+
* @since 1.0.0
|
|
1304
|
+
*/
|
|
1305
|
+
put<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "PUT", Req, Res, Op>, handler: RouteDefinition<P, "PUT", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "PUT", Req, Res, Op>>>;
|
|
1306
|
+
/**
|
|
1307
|
+
* Register a contract-backed `PATCH` route with validation and typed responses.
|
|
1308
|
+
* @param path - Literal route path.
|
|
1309
|
+
* @param options - Route schemas, responses, metadata, hooks, and security.
|
|
1310
|
+
* @param handler - Handler contextually typed from the contract options.
|
|
1311
|
+
* @returns This App widened with the registered route.
|
|
1312
|
+
* @since 1.0.0
|
|
1313
|
+
*/
|
|
1314
|
+
patch<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "PATCH", Req, Res, Op>, handler: RouteDefinition<P, "PATCH", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "PATCH", Req, Res, Op>>>;
|
|
1315
|
+
/**
|
|
1316
|
+
* Register a contract-backed `DELETE` route with typed response statuses.
|
|
1317
|
+
* @param path - Literal route path.
|
|
1318
|
+
* @param options - Route schemas, responses, metadata, hooks, and security.
|
|
1319
|
+
* @param handler - Handler contextually typed from the contract options.
|
|
1320
|
+
* @returns This App widened with the registered route.
|
|
1321
|
+
* @since 1.0.0
|
|
1322
|
+
*/
|
|
1323
|
+
delete<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "DELETE", Req, Res, Op>, handler: RouteDefinition<P, "DELETE", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "DELETE", Req, Res, Op>>>;
|
|
1324
|
+
/**
|
|
1325
|
+
* Register an explicit contract-backed `HEAD` route.
|
|
1326
|
+
* @param path - Literal route path.
|
|
1327
|
+
* @param options - Route schemas, responses, metadata, hooks, and security.
|
|
1328
|
+
* @param handler - Handler contextually typed from the contract options.
|
|
1329
|
+
* @returns This App widened with the registered route.
|
|
1330
|
+
* @since 1.0.0
|
|
1331
|
+
*/
|
|
1332
|
+
head<const P extends PathString, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(path: P, options: ShorthandOptions<P, "HEAD", Req, Res, Op>, handler: RouteDefinition<P, "HEAD", Req, Res>["handler"]): App<AppendRoute<Routes, ShorthandRoute<P, "HEAD", Req, Res, Op>>>;
|
|
1333
|
+
private addHttpShorthand;
|
|
1207
1334
|
/**
|
|
1208
1335
|
* Register a WebSocket route. The handler runs when an HTTP client sends an
|
|
1209
1336
|
* `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
|
|
@@ -1582,8 +1709,8 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1582
1709
|
* "draining" signal); then the app waits up to `timeoutMs` for in-flight
|
|
1583
1710
|
* requests to settle; finally, {@link App.onClose} cleanups run.
|
|
1584
1711
|
*
|
|
1585
|
-
*
|
|
1586
|
-
* Call it manually from custom runtimes or integration tests.
|
|
1712
|
+
* The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
|
|
1713
|
+
* `SIGTERM`. Call it manually from custom runtimes or integration tests.
|
|
1587
1714
|
*
|
|
1588
1715
|
* @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
|
|
1589
1716
|
* @param reason - Optional human-readable reason forwarded to listeners.
|
|
@@ -1671,11 +1798,4 @@ export declare function findRoutesMissingResponseBodySchema(routes: readonly Pic
|
|
|
1671
1798
|
* @since 0.3.0
|
|
1672
1799
|
*/
|
|
1673
1800
|
export declare function createApp(options?: AppOptions): App;
|
|
1674
|
-
/**
|
|
1675
|
-
* Test helper: clear the cached package.json read so each test starts
|
|
1676
|
-
* from a fresh lookup. Not part of the public API.
|
|
1677
|
-
*
|
|
1678
|
-
* @internal
|
|
1679
|
-
*/
|
|
1680
|
-
export declare function _resetPackageJsonCacheForTests(): void;
|
|
1681
1801
|
export {};
|