@mpen/routekit 0.1.0 → 0.1.2
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/dist/bin.d.mts +4 -0
- package/dist/client/react.d.mts +178 -0
- package/dist/client/react.mjs +142 -0
- package/dist/client.d.mts +433 -0
- package/dist/client.mjs +264 -0
- package/dist/content-BuDOmhH_.mjs +102 -0
- package/dist/core-CzUCxvGk.d.mts +140 -0
- package/dist/core-DbmQauwS.mjs +81 -0
- package/dist/handlers.d.mts +72 -0
- package/dist/handlers.mjs +153 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1152 -0
- package/dist/middleware.d.mts +388 -0
- package/dist/middleware.mjs +1222 -0
- package/dist/request-Dn0zc-xm.mjs +1025 -0
- package/dist/response/content.d.mts +79 -0
- package/dist/response/content.mjs +2 -0
- package/dist/response/json-rpc.d.mts +1 -0
- package/dist/response/json-rpc.mjs +1 -0
- package/dist/response/problem/valibot.d.mts +230 -0
- package/dist/response/problem/valibot.mjs +258 -0
- package/dist/response/problem.d.mts +415 -0
- package/dist/response/problem.mjs +183 -0
- package/dist/response/status.d.mts +45 -0
- package/dist/response/status.mjs +2 -0
- package/dist/responses-B379Ep9Y.d.mts +296 -0
- package/dist/responses-BpVrgeYi.mjs +101 -0
- package/dist/router-Cwb7ak0J.d.mts +1819 -0
- package/dist/routes.d.mts +282 -0
- package/dist/routes.mjs +311 -0
- package/dist/status-C-8mw-FB.mjs +59 -0
- package/dist/valibot-D7liFYyB.d.mts +290 -0
- package/dist/valibot-Du97X-TS.mjs +326 -0
- package/package.json +8 -2
- package/src/bin/gen-api-client.test.ts +0 -70
- package/src/bin/gen-api-client.ts +0 -986
- package/src/client/headers.ts +0 -31
- package/src/client/index.ts +0 -8
- package/src/client/promise.ts +0 -11
- package/src/client/react/index.test.tsx +0 -266
- package/src/client/react/index.ts +0 -431
- package/src/client/responses.test.ts +0 -151
- package/src/client/responses.ts +0 -278
- package/src/client/transport.ts +0 -74
- package/src/client/transports/body-codec.ts +0 -61
- package/src/client/transports/fetch.ts +0 -113
- package/src/client/tsconfig.json +0 -9
- package/src/client/types.ts +0 -15
- package/src/client/url.ts +0 -31
- package/src/index.ts +0 -63
- package/src/router/fetch-types.ts +0 -13
- package/src/router/handlers/index.ts +0 -2
- package/src/router/handlers/openapi/index.ts +0 -2
- package/src/router/handlers/openapi/openapi.ts +0 -293
- package/src/router/integration/zod-openapi.test.ts +0 -74
- package/src/router/lib/charset.test.ts +0 -22
- package/src/router/lib/charset.ts +0 -133
- package/src/router/lib/collections.ts +0 -3
- package/src/router/lib/format.test.ts +0 -67
- package/src/router/lib/format.ts +0 -35
- package/src/router/lib/host.ts +0 -4
- package/src/router/lib/json-schema.ts +0 -6
- package/src/router/lib/media-type.test.ts +0 -122
- package/src/router/lib/media-type.ts +0 -289
- package/src/router/lib/pathname.test.ts +0 -18
- package/src/router/lib/pathname.ts +0 -19
- package/src/router/lib/route-names.ts +0 -70
- package/src/router/lib/route-normalize.test.ts +0 -36
- package/src/router/lib/route-normalize.ts +0 -67
- package/src/router/lib/schema-merge.ts +0 -56
- package/src/router/middleware/accept-ctx.test.ts +0 -33
- package/src/router/middleware/accept-ctx.ts +0 -12
- package/src/router/middleware/body-limit.test.ts +0 -112
- package/src/router/middleware/body-limit.ts +0 -121
- package/src/router/middleware/content-type-context.ts +0 -0
- package/src/router/middleware/cors.test.ts +0 -269
- package/src/router/middleware/cors.ts +0 -490
- package/src/router/middleware/csrf.test.ts +0 -106
- package/src/router/middleware/csrf.ts +0 -192
- package/src/router/middleware/define.ts +0 -249
- package/src/router/middleware/index.ts +0 -34
- package/src/router/middleware/jsxhtml-response.ts +0 -0
- package/src/router/middleware/oas-swagger.ts +0 -0
- package/src/router/middleware/rate-limit.test.ts +0 -886
- package/src/router/middleware/rate-limit.ts +0 -920
- package/src/router/middleware/request-id-ctx.test.ts +0 -183
- package/src/router/middleware/request-id-ctx.ts +0 -135
- package/src/router/middleware/request-logger-format.test.ts +0 -16
- package/src/router/middleware/request-logger-format.ts +0 -269
- package/src/router/middleware/request-logger.test.ts +0 -267
- package/src/router/middleware/request-logger.ts +0 -131
- package/src/router/middleware/start-time-ctx.ts +0 -5
- package/src/router/request.ts +0 -611
- package/src/router/response/core.ts +0 -181
- package/src/router/response/directives.ts +0 -233
- package/src/router/response/formats/content/bodyless.ts +0 -54
- package/src/router/response/formats/content/content.ts +0 -79
- package/src/router/response/formats/content/index.ts +0 -2
- package/src/router/response/formats/json-rpc/index.ts +0 -2
- package/src/router/response/formats/problem/badRequest.ts +0 -90
- package/src/router/response/formats/problem/conflict.ts +0 -90
- package/src/router/response/formats/problem/created.ts +0 -40
- package/src/router/response/formats/problem/index.ts +0 -27
- package/src/router/response/formats/problem/notFound.ts +0 -90
- package/src/router/response/formats/problem/permissionDenied.ts +0 -90
- package/src/router/response/formats/problem/problem.test.ts +0 -888
- package/src/router/response/formats/problem/rateLimited.ts +0 -90
- package/src/router/response/formats/problem/responses.ts +0 -219
- package/src/router/response/formats/problem/root-errors.ts +0 -48
- package/src/router/response/formats/problem/sessionExpired.ts +0 -90
- package/src/router/response/formats/problem/types.ts +0 -170
- package/src/router/response/formats/problem/unauthenticated.ts +0 -90
- package/src/router/response/formats/problem/valibot.ts +0 -410
- package/src/router/response/formats/status/index.ts +0 -1
- package/src/router/response/formats/status/responses.ts +0 -59
- package/src/router/response/formats/status/status.test.ts +0 -21
- package/src/router/response/framers.ts +0 -85
- package/src/router/response/index.ts +0 -28
- package/src/router/response/openapi.test.ts +0 -96
- package/src/router/response/openapi.ts +0 -1
- package/src/router/response/serializers.ts +0 -66
- package/src/router/response/stream.ts +0 -35
- package/src/router/router.test.ts +0 -1571
- package/src/router/router.ts +0 -1965
- package/src/router/routes/index.ts +0 -46
- package/src/router/routes/valibot/index.ts +0 -18
- package/src/router/routes/valibot/valibot.ts +0 -1393
- package/src/router/routes/valibot.test.ts +0 -286
- package/src/router/routes/zod/index.ts +0 -18
- package/src/router/routes/zod/zod.ts +0 -1318
- package/src/router/routes/zod.test.ts +0 -280
- package/src/router/server-interface.ts +0 -31
- package/src/router/types.ts +0 -657
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
import { C as isChunkDirective, O as isStreamDirective, R as parseAcceptHeader, S as headers, T as isHeadersDirective, f as defineMiddleware, n as RequestBodyLengthMismatchError, r as RequestBodyTooLargeError, w as isHeadDirective } from "./request-Dn0zc-xm.mjs";
|
|
2
|
+
import { a as response, i as isRoutekitResponse, n as isResponseBodyInit } from "./core-DbmQauwS.mjs";
|
|
3
|
+
import { a as text, t as empty } from "./content-BuDOmhH_.mjs";
|
|
4
|
+
import { CommonHeaders, HttpMethod, HttpStatus } from "@mpen/http";
|
|
5
|
+
import { LogLevel } from "@mpen/logger";
|
|
6
|
+
//#region src/router/middleware/request-id-ctx.ts
|
|
7
|
+
globalThis._reloadCounter = globalThis._reloadCounter == null ? 0 : globalThis._reloadCounter + 1;
|
|
8
|
+
globalThis._globalRequestCounter ??= 0;
|
|
9
|
+
const isHMR = import.meta.hot !== void 0 || process.execArgv.includes("--hot");
|
|
10
|
+
function defaultRequestIdGenerator(_ctx, extra) {
|
|
11
|
+
let requestId = extra.requestCounter.toString(36);
|
|
12
|
+
if (isHMR) requestId = extra.hotReloadCounter.toString(36) + "." + requestId;
|
|
13
|
+
if (extra.prefix) requestId = `${extra.prefix}.${requestId}`;
|
|
14
|
+
return requestId;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Attach a correlated request id to every final response and contextual logger record.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* router.useRequest(requestIdCtx({
|
|
22
|
+
* readHeaderName: ['x-request-id', 'x-trace-id'],
|
|
23
|
+
* writeHeaderName: 'x-request-id',
|
|
24
|
+
* generate: () => crypto.randomUUID(),
|
|
25
|
+
* }))
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @param options - Configuration for reading, generating, and writing request ids.
|
|
29
|
+
* @returns Request-boundary middleware that populates `requestId` and logger context.
|
|
30
|
+
* @typeParam Ctx - Context available before request id generation.
|
|
31
|
+
*/
|
|
32
|
+
function requestIdCtx(options = {}) {
|
|
33
|
+
const prefix = options.prefix ?? "";
|
|
34
|
+
const headers = options.readHeaderName === void 0 ? [
|
|
35
|
+
"x-request-id",
|
|
36
|
+
"x-trace-id",
|
|
37
|
+
"traceparent"
|
|
38
|
+
] : options.readHeaderName == null ? [] : Array.isArray(options.readHeaderName) ? options.readHeaderName : [options.readHeaderName];
|
|
39
|
+
const writeHeaderName = options.writeHeaderName;
|
|
40
|
+
const hotReloadCounter = globalThis._reloadCounter;
|
|
41
|
+
let requestCounter = 0;
|
|
42
|
+
return async (ctx, next) => {
|
|
43
|
+
let headerId = null;
|
|
44
|
+
for (const name of headers) {
|
|
45
|
+
headerId = ctx.request.headers.get(name);
|
|
46
|
+
if (headerId !== null) break;
|
|
47
|
+
}
|
|
48
|
+
const extra = {
|
|
49
|
+
prefix,
|
|
50
|
+
hotReloadCounter,
|
|
51
|
+
requestCounter: ++requestCounter,
|
|
52
|
+
globalRequestCounter: ++globalThis._globalRequestCounter
|
|
53
|
+
};
|
|
54
|
+
const requestId = headerId ?? options.generate?.(ctx, extra) ?? defaultRequestIdGenerator(ctx, extra);
|
|
55
|
+
ctx.requestId = requestId;
|
|
56
|
+
ctx.logger = ctx.logger.withContext({ "request.id": requestId });
|
|
57
|
+
const response = await next();
|
|
58
|
+
if (writeHeaderName != null) response.headers.set(writeHeaderName, requestId);
|
|
59
|
+
return response;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/router/middleware/request-logger-format.ts
|
|
64
|
+
const ROUTEKIT_HTTP_SERVER_REQUEST_EVENT_NAME = "http.server.request";
|
|
65
|
+
const ROUTEKIT_REQUEST_CONTEXT_KEYS = [
|
|
66
|
+
"http.request.method",
|
|
67
|
+
"url.path",
|
|
68
|
+
"request.id"
|
|
69
|
+
];
|
|
70
|
+
const REQUEST_ID_COLOR_KEYS = [
|
|
71
|
+
"cyanBright",
|
|
72
|
+
"magentaBright",
|
|
73
|
+
"greenBright",
|
|
74
|
+
"yellowBright",
|
|
75
|
+
"blueBright",
|
|
76
|
+
"redBright",
|
|
77
|
+
"cyan",
|
|
78
|
+
"magenta"
|
|
79
|
+
];
|
|
80
|
+
/**
|
|
81
|
+
* Transform Routekit request activity records into production JSON access logs.
|
|
82
|
+
*
|
|
83
|
+
* Removes the fallback activity message from `http.server.request` records while preserving
|
|
84
|
+
* normal application log messages written through the same contextual logger.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { JsonLogger } from '@mpen/logger'
|
|
89
|
+
* import { transformRoutekitJsonLogRecord } from '@mpen/routekit/middleware'
|
|
90
|
+
*
|
|
91
|
+
* const logger = new JsonLogger({
|
|
92
|
+
* transformRecord: transformRoutekitJsonLogRecord,
|
|
93
|
+
* })
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* @param record - Record emitted by a logger.
|
|
97
|
+
* @returns The transformed record, or `undefined` to use the original record.
|
|
98
|
+
*/
|
|
99
|
+
const transformRoutekitJsonLogRecord = (record) => {
|
|
100
|
+
if (!isRoutekitRequestEvent(record)) return;
|
|
101
|
+
return {
|
|
102
|
+
...record,
|
|
103
|
+
data: stripActivityLifecycleMessage(record)
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Format Routekit request context for terminal logs.
|
|
108
|
+
*
|
|
109
|
+
* Renders compact request start/completion lines, color-correlated request identifiers, status
|
|
110
|
+
* codes, elapsed time, and response sizes while leaving unrelated records to
|
|
111
|
+
* [`TerminalLogger`]{@link import('@mpen/logger').TerminalLogger}'s default renderer.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* import { TerminalLogger } from '@mpen/logger'
|
|
116
|
+
* import { formatRoutekitTerminalLogRecord } from '@mpen/routekit/middleware'
|
|
117
|
+
*
|
|
118
|
+
* const logger = new TerminalLogger({
|
|
119
|
+
* formatRecord: formatRoutekitTerminalLogRecord,
|
|
120
|
+
* })
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @param record - Record emitted by a logger.
|
|
124
|
+
* @param terminal - Terminal formatting helpers.
|
|
125
|
+
* @returns Formatted terminal output, or `undefined` to use the default renderer.
|
|
126
|
+
*/
|
|
127
|
+
const formatRoutekitTerminalLogRecord = (record, terminal) => {
|
|
128
|
+
if (!hasRoutekitRequestContext(record)) return;
|
|
129
|
+
const colors = terminal.colors;
|
|
130
|
+
const context = record.context;
|
|
131
|
+
const requestId = getStringContext(record, "request.id");
|
|
132
|
+
const method = getStringContext(record, "http.request.method");
|
|
133
|
+
const path = getStringContext(record, "url.path");
|
|
134
|
+
const route = getStringContext(record, "http.route");
|
|
135
|
+
const direction = getActivityDirection(record);
|
|
136
|
+
const parts = [
|
|
137
|
+
formatServiceName(getStringContext(record, "service.name"), colors),
|
|
138
|
+
requestId == null ? void 0 : formatRequestId(requestId, colors),
|
|
139
|
+
requestId == null ? void 0 : formatDirection(direction, colors),
|
|
140
|
+
requestId == null ? formatDirection(direction, colors) : void 0,
|
|
141
|
+
colors.bold(colors.whiteBright(method)),
|
|
142
|
+
colors.white(path),
|
|
143
|
+
route == null || route === path ? void 0 : colors.blackBright(`route=${route}`),
|
|
144
|
+
formatStatusCode(getNumberContext(context["http.response.status_code"]), colors),
|
|
145
|
+
formatDuration(getNumberContext(context.duration_ms), colors),
|
|
146
|
+
formatBodySize(getNumberContext(context["http.response.body.size"]), colors)
|
|
147
|
+
].filter((part) => part != null && part !== "");
|
|
148
|
+
const prefix = `${terminal.icon} ${colors.blackBright(terminal.time)} ${parts.join(" ")}`;
|
|
149
|
+
const data = direction == null ? record.data : stripActivityLifecycleMessage(record);
|
|
150
|
+
if (data.length === 0) return prefix.trimEnd();
|
|
151
|
+
const message = terminal.formatData(data);
|
|
152
|
+
if (message === "") return prefix.trimEnd();
|
|
153
|
+
const [firstLine = "", ...remainingLines] = message.split("\n");
|
|
154
|
+
return [prefix + " " + firstLine, ...remainingLines.map((line) => " " + line)].join("\n");
|
|
155
|
+
};
|
|
156
|
+
function hasRoutekitRequestContext(record) {
|
|
157
|
+
return ROUTEKIT_REQUEST_CONTEXT_KEYS.every((key) => record.context[key] != null);
|
|
158
|
+
}
|
|
159
|
+
function isRoutekitRequestEvent(record) {
|
|
160
|
+
return record.context["event.name"] === ROUTEKIT_HTTP_SERVER_REQUEST_EVENT_NAME;
|
|
161
|
+
}
|
|
162
|
+
function stripActivityLifecycleMessage(record) {
|
|
163
|
+
return record.metadata.activity == null ? record.data : record.data.slice(1);
|
|
164
|
+
}
|
|
165
|
+
function getActivityDirection(record) {
|
|
166
|
+
switch (record.metadata.activity?.phase) {
|
|
167
|
+
case "start": return "→";
|
|
168
|
+
case "end": return "←";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function getStringContext(record, key) {
|
|
172
|
+
const value = record.context[key];
|
|
173
|
+
return value == null ? void 0 : String(value);
|
|
174
|
+
}
|
|
175
|
+
function getNumberContext(value) {
|
|
176
|
+
if (typeof value !== "number") return;
|
|
177
|
+
return Number.isFinite(value) ? value : void 0;
|
|
178
|
+
}
|
|
179
|
+
function formatServiceName(value, colors) {
|
|
180
|
+
if (value == null) return;
|
|
181
|
+
const text = `[${value}]`;
|
|
182
|
+
return colors.bold(colors.blueBright(text));
|
|
183
|
+
}
|
|
184
|
+
function formatRequestId(value, colors) {
|
|
185
|
+
const text = `[${value}]`;
|
|
186
|
+
let hash = 0;
|
|
187
|
+
for (const char of value) hash = hash * 31 + char.codePointAt(0) >>> 0;
|
|
188
|
+
return colors[REQUEST_ID_COLOR_KEYS[hash % REQUEST_ID_COLOR_KEYS.length]](text);
|
|
189
|
+
}
|
|
190
|
+
function formatDirection(value, colors) {
|
|
191
|
+
return value == null ? void 0 : colors.bold(colors.whiteBright(value));
|
|
192
|
+
}
|
|
193
|
+
function formatStatusCode(value, colors) {
|
|
194
|
+
if (value == null) return;
|
|
195
|
+
const text = String(value);
|
|
196
|
+
const formatted = value >= 500 ? colors.redBright(text) : value >= 400 ? colors.yellowBright(text) : value >= 300 ? colors.cyanBright(text) : colors.greenBright(text);
|
|
197
|
+
return colors.bold(formatted);
|
|
198
|
+
}
|
|
199
|
+
function formatDuration(value, colors) {
|
|
200
|
+
if (value == null) return;
|
|
201
|
+
const text = `${formatDurationMilliseconds(value)}ms`;
|
|
202
|
+
if (value >= 1e3) return colors.redBright(text);
|
|
203
|
+
return value >= 250 ? colors.yellowBright(text) : colors.greenBright(text);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* @internal
|
|
207
|
+
*/
|
|
208
|
+
function formatDurationMilliseconds(value) {
|
|
209
|
+
const p2 = value.toFixed(2);
|
|
210
|
+
if (Number(p2) < 1) return p2;
|
|
211
|
+
const p1 = value.toFixed(1);
|
|
212
|
+
if (Number(p1) < 100) return p1;
|
|
213
|
+
return value.toFixed(0);
|
|
214
|
+
}
|
|
215
|
+
function formatBodySize(value, colors) {
|
|
216
|
+
return value == null ? void 0 : colors.cyan(formatByteSize(value));
|
|
217
|
+
}
|
|
218
|
+
function formatByteSize(value) {
|
|
219
|
+
if (!Number.isFinite(value) || value < 0) return String(value);
|
|
220
|
+
if (value < 1024) return `${value} B`;
|
|
221
|
+
const units = [
|
|
222
|
+
"KiB",
|
|
223
|
+
"MiB",
|
|
224
|
+
"GiB"
|
|
225
|
+
];
|
|
226
|
+
let amount = value;
|
|
227
|
+
let unit = "B";
|
|
228
|
+
for (const nextUnit of units) {
|
|
229
|
+
amount /= 1024;
|
|
230
|
+
unit = nextUnit;
|
|
231
|
+
if (amount < 1024 || nextUnit === units.at(-1)) break;
|
|
232
|
+
}
|
|
233
|
+
const precision = amount >= 100 ? 0 : amount >= 10 ? 1 : 2;
|
|
234
|
+
return `${amount.toFixed(precision)} ${unit}`;
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/router/middleware/request-logger.ts
|
|
238
|
+
function defaultCompletionLevel(response) {
|
|
239
|
+
return response.status >= 500 ? LogLevel.ERROR : LogLevel.INFO;
|
|
240
|
+
}
|
|
241
|
+
const HTTP_SERVER_REQUEST_EVENT = { "event.name": ROUTEKIT_HTTP_SERVER_REQUEST_EVENT_NAME };
|
|
242
|
+
function getResponseBodySize(response) {
|
|
243
|
+
if (response.body == null) return 0;
|
|
244
|
+
const value = response.headers.get("content-length");
|
|
245
|
+
if (value == null || !/^\d+$/u.test(value)) return;
|
|
246
|
+
const size = Number(value);
|
|
247
|
+
return Number.isSafeInteger(size) ? size : void 0;
|
|
248
|
+
}
|
|
249
|
+
function getClientAddress(request, trustedHeader) {
|
|
250
|
+
if (trustedHeader == null) return;
|
|
251
|
+
const value = request.headers.get(trustedHeader)?.trim();
|
|
252
|
+
if (!value) return;
|
|
253
|
+
return trustedHeader.toLowerCase() === "x-forwarded-for" ? value.split(",", 1)[0]?.trim() : value;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Log a timed request activity through the request's contextual logger.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* router.useRequest(requestIdCtx())
|
|
261
|
+
* router.useRequest(requestLogger())
|
|
262
|
+
* ```
|
|
263
|
+
*
|
|
264
|
+
* @param options - Request activity naming and severity options.
|
|
265
|
+
* @returns Request-boundary middleware that logs final status and duration.
|
|
266
|
+
* @typeParam Ctx - Context containing the correlated request identifier.
|
|
267
|
+
*/
|
|
268
|
+
function requestLogger(options = {}) {
|
|
269
|
+
const activityName = options.activityName ?? "request";
|
|
270
|
+
return async (ctx, next) => {
|
|
271
|
+
const clientAddress = getClientAddress(ctx.request, options.trustedClientAddressHeader);
|
|
272
|
+
const userAgent = ctx.request.headers.get("user-agent") ?? void 0;
|
|
273
|
+
const activity = ctx.logger.startActivity(activityName, {
|
|
274
|
+
...clientAddress == null ? {} : { "client.address": clientAddress },
|
|
275
|
+
...userAgent == null ? {} : { "user_agent.original": userAgent }
|
|
276
|
+
});
|
|
277
|
+
ctx.logger = activity.logger;
|
|
278
|
+
try {
|
|
279
|
+
const response = await next();
|
|
280
|
+
const bodySize = getResponseBodySize(response);
|
|
281
|
+
const level = typeof options.completionLevel === "function" ? options.completionLevel(response) : options.completionLevel ?? defaultCompletionLevel(response);
|
|
282
|
+
activity.end({
|
|
283
|
+
context: {
|
|
284
|
+
...HTTP_SERVER_REQUEST_EVENT,
|
|
285
|
+
"http.response.status_code": response.status,
|
|
286
|
+
...bodySize == null ? {} : { "http.response.body.size": bodySize }
|
|
287
|
+
},
|
|
288
|
+
level
|
|
289
|
+
});
|
|
290
|
+
return response;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
activity.end({
|
|
293
|
+
context: HTTP_SERVER_REQUEST_EVENT,
|
|
294
|
+
data: [error],
|
|
295
|
+
level: LogLevel.ERROR
|
|
296
|
+
});
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/router/middleware/body-limit.ts
|
|
303
|
+
const utf8encoder = new TextEncoder();
|
|
304
|
+
function parseContentLength(value) {
|
|
305
|
+
if (!value) return null;
|
|
306
|
+
const trimmed = value.trim();
|
|
307
|
+
if (!trimmed) return null;
|
|
308
|
+
const parsed = Number(trimmed);
|
|
309
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) return null;
|
|
310
|
+
return parsed;
|
|
311
|
+
}
|
|
312
|
+
function chunkByteLength(chunk) {
|
|
313
|
+
if (typeof chunk === "string") return utf8encoder.encode(chunk).length;
|
|
314
|
+
return chunk.byteLength;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Enforce a maximum request body size while preserving access to the incoming stream.
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```ts
|
|
321
|
+
* router.use(bodyLimit({maxSize: 1024 * 1024}))
|
|
322
|
+
* ```
|
|
323
|
+
*
|
|
324
|
+
* @param options - Configuration for maximum request body size enforcement.
|
|
325
|
+
* @returns Middleware that rejects oversized bodies and mismatched Content-Length values.
|
|
326
|
+
*/
|
|
327
|
+
function bodyLimit(options) {
|
|
328
|
+
const textResponse = {
|
|
329
|
+
schema: { type: "string" },
|
|
330
|
+
parse(value) {
|
|
331
|
+
if (typeof value !== "string") throw new TypeError("Body limit responses must contain a string body.");
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
return defineMiddleware({
|
|
336
|
+
responses: {
|
|
337
|
+
[HttpStatus.BAD_REQUEST]: textResponse,
|
|
338
|
+
[HttpStatus.PAYLOAD_TOO_LARGE]: textResponse
|
|
339
|
+
},
|
|
340
|
+
async run(ctx, { next, forward, respond }) {
|
|
341
|
+
const maxSize = options.maxSize;
|
|
342
|
+
const contentLength = parseContentLength(ctx.request.headers.get("content-length"));
|
|
343
|
+
if (contentLength != null && contentLength > maxSize) {
|
|
344
|
+
ctx.request.body.stream()?.cancel();
|
|
345
|
+
return respond(text("Payload Too Large", { status: HttpStatus.PAYLOAD_TOO_LARGE }));
|
|
346
|
+
}
|
|
347
|
+
const bodyStream = ctx.request.body.stream();
|
|
348
|
+
if (!bodyStream) {
|
|
349
|
+
if (contentLength != null && contentLength !== 0) return respond(text("Bad Request", { status: HttpStatus.BAD_REQUEST }));
|
|
350
|
+
return forward(await next());
|
|
351
|
+
}
|
|
352
|
+
const reader = bodyStream.getReader();
|
|
353
|
+
let bytesRead = 0;
|
|
354
|
+
const monitoredBody = new ReadableStream({
|
|
355
|
+
async pull(controller) {
|
|
356
|
+
const result = await reader.read();
|
|
357
|
+
if (result.done) {
|
|
358
|
+
if (contentLength != null && bytesRead !== contentLength) {
|
|
359
|
+
controller.error(new RequestBodyLengthMismatchError(contentLength, bytesRead));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
controller.close();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const value = result.value;
|
|
366
|
+
bytesRead += chunkByteLength(value);
|
|
367
|
+
if (bytesRead > maxSize) {
|
|
368
|
+
await reader.cancel();
|
|
369
|
+
controller.error(new RequestBodyTooLargeError(maxSize, bytesRead));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
controller.enqueue(value);
|
|
373
|
+
},
|
|
374
|
+
cancel(reason) {
|
|
375
|
+
return reader.cancel(reason);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
ctx.request = ctx.request.withBody(ctx.request.body.withStream(monitoredBody));
|
|
379
|
+
try {
|
|
380
|
+
return forward(await next());
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (err instanceof RequestBodyTooLargeError) return respond(text("Payload Too Large", { status: HttpStatus.PAYLOAD_TOO_LARGE }));
|
|
383
|
+
if (err instanceof RequestBodyLengthMismatchError) return respond(text("Bad Request", { status: HttpStatus.BAD_REQUEST }));
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/router/middleware/start-time-ctx.ts
|
|
391
|
+
const startTimeCtx = () => (ctx) => {
|
|
392
|
+
ctx.startTime = Date.now();
|
|
393
|
+
};
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/router/middleware/accept-ctx.ts
|
|
396
|
+
/**
|
|
397
|
+
* Attach parsed Accept header values to the request context.
|
|
398
|
+
*
|
|
399
|
+
* @returns Middleware that adds `accept` to the request context.
|
|
400
|
+
*/
|
|
401
|
+
const acceptCtx = () => (ctx) => {
|
|
402
|
+
ctx.accept = parseAcceptHeader(ctx.request.headers.get("accept") ?? "*/*");
|
|
403
|
+
};
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/router/lib/host.ts
|
|
406
|
+
function isLocalhost(hostname) {
|
|
407
|
+
const lower = hostname.toLowerCase();
|
|
408
|
+
return lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0";
|
|
409
|
+
}
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/router/middleware/cors.ts
|
|
412
|
+
const headerOrigin = CommonHeaders.ORIGIN;
|
|
413
|
+
const headerVary = CommonHeaders.VARY;
|
|
414
|
+
const headerAccessControlRequestMethod = CommonHeaders.ACCESS_CONTROL_REQUEST_METHOD;
|
|
415
|
+
const headerAccessControlRequestHeaders = CommonHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
|
|
416
|
+
const headerAllowOrigin = CommonHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
|
|
417
|
+
const headerAllowMethods = CommonHeaders.ACCESS_CONTROL_ALLOW_METHODS;
|
|
418
|
+
const headerAllowHeaders = CommonHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
|
|
419
|
+
const headerAllowCredentials = CommonHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
|
|
420
|
+
const headerExposeHeaders = CommonHeaders.ACCESS_CONTROL_EXPOSE_HEADERS;
|
|
421
|
+
const headerMaxAge = CommonHeaders.ACCESS_CONTROL_MAX_AGE;
|
|
422
|
+
const defaultAllowedMethods = [
|
|
423
|
+
HttpMethod.GET,
|
|
424
|
+
HttpMethod.HEAD,
|
|
425
|
+
HttpMethod.PUT,
|
|
426
|
+
HttpMethod.POST,
|
|
427
|
+
HttpMethod.DELETE,
|
|
428
|
+
HttpMethod.PATCH
|
|
429
|
+
];
|
|
430
|
+
function normalizeHeaderValue(value) {
|
|
431
|
+
if (!value) return null;
|
|
432
|
+
const trimmed = value.trim();
|
|
433
|
+
return trimmed ? trimmed : null;
|
|
434
|
+
}
|
|
435
|
+
function normalizeOriginHeader(value) {
|
|
436
|
+
return normalizeHeaderValue(value);
|
|
437
|
+
}
|
|
438
|
+
function parseOrigin(originHeader) {
|
|
439
|
+
if (!originHeader || originHeader === "null") return null;
|
|
440
|
+
try {
|
|
441
|
+
return new URL(originHeader);
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function normalizeList(value) {
|
|
447
|
+
if (!value) return [];
|
|
448
|
+
return (Array.isArray(value) ? value : [value]).map((entry) => entry.trim()).filter(Boolean);
|
|
449
|
+
}
|
|
450
|
+
function normalizeMethods(value) {
|
|
451
|
+
return normalizeList(value).map((entry) => entry.toUpperCase());
|
|
452
|
+
}
|
|
453
|
+
function formatHeaderList(value) {
|
|
454
|
+
const list = normalizeList(value);
|
|
455
|
+
if (!list.length) return null;
|
|
456
|
+
return list.join(", ");
|
|
457
|
+
}
|
|
458
|
+
function formatMethodList(value) {
|
|
459
|
+
const list = normalizeMethods(value);
|
|
460
|
+
if (!list.length) return null;
|
|
461
|
+
return list.join(", ");
|
|
462
|
+
}
|
|
463
|
+
function normalizeAllowedOrigins(value) {
|
|
464
|
+
if (!value) return [];
|
|
465
|
+
const list = Array.isArray(value) ? value : [value];
|
|
466
|
+
const normalized = [];
|
|
467
|
+
for (const entry of list) {
|
|
468
|
+
if (entry instanceof RegExp) {
|
|
469
|
+
normalized.push({
|
|
470
|
+
kind: "regex",
|
|
471
|
+
value: entry
|
|
472
|
+
});
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (entry instanceof URL) {
|
|
476
|
+
normalized.push({
|
|
477
|
+
kind: "origin",
|
|
478
|
+
value: entry.origin
|
|
479
|
+
});
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const trimmed = entry.trim();
|
|
483
|
+
if (!trimmed) continue;
|
|
484
|
+
if (trimmed === "null") {
|
|
485
|
+
normalized.push({ kind: "null" });
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (trimmed.includes("://")) try {
|
|
489
|
+
normalized.push({
|
|
490
|
+
kind: "origin",
|
|
491
|
+
value: new URL(trimmed).origin
|
|
492
|
+
});
|
|
493
|
+
} catch {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
else normalized.push({
|
|
497
|
+
kind: "host",
|
|
498
|
+
value: trimmed.replace(/\/+$/, "")
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return normalized;
|
|
502
|
+
}
|
|
503
|
+
function hasWildcardOrigin(value) {
|
|
504
|
+
if (!value) return false;
|
|
505
|
+
if (!Array.isArray(value)) return typeof value === "string" && value.trim() === "*";
|
|
506
|
+
return value.some((entry) => typeof entry === "string" && entry.trim() === "*");
|
|
507
|
+
}
|
|
508
|
+
function isOriginAllowed(originHeader, originUrl, allowlist, allowLocalhost) {
|
|
509
|
+
if (!originHeader) return false;
|
|
510
|
+
if (originHeader === "null") return allowlist.some((entry) => entry.kind === "null");
|
|
511
|
+
if (originUrl && allowLocalhost && isLocalhost(originUrl.hostname)) return true;
|
|
512
|
+
for (const entry of allowlist) {
|
|
513
|
+
if (entry.kind === "regex") {
|
|
514
|
+
if (entry.value.test(originHeader)) return true;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (!originUrl) continue;
|
|
518
|
+
if (entry.kind === "origin") {
|
|
519
|
+
if (originUrl.origin === entry.value) return true;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (entry.kind === "host") {
|
|
523
|
+
if (entry.value.includes(":")) {
|
|
524
|
+
if (originUrl.host === entry.value) return true;
|
|
525
|
+
} else if (originUrl.hostname === entry.value) return true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
function addVaryHeader(headers, value) {
|
|
531
|
+
const current = headers.get(headerVary);
|
|
532
|
+
if (!current) {
|
|
533
|
+
headers.set(headerVary, value);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (current.split(",").map((entry) => entry.trim().toLowerCase()).includes(value.toLowerCase())) return;
|
|
537
|
+
headers.set(headerVary, `${current}, ${value}`);
|
|
538
|
+
}
|
|
539
|
+
function applyCorsHeaders(headers, allowOrigin, allowCredentials, exposeHeaders, varyOrigin) {
|
|
540
|
+
headers.set(headerAllowOrigin, allowOrigin);
|
|
541
|
+
if (allowCredentials) headers.set(headerAllowCredentials, "true");
|
|
542
|
+
const expose = formatHeaderList(exposeHeaders);
|
|
543
|
+
if (expose) headers.set(headerExposeHeaders, expose);
|
|
544
|
+
if (varyOrigin) addVaryHeader(headers, "Origin");
|
|
545
|
+
}
|
|
546
|
+
function isAsyncGenerator(value) {
|
|
547
|
+
return !!value && typeof value[Symbol.asyncIterator] === "function";
|
|
548
|
+
}
|
|
549
|
+
function wrapGeneratorWithCors(generator, allowOrigin, allowCredentials, exposeHeaders, varyOrigin) {
|
|
550
|
+
const apply = (headers) => {
|
|
551
|
+
applyCorsHeaders(headers, allowOrigin, allowCredentials, exposeHeaders, varyOrigin);
|
|
552
|
+
};
|
|
553
|
+
async function* wrapped() {
|
|
554
|
+
let headersInjected = false;
|
|
555
|
+
while (true) {
|
|
556
|
+
const next = await generator.next();
|
|
557
|
+
if (next.done) {
|
|
558
|
+
if (!headersInjected) {
|
|
559
|
+
const headers$1 = new Headers();
|
|
560
|
+
apply(headers$1);
|
|
561
|
+
yield headers(headers$1);
|
|
562
|
+
}
|
|
563
|
+
return next.value;
|
|
564
|
+
}
|
|
565
|
+
const value = next.value;
|
|
566
|
+
if (isHeadersDirective(value)) {
|
|
567
|
+
const headers$2 = new Headers(value.headers);
|
|
568
|
+
apply(headers$2);
|
|
569
|
+
headersInjected = true;
|
|
570
|
+
yield headers(headers$2);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (isHeadDirective(value)) {
|
|
574
|
+
const headers = new Headers(value.headers);
|
|
575
|
+
apply(headers);
|
|
576
|
+
headersInjected = true;
|
|
577
|
+
yield {
|
|
578
|
+
...value,
|
|
579
|
+
headers
|
|
580
|
+
};
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (isStreamDirective(value)) {
|
|
584
|
+
const headers = new Headers(value.headers);
|
|
585
|
+
apply(headers);
|
|
586
|
+
headersInjected = true;
|
|
587
|
+
yield {
|
|
588
|
+
...value,
|
|
589
|
+
headers
|
|
590
|
+
};
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (!headersInjected && isChunkDirective(value)) {
|
|
594
|
+
const headers$3 = new Headers();
|
|
595
|
+
apply(headers$3);
|
|
596
|
+
headersInjected = true;
|
|
597
|
+
yield headers(headers$3);
|
|
598
|
+
}
|
|
599
|
+
yield value;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return wrapped();
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Attach CORS response headers and handle OPTIONS preflight requests.
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* ```ts
|
|
609
|
+
* router.use(cors({origin: '*'}))
|
|
610
|
+
* router.use(cors({origin: ['https://app.example.com'], credentials: true}))
|
|
611
|
+
* router.use(cors({origin: 'https://app.example.com', dev: true}))
|
|
612
|
+
* ```
|
|
613
|
+
*
|
|
614
|
+
* @param options - Configuration for origin, preflight, and header behavior.
|
|
615
|
+
* @returns Middleware that applies CORS headers to matching requests.
|
|
616
|
+
*/
|
|
617
|
+
function cors(options) {
|
|
618
|
+
const allowCredentials = options.credentials ?? false;
|
|
619
|
+
const allowLocalhost = options.allowLocalhost ?? options.dev ?? false;
|
|
620
|
+
const originOption = options.origin;
|
|
621
|
+
const originResolver = typeof originOption === "function" ? originOption : void 0;
|
|
622
|
+
const originList = originResolver ? void 0 : originOption;
|
|
623
|
+
const allowlist = originList ? normalizeAllowedOrigins(originList) : [];
|
|
624
|
+
const hasWildcard = originList ? hasWildcardOrigin(originList) : false;
|
|
625
|
+
const preflightStatus = options.preflightStatus ?? HttpStatus.NO_CONTENT;
|
|
626
|
+
return defineMiddleware({
|
|
627
|
+
responses: { [preflightStatus]: {
|
|
628
|
+
schema: { type: "null" },
|
|
629
|
+
parse(value) {
|
|
630
|
+
if (value !== void 0) throw new TypeError("CORS preflight responses must not contain a body.");
|
|
631
|
+
}
|
|
632
|
+
} },
|
|
633
|
+
schemaAppliesTo: ({ method }) => Array.isArray(method) ? method.includes(HttpMethod.OPTIONS) : method === HttpMethod.OPTIONS,
|
|
634
|
+
async run(ctx, { next, forward, respond }) {
|
|
635
|
+
const originHeader = normalizeOriginHeader(ctx.request.headers.get(headerOrigin));
|
|
636
|
+
const originUrl = parseOrigin(originHeader);
|
|
637
|
+
let allowOrigin = null;
|
|
638
|
+
let varyOrigin = false;
|
|
639
|
+
if (originResolver) {
|
|
640
|
+
const resolved = await originResolver(originHeader, ctx);
|
|
641
|
+
if (resolved) {
|
|
642
|
+
const normalized = String(resolved).trim();
|
|
643
|
+
if (normalized === "*") {
|
|
644
|
+
if (allowCredentials && originHeader) {
|
|
645
|
+
allowOrigin = originUrl?.origin ?? originHeader;
|
|
646
|
+
varyOrigin = true;
|
|
647
|
+
} else if (!allowCredentials) allowOrigin = "*";
|
|
648
|
+
} else if (normalized) {
|
|
649
|
+
allowOrigin = normalized;
|
|
650
|
+
varyOrigin = Boolean(originHeader);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
} else if (hasWildcard) {
|
|
654
|
+
if (allowCredentials && originHeader) {
|
|
655
|
+
allowOrigin = originUrl?.origin ?? originHeader;
|
|
656
|
+
varyOrigin = true;
|
|
657
|
+
} else if (!allowCredentials) allowOrigin = "*";
|
|
658
|
+
} else if (originHeader && isOriginAllowed(originHeader, originUrl, allowlist, allowLocalhost)) {
|
|
659
|
+
allowOrigin = originUrl?.origin ?? originHeader;
|
|
660
|
+
varyOrigin = true;
|
|
661
|
+
}
|
|
662
|
+
if (ctx.request.method.toUpperCase() === "OPTIONS" && ctx.request.headers.has(headerAccessControlRequestMethod)) {
|
|
663
|
+
if (!allowOrigin) return respond(empty(preflightStatus));
|
|
664
|
+
const headers = new Headers();
|
|
665
|
+
applyCorsHeaders(headers, allowOrigin, allowCredentials, void 0, varyOrigin);
|
|
666
|
+
const allowMethodsValue = formatMethodList(typeof options.allowMethods === "function" ? await options.allowMethods(originHeader, ctx) : options.allowMethods ?? defaultAllowedMethods);
|
|
667
|
+
if (allowMethodsValue) headers.set(headerAllowMethods, allowMethodsValue);
|
|
668
|
+
const requestHeaders = normalizeHeaderValue(ctx.request.headers.get(headerAccessControlRequestHeaders));
|
|
669
|
+
const allowHeadersValue = formatHeaderList(options.allowHeaders ?? requestHeaders ?? void 0);
|
|
670
|
+
if (allowHeadersValue) headers.set(headerAllowHeaders, allowHeadersValue);
|
|
671
|
+
if (options.maxAge != null) headers.set(headerMaxAge, String(options.maxAge));
|
|
672
|
+
return respond(empty(preflightStatus, { headers }));
|
|
673
|
+
}
|
|
674
|
+
const result = await next();
|
|
675
|
+
if (!allowOrigin) return forward(result);
|
|
676
|
+
if (result instanceof Response) {
|
|
677
|
+
applyCorsHeaders(result.headers, allowOrigin, allowCredentials, options.exposeHeaders, varyOrigin);
|
|
678
|
+
return forward(result);
|
|
679
|
+
}
|
|
680
|
+
if (isRoutekitResponse(result)) {
|
|
681
|
+
applyCorsHeaders(result.headers, allowOrigin, allowCredentials, options.exposeHeaders, varyOrigin);
|
|
682
|
+
return forward(result);
|
|
683
|
+
}
|
|
684
|
+
if (isAsyncGenerator(result)) return forward(wrapGeneratorWithCors(result, allowOrigin, allowCredentials, options.exposeHeaders, varyOrigin));
|
|
685
|
+
if (result != null && isResponseBodyInit(result)) {
|
|
686
|
+
const headers = new Headers();
|
|
687
|
+
applyCorsHeaders(headers, allowOrigin, allowCredentials, options.exposeHeaders, varyOrigin);
|
|
688
|
+
return forward(new Response(result, { headers }));
|
|
689
|
+
}
|
|
690
|
+
const headers = new Headers();
|
|
691
|
+
applyCorsHeaders(headers, allowOrigin, allowCredentials, options.exposeHeaders, varyOrigin);
|
|
692
|
+
return forward(response(result, { headers }));
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/router/middleware/rate-limit.ts
|
|
698
|
+
const DEFAULT_MAX_ENTRIES = 1e5;
|
|
699
|
+
const ASN_OVERRIDES = {
|
|
700
|
+
16509: "cloud",
|
|
701
|
+
14618: "cloud",
|
|
702
|
+
15169: "cloud",
|
|
703
|
+
8075: "cloud",
|
|
704
|
+
31898: "cloud",
|
|
705
|
+
45102: "cloud",
|
|
706
|
+
132203: "cloud",
|
|
707
|
+
36351: "cloud",
|
|
708
|
+
13335: "cdn",
|
|
709
|
+
54113: "cdn",
|
|
710
|
+
20940: "cdn",
|
|
711
|
+
14061: "cloud",
|
|
712
|
+
20473: "cloud",
|
|
713
|
+
63949: "cloud",
|
|
714
|
+
24940: "hosting",
|
|
715
|
+
16276: "hosting",
|
|
716
|
+
12876: "hosting",
|
|
717
|
+
8560: "hosting",
|
|
718
|
+
47583: "hosting",
|
|
719
|
+
22612: "hosting"
|
|
720
|
+
};
|
|
721
|
+
const KEYWORDS = {
|
|
722
|
+
cdn: [
|
|
723
|
+
"cloudflare",
|
|
724
|
+
"fastly",
|
|
725
|
+
"akamai",
|
|
726
|
+
"cdn"
|
|
727
|
+
],
|
|
728
|
+
cloud: [
|
|
729
|
+
"amazon",
|
|
730
|
+
"aws",
|
|
731
|
+
"google",
|
|
732
|
+
"gcp",
|
|
733
|
+
"microsoft",
|
|
734
|
+
"azure",
|
|
735
|
+
"oracle",
|
|
736
|
+
"alibaba",
|
|
737
|
+
"tencent",
|
|
738
|
+
"digitalocean",
|
|
739
|
+
"vultr",
|
|
740
|
+
"linode",
|
|
741
|
+
"ibm"
|
|
742
|
+
],
|
|
743
|
+
hosting: [
|
|
744
|
+
"hosting",
|
|
745
|
+
"host",
|
|
746
|
+
"colo",
|
|
747
|
+
"datacenter",
|
|
748
|
+
"data center",
|
|
749
|
+
"ovh",
|
|
750
|
+
"scaleway",
|
|
751
|
+
"ionos",
|
|
752
|
+
"hostinger",
|
|
753
|
+
"namecheap"
|
|
754
|
+
],
|
|
755
|
+
mobile: [
|
|
756
|
+
"mobile",
|
|
757
|
+
"wireless",
|
|
758
|
+
"cellular",
|
|
759
|
+
"lte",
|
|
760
|
+
"5g"
|
|
761
|
+
],
|
|
762
|
+
residential: [
|
|
763
|
+
"telecom",
|
|
764
|
+
"broadband",
|
|
765
|
+
"cable",
|
|
766
|
+
"fiber"
|
|
767
|
+
],
|
|
768
|
+
unknown: []
|
|
769
|
+
};
|
|
770
|
+
var InMemoryRateLimitStorage = class {
|
|
771
|
+
maxEntries;
|
|
772
|
+
store = /* @__PURE__ */ new Map();
|
|
773
|
+
constructor(maxEntries) {
|
|
774
|
+
this.maxEntries = maxEntries;
|
|
775
|
+
}
|
|
776
|
+
async readCounter(_ctx, key) {
|
|
777
|
+
const entry = this.store.get(key);
|
|
778
|
+
if (!entry) return null;
|
|
779
|
+
if (entry.expiresAtMs <= Date.now()) {
|
|
780
|
+
this.store.delete(key);
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
this.store.delete(key);
|
|
784
|
+
this.store.set(key, entry);
|
|
785
|
+
return entry.counter;
|
|
786
|
+
}
|
|
787
|
+
async writeCounter(_ctx, key, counter, ttlMs) {
|
|
788
|
+
const expiresAtMs = Date.now() + ttlMs;
|
|
789
|
+
if (this.store.has(key)) this.store.delete(key);
|
|
790
|
+
this.store.set(key, {
|
|
791
|
+
counter,
|
|
792
|
+
expiresAtMs
|
|
793
|
+
});
|
|
794
|
+
this.evictIfNeeded();
|
|
795
|
+
}
|
|
796
|
+
evictIfNeeded() {
|
|
797
|
+
while (this.store.size > this.maxEntries) {
|
|
798
|
+
const firstKey = this.store.keys().next().value;
|
|
799
|
+
if (!firstKey) return;
|
|
800
|
+
this.store.delete(firstKey);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
function defaultGetIpAddress(ctx) {
|
|
805
|
+
const forwardedFor = ctx.request.headers.get("x-forwarded-for");
|
|
806
|
+
if (forwardedFor) {
|
|
807
|
+
const first = forwardedFor.split(",")[0]?.trim();
|
|
808
|
+
if (first) return Promise.resolve(cleanIpAddress(first));
|
|
809
|
+
}
|
|
810
|
+
const realIp = ctx.request.headers.get("x-real-ip");
|
|
811
|
+
if (realIp) return Promise.resolve(cleanIpAddress(realIp.trim()));
|
|
812
|
+
return Promise.resolve("unknown");
|
|
813
|
+
}
|
|
814
|
+
function cleanIpAddress(ip) {
|
|
815
|
+
let value = ip.trim();
|
|
816
|
+
if (!value) return "unknown";
|
|
817
|
+
if (value.startsWith("[")) {
|
|
818
|
+
const closing = value.indexOf("]");
|
|
819
|
+
if (closing !== -1) value = value.slice(1, closing);
|
|
820
|
+
}
|
|
821
|
+
const zoneIndex = value.indexOf("%");
|
|
822
|
+
if (zoneIndex !== -1) value = value.slice(0, zoneIndex);
|
|
823
|
+
if (value.includes(".")) {
|
|
824
|
+
const lastSegment = value.split(":").pop()?.trim();
|
|
825
|
+
if (lastSegment && parseIpv4Address(lastSegment)) return lastSegment;
|
|
826
|
+
if (value.split(":").length === 2) {
|
|
827
|
+
const segment = value.split(":")[0];
|
|
828
|
+
if (!segment) return "unknown";
|
|
829
|
+
value = segment;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (parseIpv4Address(value) || parseIpv6Hextets(value)) return value;
|
|
833
|
+
return "unknown";
|
|
834
|
+
}
|
|
835
|
+
function normalizeQueryString(url) {
|
|
836
|
+
const entries = Array.from(url.searchParams.entries());
|
|
837
|
+
entries.sort((a, b) => {
|
|
838
|
+
const keyCompare = a[0].localeCompare(b[0]);
|
|
839
|
+
if (keyCompare !== 0) return keyCompare;
|
|
840
|
+
return a[1].localeCompare(b[1]);
|
|
841
|
+
});
|
|
842
|
+
const normalized = new URLSearchParams();
|
|
843
|
+
for (const [key, value] of entries) normalized.append(key, value);
|
|
844
|
+
return normalized.toString();
|
|
845
|
+
}
|
|
846
|
+
function getBucketMax(baseMax, baseWindowMs, bucket) {
|
|
847
|
+
return Math.floor(baseMax * (bucket.windowMs / baseWindowMs) * bucket.scale);
|
|
848
|
+
}
|
|
849
|
+
function getBucketResetAt(nowMs, windowMs) {
|
|
850
|
+
return Math.floor(nowMs / windowMs) * windowMs + windowMs;
|
|
851
|
+
}
|
|
852
|
+
async function applyFixedWindowLimit(ctx, storage, key, windowMs, max, nowMs, retentionMs) {
|
|
853
|
+
const stored = await storage.readCounter(ctx, key);
|
|
854
|
+
const resetAtMs = getBucketResetAt(nowMs, windowMs);
|
|
855
|
+
const counter = stored && stored.resetAtMs > nowMs ? stored : {
|
|
856
|
+
resetAtMs,
|
|
857
|
+
count: 0
|
|
858
|
+
};
|
|
859
|
+
const nextCount = counter.count + 1;
|
|
860
|
+
const updated = {
|
|
861
|
+
resetAtMs: counter.resetAtMs,
|
|
862
|
+
count: nextCount
|
|
863
|
+
};
|
|
864
|
+
const ttlEffective = Math.max(1, counter.resetAtMs - nowMs + 1e3) + Math.max(0, retentionMs);
|
|
865
|
+
await storage.writeCounter(ctx, key, updated, ttlEffective);
|
|
866
|
+
return {
|
|
867
|
+
allowed: nextCount <= max,
|
|
868
|
+
resetAtMs: counter.resetAtMs
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function toURLPattern(pattern) {
|
|
872
|
+
const URLPatternCtor = ensureURLPattern();
|
|
873
|
+
if (pattern instanceof URLPatternCtor) return pattern;
|
|
874
|
+
if (Array.isArray(pattern)) return new URLPatternCtor(...pattern);
|
|
875
|
+
if (typeof pattern === "string") {
|
|
876
|
+
if (pattern.startsWith("http://") || pattern.startsWith("https://")) return new URLPatternCtor(pattern);
|
|
877
|
+
return new URLPatternCtor({ pathname: pattern });
|
|
878
|
+
}
|
|
879
|
+
return new URLPatternCtor(pattern);
|
|
880
|
+
}
|
|
881
|
+
function resolveEndpointLimit(method, url, matchers) {
|
|
882
|
+
const normalizedMethod = method.toUpperCase();
|
|
883
|
+
let minLimit = null;
|
|
884
|
+
for (const matcher of matchers) {
|
|
885
|
+
if (!matcher.pattern.test(url)) continue;
|
|
886
|
+
const limit = matcher.limit;
|
|
887
|
+
const methodLimit = typeof limit === "number" ? limit : limit[normalizedMethod];
|
|
888
|
+
if (methodLimit == null) continue;
|
|
889
|
+
minLimit = minLimit == null ? methodLimit : Math.min(minLimit, methodLimit);
|
|
890
|
+
}
|
|
891
|
+
return minLimit;
|
|
892
|
+
}
|
|
893
|
+
function parseIpv4Address(ip) {
|
|
894
|
+
const parts = ip.split(".");
|
|
895
|
+
if (parts.length !== 4) return null;
|
|
896
|
+
const bytes = [];
|
|
897
|
+
for (const part of parts) {
|
|
898
|
+
if (!part) return null;
|
|
899
|
+
const value = Number(part);
|
|
900
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
|
901
|
+
bytes.push(value);
|
|
902
|
+
}
|
|
903
|
+
return bytes;
|
|
904
|
+
}
|
|
905
|
+
function parseIpv6Hextets(ip) {
|
|
906
|
+
let value = ip.toLowerCase();
|
|
907
|
+
const zoneIndex = value.indexOf("%");
|
|
908
|
+
if (zoneIndex !== -1) value = value.slice(0, zoneIndex);
|
|
909
|
+
const halves = value.split("::");
|
|
910
|
+
if (halves.length > 2) return null;
|
|
911
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
912
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
913
|
+
const leftParsed = parseIpv6Segments(left);
|
|
914
|
+
if (!leftParsed) return null;
|
|
915
|
+
const rightParsed = parseIpv6Segments(right);
|
|
916
|
+
if (!rightParsed) return null;
|
|
917
|
+
if (halves.length === 1) {
|
|
918
|
+
if (leftParsed.length !== 8) return null;
|
|
919
|
+
return leftParsed;
|
|
920
|
+
}
|
|
921
|
+
const missing = 8 - (leftParsed.length + rightParsed.length);
|
|
922
|
+
if (missing < 0) return null;
|
|
923
|
+
return [
|
|
924
|
+
...leftParsed,
|
|
925
|
+
...new Array(missing).fill(0),
|
|
926
|
+
...rightParsed
|
|
927
|
+
];
|
|
928
|
+
}
|
|
929
|
+
function parseIpv6Segments(parts) {
|
|
930
|
+
const hextets = [];
|
|
931
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
932
|
+
const part = parts[index];
|
|
933
|
+
if (!part) return null;
|
|
934
|
+
if (part.includes(".")) {
|
|
935
|
+
if (index !== parts.length - 1) return null;
|
|
936
|
+
const bytes = parseIpv4Address(part);
|
|
937
|
+
if (!bytes) return null;
|
|
938
|
+
if (bytes.length !== 4) return null;
|
|
939
|
+
const [b0, b1, b2, b3] = bytes;
|
|
940
|
+
if (b0 == null || b1 == null || b2 == null || b3 == null) return null;
|
|
941
|
+
hextets.push(b0 << 8 | b1, b2 << 8 | b3);
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const value = Number.parseInt(part, 16);
|
|
945
|
+
if (!Number.isFinite(value) || value < 0 || value > 65535) return null;
|
|
946
|
+
hextets.push(value);
|
|
947
|
+
}
|
|
948
|
+
return hextets;
|
|
949
|
+
}
|
|
950
|
+
function deriveSubnet(ipAddress, ipv4Prefix = 24, ipv6Prefix = 64) {
|
|
951
|
+
const ipv4 = parseIpv4Address(ipAddress);
|
|
952
|
+
if (ipv4) {
|
|
953
|
+
if (ipv4.length !== 4) return {
|
|
954
|
+
key: "subnet:unknown",
|
|
955
|
+
version: "unknown"
|
|
956
|
+
};
|
|
957
|
+
const [o1, o2, o3] = ipv4;
|
|
958
|
+
if (o1 == null || o2 == null || o3 == null) return {
|
|
959
|
+
key: "subnet:unknown",
|
|
960
|
+
version: "unknown"
|
|
961
|
+
};
|
|
962
|
+
if (!Number.isInteger(ipv4Prefix) || ipv4Prefix < 0 || ipv4Prefix > 32) return {
|
|
963
|
+
key: "subnet:unknown",
|
|
964
|
+
version: "unknown"
|
|
965
|
+
};
|
|
966
|
+
if (ipv4Prefix !== 24) {
|
|
967
|
+
const network = (o1 << 24 | o2 << 16 | o3 << 8 | (ipv4[3] ?? 0)) >>> 0 & (ipv4Prefix === 0 ? 0 : 4294967295 << 32 - ipv4Prefix >>> 0);
|
|
968
|
+
return {
|
|
969
|
+
key: `subnet:ip4:${network >>> 24 & 255}.${network >>> 16 & 255}.${network >>> 8 & 255}.${network & 255}/${ipv4Prefix}`,
|
|
970
|
+
version: "ipv4"
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
key: `subnet:ip24:${o1}.${o2}.${o3}.0/24`,
|
|
975
|
+
version: "ipv4"
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
const ipv6 = parseIpv6Hextets(ipAddress);
|
|
979
|
+
if (ipv6 && ipv6.length === 8) {
|
|
980
|
+
const h1 = ipv6[0];
|
|
981
|
+
const h2 = ipv6[1];
|
|
982
|
+
const h3 = ipv6[2];
|
|
983
|
+
const h4 = ipv6[3];
|
|
984
|
+
if (!Number.isInteger(ipv6Prefix) || ipv6Prefix < 0 || ipv6Prefix > 128) return {
|
|
985
|
+
key: "subnet:unknown",
|
|
986
|
+
version: "unknown"
|
|
987
|
+
};
|
|
988
|
+
if (ipv6Prefix !== 64) return {
|
|
989
|
+
key: `subnet:ip6:${maskIpv6(ipv6, ipv6Prefix).map((value) => value.toString(16)).join(":")}/${ipv6Prefix}`,
|
|
990
|
+
version: "ipv6"
|
|
991
|
+
};
|
|
992
|
+
return {
|
|
993
|
+
key: `subnet:ip64:${h1.toString(16)}:${h2.toString(16)}:${h3.toString(16)}:${h4.toString(16)}::/64`,
|
|
994
|
+
version: "ipv6"
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
key: "subnet:unknown",
|
|
999
|
+
version: "unknown"
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
function maskIpv6(hextets, prefix) {
|
|
1003
|
+
const masked = [];
|
|
1004
|
+
let remaining = prefix;
|
|
1005
|
+
for (const hextet of hextets) {
|
|
1006
|
+
if (remaining >= 16) {
|
|
1007
|
+
masked.push(hextet);
|
|
1008
|
+
remaining -= 16;
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (remaining <= 0) {
|
|
1012
|
+
masked.push(0);
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const mask = (65535 << 16 - remaining & 65535) >>> 0;
|
|
1016
|
+
masked.push(hextet & mask);
|
|
1017
|
+
remaining = 0;
|
|
1018
|
+
}
|
|
1019
|
+
return masked;
|
|
1020
|
+
}
|
|
1021
|
+
function defaultAsnToClass(asn, organization) {
|
|
1022
|
+
const override = ASN_OVERRIDES[asn];
|
|
1023
|
+
if (override) return override;
|
|
1024
|
+
const normalizedOrg = organization.toLowerCase();
|
|
1025
|
+
const entries = Object.entries(KEYWORDS);
|
|
1026
|
+
for (const [asnClass, keywords] of entries) {
|
|
1027
|
+
if (asnClass === "unknown") continue;
|
|
1028
|
+
if (keywords.some((keyword) => normalizedOrg.includes(keyword))) return asnClass;
|
|
1029
|
+
}
|
|
1030
|
+
return "unknown";
|
|
1031
|
+
}
|
|
1032
|
+
function normalizeAsnClass(asnClass) {
|
|
1033
|
+
if (!asnClass) return "unknown";
|
|
1034
|
+
return asnClass;
|
|
1035
|
+
}
|
|
1036
|
+
function normalizeCountryCode(code) {
|
|
1037
|
+
if (!code) return null;
|
|
1038
|
+
const trimmed = code.trim();
|
|
1039
|
+
if (!trimmed) return null;
|
|
1040
|
+
return trimmed.toUpperCase();
|
|
1041
|
+
}
|
|
1042
|
+
async function loadMaxmindModule() {
|
|
1043
|
+
try {
|
|
1044
|
+
return await import("maxmind");
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
throw new Error("maxmind is required for ASN or country lookups; install it as a peer dependency", { cause: err });
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function createGeoResolvers(options) {
|
|
1050
|
+
let maxmindModulePromise = null;
|
|
1051
|
+
let asnReaderPromise = null;
|
|
1052
|
+
let countryReaderPromise = null;
|
|
1053
|
+
const loadMaxmind = () => {
|
|
1054
|
+
if (!maxmindModulePromise) maxmindModulePromise = loadMaxmindModule();
|
|
1055
|
+
return maxmindModulePromise;
|
|
1056
|
+
};
|
|
1057
|
+
const loadAsnReader = async () => {
|
|
1058
|
+
if (!options.maxmindAsnDatabase) return null;
|
|
1059
|
+
if (!asnReaderPromise) asnReaderPromise = loadMaxmind().then((module) => module.open(options.maxmindAsnDatabase));
|
|
1060
|
+
return asnReaderPromise;
|
|
1061
|
+
};
|
|
1062
|
+
const loadCountryReader = async () => {
|
|
1063
|
+
if (!options.maxmindCountryDatabase) return null;
|
|
1064
|
+
if (!countryReaderPromise) countryReaderPromise = loadMaxmind().then((module) => module.open(options.maxmindCountryDatabase));
|
|
1065
|
+
return countryReaderPromise;
|
|
1066
|
+
};
|
|
1067
|
+
return {
|
|
1068
|
+
getAsn: options.getAsn ?? (async (_ctx, input) => {
|
|
1069
|
+
const reader = await loadAsnReader();
|
|
1070
|
+
if (!reader) return null;
|
|
1071
|
+
const record = reader.get(input.ipAddress);
|
|
1072
|
+
const asn = record?.autonomous_system_number ?? record?.autonomousSystemNumber;
|
|
1073
|
+
const organization = record?.autonomous_system_organization ?? record?.autonomousSystemOrganization ?? record?.organization;
|
|
1074
|
+
if (typeof asn !== "number" || !organization) return null;
|
|
1075
|
+
return {
|
|
1076
|
+
asn,
|
|
1077
|
+
organization: String(organization)
|
|
1078
|
+
};
|
|
1079
|
+
}),
|
|
1080
|
+
getCountry: options.getCountryCode ?? (async (_ctx, input) => {
|
|
1081
|
+
const reader = await loadCountryReader();
|
|
1082
|
+
if (!reader) return null;
|
|
1083
|
+
const record = reader.get(input.ipAddress);
|
|
1084
|
+
const code = record?.country?.iso_code ?? record?.country?.isoCode ?? record?.registered_country?.iso_code ?? record?.registeredCountry?.isoCode ?? record?.represented_country?.iso_code ?? record?.representedCountry?.isoCode;
|
|
1085
|
+
return normalizeCountryCode(typeof code === "string" ? code : null);
|
|
1086
|
+
})
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
function formatRetryAfterSeconds(resetAtMs, nowMs) {
|
|
1090
|
+
const seconds = Math.max(1, Math.ceil((resetAtMs - nowMs) / 1e3));
|
|
1091
|
+
return String(seconds);
|
|
1092
|
+
}
|
|
1093
|
+
function buildTooManyRequests(addRetryAfterHeader, resetAtMs, nowMs) {
|
|
1094
|
+
const response = text("Too Many Requests", { status: HttpStatus.TOO_MANY_REQUESTS });
|
|
1095
|
+
if (addRetryAfterHeader) response.headers.set("Retry-After", formatRetryAfterSeconds(resetAtMs, nowMs));
|
|
1096
|
+
return response;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Enforce per-identity, subnet, ASN, country, and endpoint rate limits using fixed-window buckets.
|
|
1100
|
+
*
|
|
1101
|
+
* @example
|
|
1102
|
+
* ```ts
|
|
1103
|
+
* router.use(rateLimit({
|
|
1104
|
+
* getUserId: async ({user}) => user?.id,
|
|
1105
|
+
* getGlobalPeakConcurrentUsers: async () => 5000,
|
|
1106
|
+
* baseWindowMs: 60_000,
|
|
1107
|
+
* baseMaxRequestsPerBaseWindow: 120,
|
|
1108
|
+
* anonymousIpMultiplier: 0.5,
|
|
1109
|
+
* addRetryAfterHeader: true,
|
|
1110
|
+
* buckets: [{windowMs: 60_000, scale: 1}],
|
|
1111
|
+
* endpointLimits: [{pattern: '/login', limit: {POST: 10}}],
|
|
1112
|
+
* includeQueryInEndpointKey: false,
|
|
1113
|
+
* scales: {subnet: {ipv4: 2, ipv6: 1}},
|
|
1114
|
+
* }))
|
|
1115
|
+
* ```
|
|
1116
|
+
*
|
|
1117
|
+
* @param options - Configuration for identity sources, buckets, scaling, and storage.
|
|
1118
|
+
* @returns Middleware that enforces rate limits and returns 429 responses when exceeded.
|
|
1119
|
+
*/
|
|
1120
|
+
function rateLimit(options) {
|
|
1121
|
+
if (!options.buckets.length) throw new Error("rateLimit requires at least one bucket");
|
|
1122
|
+
const storage = options.storage ?? new InMemoryRateLimitStorage(options.inMemory?.maxEntries ?? DEFAULT_MAX_ENTRIES);
|
|
1123
|
+
const getIpAddress = options.getIpAddress ?? defaultGetIpAddress;
|
|
1124
|
+
const normalizeQuery = options.normalizeQuery ?? normalizeQueryString;
|
|
1125
|
+
const endpointMatchers = options.endpointLimits.map((limit) => ({
|
|
1126
|
+
pattern: toURLPattern(limit.pattern),
|
|
1127
|
+
limit: limit.limit
|
|
1128
|
+
}));
|
|
1129
|
+
const asnToClass = options.asnToClass ?? defaultAsnToClass;
|
|
1130
|
+
const geoResolvers = createGeoResolvers(options);
|
|
1131
|
+
const asnLimitEnabled = Boolean(options.scales.asnClass && (options.getAsn || options.maxmindAsnDatabase));
|
|
1132
|
+
const countryLimitEnabled = Boolean(options.scales.country && (options.getCountryCode || options.maxmindCountryDatabase));
|
|
1133
|
+
const subnetAsnClassEnabled = Boolean(options.scales.subnet.byAsnClass && (options.getAsn || options.maxmindAsnDatabase));
|
|
1134
|
+
const retentionMs = options.storage ? 0 : options.inMemory?.ttlMs ?? 1e3;
|
|
1135
|
+
return defineMiddleware({
|
|
1136
|
+
responses: { [HttpStatus.TOO_MANY_REQUESTS]: {
|
|
1137
|
+
schema: { type: "string" },
|
|
1138
|
+
parse(value) {
|
|
1139
|
+
if (typeof value !== "string") throw new TypeError("Rate limit responses must contain a string body.");
|
|
1140
|
+
return value;
|
|
1141
|
+
}
|
|
1142
|
+
} },
|
|
1143
|
+
async run(ctx, { next, forward, respond }) {
|
|
1144
|
+
const reject = (addRetryAfterHeader, resetAtMs, nowMs) => respond(buildTooManyRequests(addRetryAfterHeader, resetAtMs, nowMs));
|
|
1145
|
+
const nowMs = ctx.startTime ?? Date.now();
|
|
1146
|
+
const url = ctx.request.url;
|
|
1147
|
+
const method = ctx.request.method.toUpperCase();
|
|
1148
|
+
const userId = await options.getUserId(ctx);
|
|
1149
|
+
const ipAddress = cleanIpAddress(await getIpAddress(ctx));
|
|
1150
|
+
const identityKey = userId ? `identity:user:${userId}` : `identity:ip:${ipAddress}`;
|
|
1151
|
+
const identityMultiplier = userId ? 1 : options.anonymousIpMultiplier;
|
|
1152
|
+
const subnet = deriveSubnet(ipAddress, options.scales.subnet.ipv4Prefix ?? 24, options.scales.subnet.ipv6Prefix ?? 64);
|
|
1153
|
+
const subnetScaleBase = subnet.version === "ipv6" ? options.scales.subnet.ipv6 : subnet.version === "ipv4" ? options.scales.subnet.ipv4 : Math.min(options.scales.subnet.ipv4, options.scales.subnet.ipv6);
|
|
1154
|
+
let asnRecord = null;
|
|
1155
|
+
let asnClass = "unknown";
|
|
1156
|
+
if (asnLimitEnabled || subnetAsnClassEnabled) {
|
|
1157
|
+
asnRecord = await geoResolvers.getAsn(ctx, {
|
|
1158
|
+
userId,
|
|
1159
|
+
ipAddress
|
|
1160
|
+
});
|
|
1161
|
+
asnClass = normalizeAsnClass(asnRecord ? asnToClass(asnRecord.asn, asnRecord.organization) : "unknown");
|
|
1162
|
+
}
|
|
1163
|
+
const subnetMultiplier = subnetScaleBase * (options.scales.subnet.byAsnClass?.[asnClass] ?? 1);
|
|
1164
|
+
let countryCode = null;
|
|
1165
|
+
if (countryLimitEnabled) countryCode = normalizeCountryCode(await geoResolvers.getCountry(ctx, {
|
|
1166
|
+
userId,
|
|
1167
|
+
ipAddress
|
|
1168
|
+
}));
|
|
1169
|
+
const endpointBaseLimit = endpointMatchers.length ? resolveEndpointLimit(method, url, endpointMatchers) : null;
|
|
1170
|
+
const endpointKeyBase = buildEndpointKeyBase(url, method, options.includeQueryInEndpointKey, normalizeQuery);
|
|
1171
|
+
const endpointIdentityKey = endpointKeyBase ? `endpoint:${endpointKeyBase}:${identityKey}` : null;
|
|
1172
|
+
const endpointSubnetKey = endpointKeyBase ? `endpoint:${endpointKeyBase}:${subnet.key}` : null;
|
|
1173
|
+
const globalPeak = asnLimitEnabled || countryLimitEnabled ? await options.getGlobalPeakConcurrentUsers(ctx) : 1;
|
|
1174
|
+
for (const bucket of options.buckets) {
|
|
1175
|
+
const bucketMax = getBucketMax(options.baseMaxRequestsPerBaseWindow, options.baseWindowMs, bucket);
|
|
1176
|
+
const bucketSuffix = `:w${bucket.windowMs}`;
|
|
1177
|
+
const identityMax = Math.floor(bucketMax * identityMultiplier);
|
|
1178
|
+
const identityResult = await applyFixedWindowLimit(ctx, storage, `${identityKey}${bucketSuffix}`, bucket.windowMs, identityMax, nowMs, retentionMs);
|
|
1179
|
+
if (!identityResult.allowed) return reject(options.addRetryAfterHeader, identityResult.resetAtMs, nowMs);
|
|
1180
|
+
const subnetMax = Math.floor(bucketMax * subnetMultiplier);
|
|
1181
|
+
const subnetResult = await applyFixedWindowLimit(ctx, storage, `${subnet.key}${bucketSuffix}`, bucket.windowMs, subnetMax, nowMs, retentionMs);
|
|
1182
|
+
if (!subnetResult.allowed) return reject(options.addRetryAfterHeader, subnetResult.resetAtMs, nowMs);
|
|
1183
|
+
if (asnLimitEnabled && options.scales.asnClass) {
|
|
1184
|
+
const asnScale = options.scales.asnClass[asnClass] ?? options.scales.asnClass.unknown;
|
|
1185
|
+
const asnMax = Math.floor(bucketMax * asnScale * globalPeak);
|
|
1186
|
+
const asnResult = await applyFixedWindowLimit(ctx, storage, `${asnRecord ? `asn:${asnRecord.asn}` : "asn:unknown"}${bucketSuffix}`, bucket.windowMs, asnMax, nowMs, retentionMs);
|
|
1187
|
+
if (!asnResult.allowed) return reject(options.addRetryAfterHeader, asnResult.resetAtMs, nowMs);
|
|
1188
|
+
}
|
|
1189
|
+
if (countryLimitEnabled && options.scales.country) {
|
|
1190
|
+
const countryScale = countryCode ? options.scales.country[countryCode] ?? options.scales.country.other : options.scales.country.unknown;
|
|
1191
|
+
const countryMax = Math.floor(bucketMax * countryScale * globalPeak);
|
|
1192
|
+
const countryResult = await applyFixedWindowLimit(ctx, storage, `${countryCode ? `country:${countryCode}` : "country:unknown"}${bucketSuffix}`, bucket.windowMs, countryMax, nowMs, retentionMs);
|
|
1193
|
+
if (!countryResult.allowed) return reject(options.addRetryAfterHeader, countryResult.resetAtMs, nowMs);
|
|
1194
|
+
}
|
|
1195
|
+
if (endpointBaseLimit != null && endpointKeyBase && endpointIdentityKey && endpointSubnetKey) {
|
|
1196
|
+
const endpointBucketMax = getBucketMax(endpointBaseLimit, options.baseWindowMs, bucket);
|
|
1197
|
+
const endpointIdentityMax = Math.floor(endpointBucketMax * identityMultiplier);
|
|
1198
|
+
const endpointSubnetMax = Math.floor(endpointBucketMax * subnetMultiplier);
|
|
1199
|
+
const endpointIdentityResult = await applyFixedWindowLimit(ctx, storage, `${endpointIdentityKey}${bucketSuffix}`, bucket.windowMs, endpointIdentityMax, nowMs, retentionMs);
|
|
1200
|
+
if (!endpointIdentityResult.allowed) return reject(options.addRetryAfterHeader, endpointIdentityResult.resetAtMs, nowMs);
|
|
1201
|
+
const endpointSubnetResult = await applyFixedWindowLimit(ctx, storage, `${endpointSubnetKey}${bucketSuffix}`, bucket.windowMs, endpointSubnetMax, nowMs, retentionMs);
|
|
1202
|
+
if (!endpointSubnetResult.allowed) return reject(options.addRetryAfterHeader, endpointSubnetResult.resetAtMs, nowMs);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return forward(await next());
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
function buildEndpointKeyBase(url, method, includeQuery, normalizeQuery) {
|
|
1210
|
+
const pathname = url.pathname;
|
|
1211
|
+
const normalizedMethod = method.toUpperCase();
|
|
1212
|
+
if (!includeQuery) return `route:${normalizedMethod}:${pathname}`;
|
|
1213
|
+
const query = normalizeQuery(url);
|
|
1214
|
+
if (!query) return `routeq:${normalizedMethod}:${pathname}`;
|
|
1215
|
+
return `routeq:${normalizedMethod}:${pathname}?${query}`;
|
|
1216
|
+
}
|
|
1217
|
+
function ensureURLPattern() {
|
|
1218
|
+
if (typeof URLPattern === "undefined") throw new Error("URLPattern is not available in this runtime");
|
|
1219
|
+
return URLPattern;
|
|
1220
|
+
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
export { acceptCtx, bodyLimit, cors, defineMiddleware, formatRoutekitTerminalLogRecord, rateLimit, requestIdCtx, requestLogger, startTimeCtx, transformRoutekitJsonLogRecord };
|