0http-bun 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +505 -9
- package/common.d.ts +16 -0
- package/lib/middleware/README.md +402 -22
- package/lib/middleware/body-parser.js +410 -263
- package/lib/middleware/cors.js +57 -57
- package/lib/middleware/index.d.ts +93 -26
- package/lib/middleware/index.js +13 -0
- package/lib/middleware/jwt-auth.js +146 -36
- package/lib/middleware/logger.js +19 -4
- package/lib/middleware/prometheus.js +491 -0
- package/lib/middleware/rate-limit.js +77 -27
- package/lib/next.js +8 -1
- package/lib/router/sequential.js +41 -10
- package/package.json +9 -8
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Lazy load prom-client to improve startup performance
|
|
2
|
+
let promClient = null
|
|
3
|
+
function loadPromClient() {
|
|
4
|
+
if (!promClient) {
|
|
5
|
+
try {
|
|
6
|
+
promClient = require('prom-client')
|
|
7
|
+
} catch (error) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
'prom-client is required for Prometheus middleware. Install it with: bun install prom-client',
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return promClient
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Security: Limit label cardinality
|
|
17
|
+
const MAX_LABEL_VALUE_LENGTH = 100
|
|
18
|
+
const MAX_ROUTE_SEGMENTS = 10
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sanitize label values to prevent high cardinality
|
|
22
|
+
*/
|
|
23
|
+
function sanitizeLabelValue(value) {
|
|
24
|
+
if (typeof value !== 'string') {
|
|
25
|
+
value = String(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Truncate long values
|
|
29
|
+
if (value.length > MAX_LABEL_VALUE_LENGTH) {
|
|
30
|
+
value = value.substring(0, MAX_LABEL_VALUE_LENGTH)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Replace invalid characters
|
|
34
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate route pattern to prevent injection attacks
|
|
39
|
+
*/
|
|
40
|
+
function validateRoute(route) {
|
|
41
|
+
if (typeof route !== 'string' || route.length === 0) {
|
|
42
|
+
return '/unknown'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Limit route complexity
|
|
46
|
+
const segments = route.split('/').filter(Boolean)
|
|
47
|
+
if (segments.length > MAX_ROUTE_SEGMENTS) {
|
|
48
|
+
return '/' + segments.slice(0, MAX_ROUTE_SEGMENTS).join('/')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return sanitizeLabelValue(route)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Default Prometheus metrics for HTTP requests
|
|
56
|
+
*/
|
|
57
|
+
function createDefaultMetrics() {
|
|
58
|
+
const client = loadPromClient()
|
|
59
|
+
|
|
60
|
+
// HTTP request duration histogram
|
|
61
|
+
const httpRequestDuration = new client.Histogram({
|
|
62
|
+
name: 'http_request_duration_seconds',
|
|
63
|
+
help: 'Duration of HTTP requests in seconds',
|
|
64
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
65
|
+
buckets: [0.001, 0.005, 0.015, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 1, 2, 5, 10],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// HTTP request counter
|
|
69
|
+
const httpRequestTotal = new client.Counter({
|
|
70
|
+
name: 'http_requests_total',
|
|
71
|
+
help: 'Total number of HTTP requests',
|
|
72
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// HTTP request size histogram
|
|
76
|
+
const httpRequestSize = new client.Histogram({
|
|
77
|
+
name: 'http_request_size_bytes',
|
|
78
|
+
help: 'Size of HTTP requests in bytes',
|
|
79
|
+
labelNames: ['method', 'route'],
|
|
80
|
+
buckets: [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// HTTP response size histogram
|
|
84
|
+
const httpResponseSize = new client.Histogram({
|
|
85
|
+
name: 'http_response_size_bytes',
|
|
86
|
+
help: 'Size of HTTP responses in bytes',
|
|
87
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
88
|
+
buckets: [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000],
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Active HTTP connections gauge
|
|
92
|
+
const httpActiveConnections = new client.Gauge({
|
|
93
|
+
name: 'http_active_connections',
|
|
94
|
+
help: 'Number of active HTTP connections',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
httpRequestDuration,
|
|
99
|
+
httpRequestTotal,
|
|
100
|
+
httpRequestSize,
|
|
101
|
+
httpResponseSize,
|
|
102
|
+
httpActiveConnections,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extract route pattern from request
|
|
108
|
+
* This function attempts to extract a meaningful route pattern from the request
|
|
109
|
+
* for use in Prometheus metrics labels
|
|
110
|
+
*/
|
|
111
|
+
function extractRoutePattern(req) {
|
|
112
|
+
try {
|
|
113
|
+
// If route pattern is available from router context
|
|
114
|
+
if (req.ctx && req.ctx.route) {
|
|
115
|
+
return validateRoute(req.ctx.route)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// If params exist, try to reconstruct the pattern
|
|
119
|
+
if (req.params && Object.keys(req.params).length > 0) {
|
|
120
|
+
const url = new URL(req.url, 'http://localhost')
|
|
121
|
+
let pattern = url.pathname
|
|
122
|
+
|
|
123
|
+
// Replace parameter values with parameter names
|
|
124
|
+
Object.entries(req.params).forEach(([key, value]) => {
|
|
125
|
+
if (typeof key === 'string' && typeof value === 'string') {
|
|
126
|
+
const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
127
|
+
pattern = pattern.replace(
|
|
128
|
+
new RegExp(`/${escapedValue}(?=/|$)`),
|
|
129
|
+
`/:${sanitizeLabelValue(key)}`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return validateRoute(pattern)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Try to normalize common patterns
|
|
138
|
+
const url = new URL(req.url, 'http://localhost')
|
|
139
|
+
let pathname = url.pathname
|
|
140
|
+
|
|
141
|
+
// Replace UUIDs, numbers, and other common ID patterns
|
|
142
|
+
pathname = pathname
|
|
143
|
+
.replace(
|
|
144
|
+
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
|
145
|
+
'/:id',
|
|
146
|
+
)
|
|
147
|
+
.replace(/\/\d+/g, '/:id')
|
|
148
|
+
.replace(/\/[a-zA-Z0-9_-]{20,}/g, '/:token')
|
|
149
|
+
|
|
150
|
+
return validateRoute(pathname)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// Fallback for malformed URLs
|
|
153
|
+
return '/unknown'
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get request size in bytes - optimized for performance
|
|
159
|
+
*/
|
|
160
|
+
function getRequestSize(req) {
|
|
161
|
+
try {
|
|
162
|
+
const contentLength = req.headers.get('content-length')
|
|
163
|
+
if (contentLength) {
|
|
164
|
+
const size = parseInt(contentLength, 10)
|
|
165
|
+
return size >= 0 && size <= 100 * 1024 * 1024 ? size : 0 // Max 100MB
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fast estimation based on headers only
|
|
169
|
+
let size = 0
|
|
170
|
+
const url = req.url || ''
|
|
171
|
+
size += req.method.length + url.length + 12 // HTTP/1.1 + spaces
|
|
172
|
+
|
|
173
|
+
// Quick header size estimation
|
|
174
|
+
if (req.headers && typeof req.headers.forEach === 'function') {
|
|
175
|
+
let headerCount = 0
|
|
176
|
+
req.headers.forEach((value, key) => {
|
|
177
|
+
if (headerCount < 50) {
|
|
178
|
+
// Limit header processing for performance
|
|
179
|
+
size += key.length + value.length + 4 // ": " + "\r\n"
|
|
180
|
+
headerCount++
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return Math.min(size, 1024 * 1024) // Cap at 1MB for estimation
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return 0
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get response size in bytes - optimized for performance
|
|
193
|
+
*/
|
|
194
|
+
function getResponseSize(response) {
|
|
195
|
+
try {
|
|
196
|
+
// Check content-length header first (fastest)
|
|
197
|
+
const contentLength = response.headers?.get('content-length')
|
|
198
|
+
if (contentLength) {
|
|
199
|
+
const size = parseInt(contentLength, 10)
|
|
200
|
+
return size >= 0 && size <= 100 * 1024 * 1024 ? size : 0 // Max 100MB
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Try to estimate from response body if available
|
|
204
|
+
if (
|
|
205
|
+
response._bodyForLogger &&
|
|
206
|
+
typeof response._bodyForLogger === 'string'
|
|
207
|
+
) {
|
|
208
|
+
return Math.min(
|
|
209
|
+
Buffer.byteLength(response._bodyForLogger, 'utf8'),
|
|
210
|
+
1024 * 1024,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Fast estimation for headers only
|
|
215
|
+
let size = 15 // "HTTP/1.1 200 OK\r\n"
|
|
216
|
+
|
|
217
|
+
if (response.headers && typeof response.headers.forEach === 'function') {
|
|
218
|
+
let headerCount = 0
|
|
219
|
+
response.headers.forEach((value, key) => {
|
|
220
|
+
if (headerCount < 20) {
|
|
221
|
+
// Limit for performance
|
|
222
|
+
size += key.length + value.length + 4 // ": " + "\r\n"
|
|
223
|
+
headerCount++
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return Math.min(size, 1024) // Cap header estimation at 1KB
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return 0
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Creates a Prometheus metrics middleware
|
|
236
|
+
* @param {Object} options - Prometheus middleware configuration
|
|
237
|
+
* @param {Object} options.metrics - Custom metrics object (optional)
|
|
238
|
+
* @param {Array<string>} options.excludePaths - Paths to exclude from metrics
|
|
239
|
+
* @param {boolean} options.collectDefaultMetrics - Whether to collect default Node.js metrics
|
|
240
|
+
* @param {Function} options.normalizeRoute - Custom route normalization function
|
|
241
|
+
* @param {Function} options.extractLabels - Custom label extraction function
|
|
242
|
+
* @param {Array<string>} options.skipMethods - HTTP methods to skip from metrics
|
|
243
|
+
* @returns {Function} Middleware function
|
|
244
|
+
*/
|
|
245
|
+
function createPrometheusMiddleware(options = {}) {
|
|
246
|
+
const {
|
|
247
|
+
metrics: customMetrics,
|
|
248
|
+
excludePaths = ['/health', '/ping', '/favicon.ico', '/metrics'],
|
|
249
|
+
collectDefaultMetrics = true,
|
|
250
|
+
normalizeRoute = extractRoutePattern,
|
|
251
|
+
extractLabels,
|
|
252
|
+
skipMethods = [],
|
|
253
|
+
} = options
|
|
254
|
+
|
|
255
|
+
// Collect default Node.js metrics
|
|
256
|
+
if (collectDefaultMetrics) {
|
|
257
|
+
const client = loadPromClient()
|
|
258
|
+
client.collectDefaultMetrics({
|
|
259
|
+
timeout: 5000,
|
|
260
|
+
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
|
|
261
|
+
eventLoopMonitoringPrecision: 5,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Use custom metrics or create default ones
|
|
266
|
+
const metrics = customMetrics || createDefaultMetrics()
|
|
267
|
+
|
|
268
|
+
return async function prometheusMiddleware(req, next) {
|
|
269
|
+
const startHrTime = process.hrtime()
|
|
270
|
+
|
|
271
|
+
// Skip metrics collection for excluded paths (performance optimization)
|
|
272
|
+
const url = req.url || ''
|
|
273
|
+
let pathname
|
|
274
|
+
try {
|
|
275
|
+
// Handle both full URLs and pathname-only URLs
|
|
276
|
+
if (url.startsWith('http')) {
|
|
277
|
+
pathname = new URL(url).pathname
|
|
278
|
+
} else {
|
|
279
|
+
pathname = url.split('?')[0] // Fast pathname extraction
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
pathname = url.split('?')[0] // Fallback to simple splitting
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (excludePaths.some((path) => pathname.startsWith(path))) {
|
|
286
|
+
return next()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Skip metrics collection for specified methods
|
|
290
|
+
const method = req.method?.toUpperCase() || 'GET'
|
|
291
|
+
if (skipMethods.includes(method)) {
|
|
292
|
+
return next()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Increment active connections
|
|
296
|
+
if (metrics.httpActiveConnections) {
|
|
297
|
+
metrics.httpActiveConnections.inc()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// Get request size (lazy evaluation)
|
|
302
|
+
let requestSize = 0
|
|
303
|
+
|
|
304
|
+
// Execute the request
|
|
305
|
+
const response = await next()
|
|
306
|
+
|
|
307
|
+
// Calculate duration (high precision)
|
|
308
|
+
const duration = process.hrtime(startHrTime)
|
|
309
|
+
const durationInSeconds = duration[0] + duration[1] * 1e-9
|
|
310
|
+
|
|
311
|
+
// Extract route pattern (cached/optimized)
|
|
312
|
+
const route = normalizeRoute(req)
|
|
313
|
+
const statusCode = sanitizeLabelValue(
|
|
314
|
+
response?.status?.toString() || 'unknown',
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
// Create base labels with sanitized values
|
|
318
|
+
let labels = {
|
|
319
|
+
method: sanitizeLabelValue(method),
|
|
320
|
+
route: route,
|
|
321
|
+
status_code: statusCode,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Add custom labels if extractor provided
|
|
325
|
+
if (extractLabels && typeof extractLabels === 'function') {
|
|
326
|
+
try {
|
|
327
|
+
const customLabels = extractLabels(req, response)
|
|
328
|
+
if (customLabels && typeof customLabels === 'object') {
|
|
329
|
+
// Sanitize custom labels
|
|
330
|
+
Object.entries(customLabels).forEach(([key, value]) => {
|
|
331
|
+
if (typeof key === 'string' && key.length <= 50) {
|
|
332
|
+
labels[sanitizeLabelValue(key)] = sanitizeLabelValue(
|
|
333
|
+
String(value),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
// Ignore custom label extraction errors
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Record metrics efficiently
|
|
344
|
+
if (metrics.httpRequestDuration) {
|
|
345
|
+
metrics.httpRequestDuration.observe(
|
|
346
|
+
{
|
|
347
|
+
method: labels.method,
|
|
348
|
+
route: labels.route,
|
|
349
|
+
status_code: labels.status_code,
|
|
350
|
+
},
|
|
351
|
+
durationInSeconds,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (metrics.httpRequestTotal) {
|
|
356
|
+
metrics.httpRequestTotal.inc({
|
|
357
|
+
method: labels.method,
|
|
358
|
+
route: labels.route,
|
|
359
|
+
status_code: labels.status_code,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (metrics.httpRequestSize) {
|
|
364
|
+
requestSize = getRequestSize(req)
|
|
365
|
+
if (requestSize > 0) {
|
|
366
|
+
metrics.httpRequestSize.observe(
|
|
367
|
+
{method: labels.method, route: labels.route},
|
|
368
|
+
requestSize,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (metrics.httpResponseSize) {
|
|
374
|
+
const responseSize = getResponseSize(response)
|
|
375
|
+
if (responseSize > 0) {
|
|
376
|
+
metrics.httpResponseSize.observe(
|
|
377
|
+
{
|
|
378
|
+
method: labels.method,
|
|
379
|
+
route: labels.route,
|
|
380
|
+
status_code: labels.status_code,
|
|
381
|
+
},
|
|
382
|
+
responseSize,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return response
|
|
388
|
+
} catch (error) {
|
|
389
|
+
// Record error metrics
|
|
390
|
+
const duration = process.hrtime(startHrTime)
|
|
391
|
+
const durationInSeconds = duration[0] + duration[1] * 1e-9
|
|
392
|
+
const route = normalizeRoute(req)
|
|
393
|
+
const sanitizedMethod = sanitizeLabelValue(method)
|
|
394
|
+
|
|
395
|
+
if (metrics.httpRequestDuration) {
|
|
396
|
+
metrics.httpRequestDuration.observe(
|
|
397
|
+
{method: sanitizedMethod, route: route, status_code: '500'},
|
|
398
|
+
durationInSeconds,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (metrics.httpRequestTotal) {
|
|
403
|
+
metrics.httpRequestTotal.inc({
|
|
404
|
+
method: sanitizedMethod,
|
|
405
|
+
route: route,
|
|
406
|
+
status_code: '500',
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
throw error
|
|
411
|
+
} finally {
|
|
412
|
+
// Decrement active connections
|
|
413
|
+
if (metrics.httpActiveConnections) {
|
|
414
|
+
metrics.httpActiveConnections.dec()
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Creates a metrics endpoint handler that serves Prometheus metrics
|
|
422
|
+
* @param {Object} options - Metrics endpoint configuration
|
|
423
|
+
* @param {string} options.endpoint - The endpoint path (default: '/metrics')
|
|
424
|
+
* @param {Object} options.registry - Custom Prometheus registry
|
|
425
|
+
* @returns {Function} Request handler function
|
|
426
|
+
*/
|
|
427
|
+
function createMetricsHandler(options = {}) {
|
|
428
|
+
const client = loadPromClient()
|
|
429
|
+
const {endpoint = '/metrics', registry = client.register} = options
|
|
430
|
+
|
|
431
|
+
return async function metricsHandler(req) {
|
|
432
|
+
const url = new URL(req.url, 'http://localhost')
|
|
433
|
+
|
|
434
|
+
if (url.pathname === endpoint) {
|
|
435
|
+
try {
|
|
436
|
+
const metrics = await registry.metrics()
|
|
437
|
+
return new Response(metrics, {
|
|
438
|
+
status: 200,
|
|
439
|
+
headers: {
|
|
440
|
+
'Content-Type': registry.contentType,
|
|
441
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
442
|
+
Pragma: 'no-cache',
|
|
443
|
+
Expires: '0',
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return new Response('Error collecting metrics', {
|
|
448
|
+
status: 500,
|
|
449
|
+
headers: {'Content-Type': 'text/plain'},
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return null // Let other middleware handle the request
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Simple helper to create both middleware and metrics endpoint
|
|
460
|
+
* @param {Object} options - Combined configuration options
|
|
461
|
+
* @returns {Object} Object containing middleware and handler functions
|
|
462
|
+
*/
|
|
463
|
+
function createPrometheusIntegration(options = {}) {
|
|
464
|
+
const middleware = createPrometheusMiddleware(options)
|
|
465
|
+
const metricsHandler = createMetricsHandler(options)
|
|
466
|
+
const client = loadPromClient()
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
middleware,
|
|
470
|
+
metricsHandler,
|
|
471
|
+
// Expose the registry for custom metrics
|
|
472
|
+
registry: client.register,
|
|
473
|
+
// Expose prom-client for creating custom metrics
|
|
474
|
+
promClient: client,
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
createPrometheusMiddleware,
|
|
480
|
+
createMetricsHandler,
|
|
481
|
+
createPrometheusIntegration,
|
|
482
|
+
createDefaultMetrics,
|
|
483
|
+
extractRoutePattern,
|
|
484
|
+
// Export lazy loader functions to maintain compatibility
|
|
485
|
+
get promClient() {
|
|
486
|
+
return loadPromClient()
|
|
487
|
+
},
|
|
488
|
+
get register() {
|
|
489
|
+
return loadPromClient().register
|
|
490
|
+
},
|
|
491
|
+
}
|
|
@@ -8,7 +8,7 @@ class MemoryStore {
|
|
|
8
8
|
this.resetTimes = new Map()
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
increment(key, windowMs) {
|
|
12
12
|
const now = Date.now()
|
|
13
13
|
const windowStart = Math.floor(now / windowMs) * windowMs
|
|
14
14
|
const storeKey = `${key}:${windowStart}`
|
|
@@ -82,27 +82,40 @@ function createRateLimit(options = {}) {
|
|
|
82
82
|
* @returns {Response} Response with headers added
|
|
83
83
|
*/
|
|
84
84
|
const addRateLimitHeaders = (response, totalHits, resetTime) => {
|
|
85
|
-
if (standardHeaders
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
response.headers.set('X-RateLimit-Used', totalHits.toString())
|
|
85
|
+
if (!standardHeaders || !response?.headers) return response
|
|
86
|
+
|
|
87
|
+
if (standardHeaders === 'minimal') {
|
|
88
|
+
// L-6: Only add Retry-After on 429 responses to avoid disclosing config
|
|
89
|
+
if (response.status === 429) {
|
|
90
|
+
const retryAfter = Math.ceil((resetTime.getTime() - Date.now()) / 1000)
|
|
91
|
+
response.headers.set('Retry-After', Math.max(0, retryAfter).toString())
|
|
92
|
+
}
|
|
93
|
+
return response
|
|
96
94
|
}
|
|
95
|
+
|
|
96
|
+
// Full headers (standardHeaders === true)
|
|
97
|
+
response.headers.set('X-RateLimit-Limit', max.toString())
|
|
98
|
+
response.headers.set(
|
|
99
|
+
'X-RateLimit-Remaining',
|
|
100
|
+
Math.max(0, max - totalHits).toString(),
|
|
101
|
+
)
|
|
102
|
+
response.headers.set(
|
|
103
|
+
'X-RateLimit-Reset',
|
|
104
|
+
Math.ceil(resetTime.getTime() / 1000).toString(),
|
|
105
|
+
)
|
|
106
|
+
response.headers.set('X-RateLimit-Used', totalHits.toString())
|
|
97
107
|
return response
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
return async function rateLimitMiddleware(req, next) {
|
|
101
|
-
|
|
102
|
-
const activeStore = req && req.rateLimitStore ? req.rateLimitStore : store
|
|
111
|
+
const activeStore = store
|
|
103
112
|
|
|
104
113
|
const url = new URL(req.url)
|
|
105
|
-
if (
|
|
114
|
+
if (
|
|
115
|
+
excludePaths.some(
|
|
116
|
+
(path) => url.pathname === path || url.pathname.startsWith(path + '/'),
|
|
117
|
+
)
|
|
118
|
+
) {
|
|
106
119
|
return next()
|
|
107
120
|
}
|
|
108
121
|
|
|
@@ -159,22 +172,30 @@ function createRateLimit(options = {}) {
|
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
/**
|
|
162
|
-
* Default key generator - uses IP address
|
|
175
|
+
* Default key generator - uses connection-level IP address
|
|
176
|
+
* Checks req.ip, req.remoteAddress, and req.socket?.remoteAddress in order.
|
|
177
|
+
* NOTE: If behind a reverse proxy, provide a custom keyGenerator that
|
|
178
|
+
* reads the appropriate header after configuring your proxy to set it.
|
|
163
179
|
* @param {Request} req - Request object
|
|
164
180
|
* @returns {string} Rate limit key
|
|
165
181
|
*/
|
|
182
|
+
let _unknownKeyWarned = false
|
|
183
|
+
|
|
166
184
|
function defaultKeyGenerator(req) {
|
|
167
|
-
//
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
// Use connection-level IP if available (set by Bun's server or upstream middleware)
|
|
186
|
+
const ip = req.ip || req.remoteAddress || req.socket?.remoteAddress
|
|
187
|
+
if (ip) return ip
|
|
188
|
+
|
|
189
|
+
// I-1: Generate unique key per request to avoid shared bucket DoS
|
|
190
|
+
if (!_unknownKeyWarned) {
|
|
191
|
+
console.warn(
|
|
192
|
+
'[0http-bun] SECURITY WARNING: Rate limiter cannot determine client IP. ' +
|
|
193
|
+
'Each request without an IP gets a unique key (no shared bucket). ' +
|
|
194
|
+
'Configure a custom keyGenerator for proper rate limiting behind proxies.',
|
|
195
|
+
)
|
|
196
|
+
_unknownKeyWarned = true
|
|
197
|
+
}
|
|
198
|
+
return `unknown:${Date.now()}:${Math.random().toString(36).slice(2)}`
|
|
178
199
|
}
|
|
179
200
|
|
|
180
201
|
/**
|
|
@@ -216,10 +237,32 @@ function createSlidingWindowRateLimit(options = {}) {
|
|
|
216
237
|
max = 100,
|
|
217
238
|
keyGenerator = defaultKeyGenerator,
|
|
218
239
|
handler = defaultHandler,
|
|
240
|
+
maxKeys = 10000,
|
|
219
241
|
} = options
|
|
220
242
|
|
|
221
243
|
const requests = new Map() // key -> array of timestamps
|
|
222
244
|
|
|
245
|
+
// Periodic cleanup of stale entries
|
|
246
|
+
const cleanupInterval = setInterval(
|
|
247
|
+
() => {
|
|
248
|
+
const now = Date.now()
|
|
249
|
+
for (const [key, timestamps] of requests.entries()) {
|
|
250
|
+
const filtered = timestamps.filter((ts) => now - ts < windowMs)
|
|
251
|
+
if (filtered.length === 0) {
|
|
252
|
+
requests.delete(key)
|
|
253
|
+
} else {
|
|
254
|
+
requests.set(key, filtered)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
Math.min(windowMs, 60000),
|
|
259
|
+
) // Clean up at most every minute
|
|
260
|
+
|
|
261
|
+
// Allow garbage collection if reference is lost
|
|
262
|
+
if (cleanupInterval.unref) {
|
|
263
|
+
cleanupInterval.unref()
|
|
264
|
+
}
|
|
265
|
+
|
|
223
266
|
return async function slidingWindowRateLimitMiddleware(req, next) {
|
|
224
267
|
// Generate key and record the request immediately - let errors bubble up
|
|
225
268
|
const key = await keyGenerator(req)
|
|
@@ -248,6 +291,13 @@ function createSlidingWindowRateLimit(options = {}) {
|
|
|
248
291
|
userRequests.push(now)
|
|
249
292
|
requests.set(key, userRequests)
|
|
250
293
|
|
|
294
|
+
// Enforce max keys to prevent unbounded memory growth
|
|
295
|
+
if (requests.size > maxKeys) {
|
|
296
|
+
// Remove oldest entry
|
|
297
|
+
const firstKey = requests.keys().next().value
|
|
298
|
+
requests.delete(firstKey)
|
|
299
|
+
}
|
|
300
|
+
|
|
251
301
|
// Add rate limit info to context
|
|
252
302
|
req.ctx = req.ctx || {}
|
|
253
303
|
req.ctx.rateLimit = {
|
package/lib/next.js
CHANGED
|
@@ -15,12 +15,19 @@ module.exports = function next(
|
|
|
15
15
|
const nextIndex = index + 1
|
|
16
16
|
|
|
17
17
|
try {
|
|
18
|
-
|
|
18
|
+
const result = middleware(req, (err) => {
|
|
19
19
|
if (err) {
|
|
20
20
|
return errorHandler(err, req)
|
|
21
21
|
}
|
|
22
22
|
return next(middlewares, req, nextIndex, defaultRoute, errorHandler)
|
|
23
23
|
})
|
|
24
|
+
|
|
25
|
+
// Catch rejected promises from async middleware
|
|
26
|
+
if (result && typeof result.catch === 'function') {
|
|
27
|
+
return result.catch((err) => errorHandler(err, req))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return result
|
|
24
31
|
} catch (err) {
|
|
25
32
|
return errorHandler(err, req)
|
|
26
33
|
}
|