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.
@@ -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
- async increment(key, windowMs) {
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 && response && response.headers) {
86
- response.headers.set('X-RateLimit-Limit', max.toString())
87
- response.headers.set(
88
- 'X-RateLimit-Remaining',
89
- Math.max(0, max - totalHits).toString(),
90
- )
91
- response.headers.set(
92
- 'X-RateLimit-Reset',
93
- Math.ceil(resetTime.getTime() / 1000).toString(),
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
- // Allow test to inject a fresh store for isolation
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 (excludePaths.some((path) => url.pathname.startsWith(path))) {
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
- // Try to get real IP from common headers
168
- const forwarded = req.headers.get('x-forwarded-for')
169
- const realIp = req.headers.get('x-real-ip')
170
- const cfConnectingIp = req.headers.get('cf-connecting-ip')
171
-
172
- return (
173
- cfConnectingIp ||
174
- realIp ||
175
- (forwarded && forwarded.split(',')[0].trim()) ||
176
- 'unknown'
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
- return middleware(req, (err) => {
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
  }