@coherent.js/api 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/api/errors.d.ts +92 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +161 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +61 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +41 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/middleware.d.ts +57 -0
- package/dist/api/middleware.d.ts.map +1 -0
- package/dist/api/middleware.js +244 -0
- package/dist/api/middleware.js.map +1 -0
- package/dist/api/openapi.d.ts +54 -0
- package/dist/api/openapi.d.ts.map +1 -0
- package/dist/api/openapi.js +144 -0
- package/dist/api/openapi.js.map +1 -0
- package/dist/api/router.d.ts +368 -0
- package/dist/api/router.d.ts.map +1 -0
- package/dist/api/router.js +1508 -0
- package/dist/api/router.js.map +1 -0
- package/dist/api/security.d.ts +64 -0
- package/dist/api/security.d.ts.map +1 -0
- package/dist/api/security.js +239 -0
- package/dist/api/security.js.map +1 -0
- package/dist/api/serialization.d.ts +86 -0
- package/dist/api/serialization.d.ts.map +1 -0
- package/dist/api/serialization.js +151 -0
- package/dist/api/serialization.js.map +1 -0
- package/dist/api/validation.d.ts +34 -0
- package/dist/api/validation.d.ts.map +1 -0
- package/dist/api/validation.js +172 -0
- package/dist/api/validation.js.map +1 -0
- package/dist/index.cjs +1776 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.js +1722 -0
- package/dist/index.js.map +7 -0
- package/package.json +46 -0
- package/types/index.d.ts +720 -0
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object-based Router for Coherent.js API framework
|
|
3
|
+
* Pure object-oriented approach to backend API routing
|
|
4
|
+
*
|
|
5
|
+
* @fileoverview Transforms nested JavaScript objects into API routes with
|
|
6
|
+
* middleware, validation, and error handling support.
|
|
7
|
+
*
|
|
8
|
+
* @author Coherent.js Team
|
|
9
|
+
* @version 1.0.0
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { withValidation } from './validation.js';
|
|
13
|
+
import { withErrorHandling } from './errors.js';
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { parse as parseUrl } from 'node:url';
|
|
16
|
+
/**
|
|
17
|
+
* HTTP methods supported by the object router
|
|
18
|
+
* @private
|
|
19
|
+
*/
|
|
20
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
21
|
+
/**
|
|
22
|
+
* Parse JSON request body with security limits
|
|
23
|
+
* @private
|
|
24
|
+
*/
|
|
25
|
+
function parseBody(req, maxSize = 1024 * 1024) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
28
|
+
resolve({});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let body = '';
|
|
32
|
+
let size = 0;
|
|
33
|
+
req.on('data', chunk => {
|
|
34
|
+
size += chunk.length;
|
|
35
|
+
if (size > maxSize) {
|
|
36
|
+
reject(new Error('Request body too large'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
body += chunk.toString();
|
|
40
|
+
});
|
|
41
|
+
req.on('end', () => {
|
|
42
|
+
try {
|
|
43
|
+
const contentType = req.headers['content-type'] || '';
|
|
44
|
+
if (contentType.includes('application/json')) {
|
|
45
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
46
|
+
// Basic input sanitization
|
|
47
|
+
resolve(sanitizeInput(parsed));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
resolve({});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
reject(new Error('Invalid JSON body'));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
req.on('error', reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Basic input sanitization
|
|
62
|
+
* @private
|
|
63
|
+
*/
|
|
64
|
+
function sanitizeInput(obj) {
|
|
65
|
+
if (typeof obj !== 'object' || obj === null)
|
|
66
|
+
return obj;
|
|
67
|
+
const sanitized = {};
|
|
68
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
69
|
+
// Remove potentially dangerous keys
|
|
70
|
+
if (key.startsWith('__') || key.includes('prototype'))
|
|
71
|
+
continue;
|
|
72
|
+
if (typeof value === 'string') {
|
|
73
|
+
// Basic XSS prevention
|
|
74
|
+
sanitized[key] = value.replace(/<script[^>]*>.*?<\/script>/gi, '')
|
|
75
|
+
.replace(/javascript:/gi, '')
|
|
76
|
+
.replace(/on\w+=/gi, '');
|
|
77
|
+
}
|
|
78
|
+
else if (typeof value === 'object') {
|
|
79
|
+
sanitized[key] = sanitizeInput(value);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
sanitized[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sanitized;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Rate limiting store
|
|
89
|
+
* @private
|
|
90
|
+
*/
|
|
91
|
+
const rateLimitStore = new Map();
|
|
92
|
+
/**
|
|
93
|
+
* Rate limiting middleware for API endpoints
|
|
94
|
+
* @private
|
|
95
|
+
* @param {string} ip - Client IP address
|
|
96
|
+
* @param {number} [windowMs=60000] - Time window in milliseconds
|
|
97
|
+
* @param {number} [maxRequests=100] - Maximum requests per window
|
|
98
|
+
* @returns {boolean} True if request is allowed, false if rate limited
|
|
99
|
+
*/
|
|
100
|
+
function checkRateLimit(ip, windowMs = 60000, maxRequests = 100) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const key = ip;
|
|
103
|
+
if (!rateLimitStore.has(key)) {
|
|
104
|
+
rateLimitStore.set(key, { count: 1, resetTime: now + windowMs });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
const record = rateLimitStore.get(key);
|
|
108
|
+
if (now > record.resetTime) {
|
|
109
|
+
record.count = 1;
|
|
110
|
+
record.resetTime = now + windowMs;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (record.count >= maxRequests) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
record.count++;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Add comprehensive security headers to HTTP response
|
|
121
|
+
* @private
|
|
122
|
+
* @param {Object} res - HTTP response object
|
|
123
|
+
* @param {string|null} [corsOrigin=null] - CORS origin, defaults to localhost:3000
|
|
124
|
+
*/
|
|
125
|
+
function addSecurityHeaders(res, corsOrigin = null) {
|
|
126
|
+
// CORS headers - configurable origin
|
|
127
|
+
const origin = corsOrigin || 'http://localhost:3000';
|
|
128
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
129
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
130
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
131
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
132
|
+
// Security headers
|
|
133
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
134
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
135
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
136
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
137
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
|
|
138
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Extract parameters from URL path with constraint support
|
|
142
|
+
* @private
|
|
143
|
+
* @param {string} pattern - URL pattern with parameters (e.g., '/users/:id(\\d+)')
|
|
144
|
+
* @param {string} path - Actual URL path to match
|
|
145
|
+
* @returns {Object|null} Extracted parameters object or null if no match
|
|
146
|
+
* @example
|
|
147
|
+
* extractParams('/users/:id(\\d+)', '/users/123') // { id: '123' }
|
|
148
|
+
* extractParams('/users/:id?', '/users') // {}
|
|
149
|
+
*/
|
|
150
|
+
function extractParams(pattern, path) {
|
|
151
|
+
const patternParts = pattern.split('/');
|
|
152
|
+
const pathParts = path.split('/');
|
|
153
|
+
const params = {};
|
|
154
|
+
// Handle wildcard patterns that can match different lengths
|
|
155
|
+
const hasMultiWildcard = patternParts.includes('**');
|
|
156
|
+
const hasSingleWildcard = patternParts.includes('*');
|
|
157
|
+
if (!hasMultiWildcard && !hasSingleWildcard && patternParts.length !== pathParts.length) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
161
|
+
const patternPart = patternParts[i];
|
|
162
|
+
const pathPart = pathParts[i];
|
|
163
|
+
if (patternPart.startsWith(':')) {
|
|
164
|
+
// Parse parameter with optional constraint: :name(regex) or :name?
|
|
165
|
+
const match = patternPart.match(/^:([^(]+)(\(([^)]+)\))?(\?)?$/);
|
|
166
|
+
if (match) {
|
|
167
|
+
const [, paramName, , constraint, optional] = match;
|
|
168
|
+
// Check if parameter is optional and path part is missing
|
|
169
|
+
if (optional && !pathPart) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// Apply constraint if present
|
|
173
|
+
if (constraint) {
|
|
174
|
+
const regex = new RegExp(`^${constraint}$`);
|
|
175
|
+
if (!regex.test(pathPart)) {
|
|
176
|
+
return null; // Constraint failed
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
params[paramName] = pathPart;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Fallback to simple parameter
|
|
183
|
+
params[patternPart.slice(1)] = pathPart;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (patternPart === '*') {
|
|
187
|
+
// Single wildcard - matches one segment
|
|
188
|
+
params.splat = pathPart;
|
|
189
|
+
}
|
|
190
|
+
else if (patternPart === '**') {
|
|
191
|
+
// Multi-segment wildcard - matches remaining path
|
|
192
|
+
params.splat = pathParts.slice(i).join('/');
|
|
193
|
+
return params; // ** consumes rest of path
|
|
194
|
+
}
|
|
195
|
+
else if (patternPart !== pathPart) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return params;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Transforms nested route objects into registered API routes
|
|
203
|
+
*
|
|
204
|
+
* @param {Object} routeObj - Route definition object
|
|
205
|
+
* @param {Object} router - Router instance
|
|
206
|
+
* @param {string} basePath - Current path prefix
|
|
207
|
+
*/
|
|
208
|
+
function processRoutes(routeObj, router, basePath = '') {
|
|
209
|
+
if (!routeObj || typeof routeObj !== 'object')
|
|
210
|
+
return;
|
|
211
|
+
Object.entries(routeObj).forEach(([key, config]) => {
|
|
212
|
+
if (!config || typeof config !== 'object')
|
|
213
|
+
return;
|
|
214
|
+
// Check if this is a WebSocket route configuration
|
|
215
|
+
if (config.ws && typeof config.ws === 'function') {
|
|
216
|
+
// WebSocket route - register directly
|
|
217
|
+
const cleanKey = key.startsWith('/') ? key.slice(1) : key;
|
|
218
|
+
const wsPath = basePath ? `${basePath}/${cleanKey}` : `/${cleanKey}`;
|
|
219
|
+
router.addWebSocketRoute(wsPath, config.ws);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (HTTP_METHODS.includes(key.toUpperCase())) {
|
|
223
|
+
// HTTP method route
|
|
224
|
+
registerRoute(key.toUpperCase(), config, router, basePath);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// Nested path - handle leading slash correctly
|
|
228
|
+
const cleanKey = key.startsWith('/') ? key.slice(1) : key;
|
|
229
|
+
const path = basePath ? `${basePath}/${cleanKey}` : `/${cleanKey}`;
|
|
230
|
+
processRoutes(config, router, path);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Registers a single route with middleware chain
|
|
236
|
+
*
|
|
237
|
+
* @param {string} method - HTTP method
|
|
238
|
+
* @param {Object} config - Route configuration
|
|
239
|
+
* @param {Object} router - Router instance
|
|
240
|
+
* @param {string} path - Route path
|
|
241
|
+
*/
|
|
242
|
+
function registerRoute(method, config, router, path) {
|
|
243
|
+
const { handler, handlers, validation, middleware, errorHandling = true, path: customPath, name } = config;
|
|
244
|
+
const routePath = customPath || path || '/';
|
|
245
|
+
const chain = [];
|
|
246
|
+
// Add middleware
|
|
247
|
+
if (middleware) {
|
|
248
|
+
chain.push(...(Array.isArray(middleware) ? middleware : [middleware]));
|
|
249
|
+
}
|
|
250
|
+
// Add validation
|
|
251
|
+
if (validation) {
|
|
252
|
+
chain.push(withValidation(validation));
|
|
253
|
+
}
|
|
254
|
+
// Add handlers
|
|
255
|
+
if (handlers) {
|
|
256
|
+
chain.push(...handlers);
|
|
257
|
+
}
|
|
258
|
+
else if (handler) {
|
|
259
|
+
chain.push(handler);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
console.warn(`No handler for ${method} ${routePath}`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Apply error handling
|
|
266
|
+
if (errorHandling) {
|
|
267
|
+
chain.forEach((fn, i) => {
|
|
268
|
+
chain[i] = withErrorHandling(fn);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// Register route with name option
|
|
272
|
+
router.addRoute(method, routePath, async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
// Execute middleware and handler chain
|
|
275
|
+
let result = null;
|
|
276
|
+
for (const fn of chain) {
|
|
277
|
+
result = await fn(req, res);
|
|
278
|
+
if (result && typeof result === 'object') {
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (result && typeof result === 'object') {
|
|
283
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
284
|
+
res.end(JSON.stringify(result));
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
res.writeHead(204);
|
|
288
|
+
res.end();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
const statusCode = error.statusCode || 500;
|
|
293
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
295
|
+
}
|
|
296
|
+
}, { name });
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Simple router implementation for object-based routing with WebSocket support
|
|
300
|
+
*
|
|
301
|
+
* @class SimpleRouter
|
|
302
|
+
* @description Provides HTTP and WebSocket routing with middleware support, caching,
|
|
303
|
+
* versioning, content negotiation, and performance metrics.
|
|
304
|
+
*
|
|
305
|
+
* @param {Object} [options={}] - Router configuration options
|
|
306
|
+
* @param {number} [options.maxCacheSize=1000] - Maximum cache size for route lookups
|
|
307
|
+
* @param {boolean} [options.enableCompilation=true] - Enable route pattern compilation
|
|
308
|
+
* @param {boolean} [options.enableVersioning=false] - Enable API versioning
|
|
309
|
+
* @param {string} [options.defaultVersion='v1'] - Default API version
|
|
310
|
+
* @param {string} [options.versionHeader='api-version'] - Header for version detection
|
|
311
|
+
* @param {boolean} [options.enableContentNegotiation=true] - Enable content type negotiation
|
|
312
|
+
* @param {string} [options.defaultContentType='application/json'] - Default response content type
|
|
313
|
+
* @param {boolean} [options.enableWebSockets=false] - Enable WebSocket routing
|
|
314
|
+
* @param {boolean} [options.enableMetrics=false] - Enable performance metrics collection
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* const router = new SimpleRouter({
|
|
318
|
+
* enableWebSockets: true,
|
|
319
|
+
* enableMetrics: true,
|
|
320
|
+
* maxCacheSize: 2000
|
|
321
|
+
* });
|
|
322
|
+
*/
|
|
323
|
+
class SimpleRouter {
|
|
324
|
+
constructor(options = {}) {
|
|
325
|
+
this.routes = [];
|
|
326
|
+
this.routeCache = new Map();
|
|
327
|
+
this.namedRoutes = new Map();
|
|
328
|
+
this.maxCacheSize = options.maxCacheSize || 1000;
|
|
329
|
+
this.routeGroups = [];
|
|
330
|
+
this.globalMiddleware = [];
|
|
331
|
+
// Route compilation options
|
|
332
|
+
this.enableCompilation = options.enableCompilation !== false; // Default true
|
|
333
|
+
this.compiledRoutes = new Map(); // Compiled regex patterns
|
|
334
|
+
this.routeCompilationCache = new Map(); // Cache compiled patterns
|
|
335
|
+
// Route versioning options
|
|
336
|
+
this.enableVersioning = options.enableVersioning || false;
|
|
337
|
+
this.defaultVersion = options.defaultVersion || 'v1';
|
|
338
|
+
this.versionHeader = options.versionHeader || 'api-version';
|
|
339
|
+
this.versionedRoutes = new Map(); // version -> routes mapping
|
|
340
|
+
// Content negotiation options
|
|
341
|
+
this.enableContentNegotiation = options.enableContentNegotiation !== false; // Default true
|
|
342
|
+
this.defaultContentType = options.defaultContentType || 'application/json';
|
|
343
|
+
// WebSocket routing options
|
|
344
|
+
this.enableWebSockets = options.enableWebSockets || false;
|
|
345
|
+
this.wsRoutes = [];
|
|
346
|
+
this.wsConnections = new Map(); // Track active WebSocket connections
|
|
347
|
+
// Performance metrics
|
|
348
|
+
this.enableMetrics = options.enableMetrics || false;
|
|
349
|
+
if (this.enableMetrics) {
|
|
350
|
+
this.metrics = {
|
|
351
|
+
requests: 0,
|
|
352
|
+
cacheHits: 0,
|
|
353
|
+
compilationHits: 0,
|
|
354
|
+
routeMatches: new Map(),
|
|
355
|
+
responseTime: [],
|
|
356
|
+
errors: 0,
|
|
357
|
+
versionRequests: new Map(), // Track requests per version
|
|
358
|
+
contentTypeRequests: new Map(), // Track requests per content type
|
|
359
|
+
wsConnections: 0, // Track WebSocket connections
|
|
360
|
+
wsMessages: 0 // Track WebSocket messages
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Add an HTTP route to the router
|
|
366
|
+
*
|
|
367
|
+
* @param {string} method - HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
368
|
+
* @param {string} path - Route path pattern (supports :param and wildcards)
|
|
369
|
+
* @param {Function} handler - Route handler function
|
|
370
|
+
* @param {Object} [options={}] - Route options
|
|
371
|
+
* @param {Array} [options.middleware] - Route-specific middleware
|
|
372
|
+
* @param {string} [options.name] - Named route for URL generation
|
|
373
|
+
* @param {string} [options.version] - API version for this route
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* router.addRoute('GET', '/users/:id', (req, res) => {
|
|
377
|
+
* return { user: { id: req.params.id } };
|
|
378
|
+
* }, { name: 'getUser', version: 'v2' });
|
|
379
|
+
*/
|
|
380
|
+
addRoute(method, path, handler, options = {}) {
|
|
381
|
+
// Apply group prefix
|
|
382
|
+
const prefix = this.getCurrentPrefix();
|
|
383
|
+
const fullPath = prefix + (path.startsWith('/') ? path : `/${path}`);
|
|
384
|
+
// Combine group middleware with route middleware
|
|
385
|
+
const groupMiddleware = this.getCurrentGroupMiddleware();
|
|
386
|
+
const routeMiddleware = options.middleware || [];
|
|
387
|
+
const allMiddleware = [...this.globalMiddleware, ...groupMiddleware, ...routeMiddleware];
|
|
388
|
+
const route = {
|
|
389
|
+
method: method.toUpperCase(),
|
|
390
|
+
path: fullPath,
|
|
391
|
+
handler,
|
|
392
|
+
middleware: allMiddleware,
|
|
393
|
+
name: options.name,
|
|
394
|
+
version: options.version || this.defaultVersion
|
|
395
|
+
};
|
|
396
|
+
// Compile route pattern if compilation is enabled
|
|
397
|
+
if (this.enableCompilation) {
|
|
398
|
+
route.compiled = this.compileRoute(fullPath);
|
|
399
|
+
}
|
|
400
|
+
this.routes.push(route);
|
|
401
|
+
// Store versioned route if versioning is enabled
|
|
402
|
+
if (this.enableVersioning) {
|
|
403
|
+
if (!this.versionedRoutes.has(route.version)) {
|
|
404
|
+
this.versionedRoutes.set(route.version, []);
|
|
405
|
+
}
|
|
406
|
+
this.versionedRoutes.get(route.version).push(route);
|
|
407
|
+
}
|
|
408
|
+
// Store named route for URL generation
|
|
409
|
+
if (options.name) {
|
|
410
|
+
this.namedRoutes.set(options.name, { method: route.method, path: fullPath, version: route.version });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Add a versioned route
|
|
415
|
+
* @param {string} version - API version (e.g., 'v1', 'v2')
|
|
416
|
+
* @param {string} method - HTTP method
|
|
417
|
+
* @param {string} path - Route path
|
|
418
|
+
* @param {Function} handler - Route handler
|
|
419
|
+
* @param {Object} options - Route options
|
|
420
|
+
*/
|
|
421
|
+
addVersionedRoute(version, method, path, handler, options = {}) {
|
|
422
|
+
this.addRoute(method, path, handler, { ...options, version });
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Add route with content negotiation support
|
|
426
|
+
* @param {string} method - HTTP method
|
|
427
|
+
* @param {string} path - Route path
|
|
428
|
+
* @param {Object} handlers - Content type handlers { 'application/json': handler, 'text/xml': handler }
|
|
429
|
+
* @param {Object} options - Route options
|
|
430
|
+
*/
|
|
431
|
+
addContentNegotiatedRoute(method, path, handlers, options = {}) {
|
|
432
|
+
const negotiationHandler = async (req, res) => {
|
|
433
|
+
const acceptedType = this.negotiateContentType(req, Object.keys(handlers));
|
|
434
|
+
if (this.enableMetrics) {
|
|
435
|
+
this.metrics.contentTypeRequests.set(acceptedType, (this.metrics.contentTypeRequests.get(acceptedType) || 0) + 1);
|
|
436
|
+
}
|
|
437
|
+
const handler = handlers[acceptedType];
|
|
438
|
+
if (!handler) {
|
|
439
|
+
res.writeHead(406, { 'Content-Type': 'application/json' });
|
|
440
|
+
res.end(JSON.stringify({
|
|
441
|
+
error: 'Not Acceptable',
|
|
442
|
+
supportedTypes: Object.keys(handlers)
|
|
443
|
+
}));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const result = await handler(req, res);
|
|
447
|
+
if (result && typeof result === 'object') {
|
|
448
|
+
res.writeHead(200, { 'Content-Type': acceptedType });
|
|
449
|
+
if (acceptedType === 'application/json') {
|
|
450
|
+
res.end(JSON.stringify(result));
|
|
451
|
+
}
|
|
452
|
+
else if (acceptedType === 'text/xml' || acceptedType === 'application/xml') {
|
|
453
|
+
res.end(this.objectToXml(result));
|
|
454
|
+
}
|
|
455
|
+
else if (acceptedType === 'text/html') {
|
|
456
|
+
res.end(typeof result === 'string' ? result : `<pre>${JSON.stringify(result, null, 2)}</pre>`);
|
|
457
|
+
}
|
|
458
|
+
else if (acceptedType === 'text/plain') {
|
|
459
|
+
res.end(typeof result === 'string' ? result : JSON.stringify(result));
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
res.end(JSON.stringify(result));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
this.addRoute(method, path, negotiationHandler, options);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Negotiate content type based on Accept header
|
|
470
|
+
* @param {Object} req - Request object
|
|
471
|
+
* @param {Array} supportedTypes - Array of supported content types
|
|
472
|
+
* @returns {string} Best matching content type
|
|
473
|
+
* @private
|
|
474
|
+
*/
|
|
475
|
+
negotiateContentType(req, supportedTypes) {
|
|
476
|
+
const acceptHeader = req.headers.accept || this.defaultContentType;
|
|
477
|
+
// Parse Accept header and find best match
|
|
478
|
+
const acceptedTypes = acceptHeader
|
|
479
|
+
.split(',')
|
|
480
|
+
.map(type => {
|
|
481
|
+
const [mediaType, ...params] = type.trim().split(';');
|
|
482
|
+
const qValue = params.find(p => p.trim().startsWith('q='));
|
|
483
|
+
const quality = qValue ? parseFloat(qValue.split('=')[1]) : 1.0;
|
|
484
|
+
return { type: mediaType.trim(), quality };
|
|
485
|
+
})
|
|
486
|
+
.sort((a, b) => b.quality - a.quality);
|
|
487
|
+
// Find first supported type
|
|
488
|
+
for (const accepted of acceptedTypes) {
|
|
489
|
+
if (accepted.type === '*/*') {
|
|
490
|
+
return supportedTypes[0] || this.defaultContentType;
|
|
491
|
+
}
|
|
492
|
+
const [mainType, subType] = accepted.type.split('/');
|
|
493
|
+
for (const supported of supportedTypes) {
|
|
494
|
+
const [supportedMain, supportedSub] = supported.split('/');
|
|
495
|
+
if (accepted.type === supported ||
|
|
496
|
+
(mainType === supportedMain && subType === '*') ||
|
|
497
|
+
(mainType === '*' && subType === supportedSub)) {
|
|
498
|
+
return supported;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return supportedTypes[0] || this.defaultContentType;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Convert object to XML string
|
|
506
|
+
* @param {Object} obj - Object to convert
|
|
507
|
+
* @param {string} rootName - Root element name
|
|
508
|
+
* @returns {string} XML string
|
|
509
|
+
* @private
|
|
510
|
+
*/
|
|
511
|
+
objectToXml(obj, rootName = 'root') {
|
|
512
|
+
const xmlEscape = (str) => String(str)
|
|
513
|
+
.replace(/&/g, '&')
|
|
514
|
+
.replace(/</g, '<')
|
|
515
|
+
.replace(/>/g, '>')
|
|
516
|
+
.replace(/"/g, '"')
|
|
517
|
+
.replace(/'/g, ''');
|
|
518
|
+
const toXml = (obj, name) => {
|
|
519
|
+
if (obj === null || obj === undefined) {
|
|
520
|
+
return `<${name}/>`;
|
|
521
|
+
}
|
|
522
|
+
if (typeof obj !== 'object') {
|
|
523
|
+
return `<${name}>${xmlEscape(obj)}</${name}>`;
|
|
524
|
+
}
|
|
525
|
+
if (Array.isArray(obj)) {
|
|
526
|
+
return obj.map(item => toXml(item, 'item')).join('');
|
|
527
|
+
}
|
|
528
|
+
const content = Object.entries(obj)
|
|
529
|
+
.map(([key, value]) => toXml(value, key))
|
|
530
|
+
.join('');
|
|
531
|
+
return `<${name}>${content}</${name}>`;
|
|
532
|
+
};
|
|
533
|
+
return `<?xml version="1.0" encoding="UTF-8"?>${toXml(obj, rootName)}`;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Add WebSocket route
|
|
537
|
+
* @param {string} path - WebSocket path
|
|
538
|
+
* @param {Function} handler - WebSocket handler function
|
|
539
|
+
* @param {Object} options - Route options
|
|
540
|
+
*/
|
|
541
|
+
addWebSocketRoute(path, handler, options = {}) {
|
|
542
|
+
if (!this.enableWebSockets) {
|
|
543
|
+
throw new Error('WebSocket routing is disabled. Enable with { enableWebSockets: true }');
|
|
544
|
+
}
|
|
545
|
+
const prefix = this.getCurrentPrefix();
|
|
546
|
+
const fullPath = prefix + (path.startsWith('/') ? path : `/${path}`);
|
|
547
|
+
const wsRoute = {
|
|
548
|
+
path: fullPath,
|
|
549
|
+
handler,
|
|
550
|
+
name: options.name,
|
|
551
|
+
version: options.version || this.defaultVersion,
|
|
552
|
+
compiled: this.enableCompilation ? this.compileRoute(fullPath) : null
|
|
553
|
+
};
|
|
554
|
+
this.wsRoutes.push(wsRoute);
|
|
555
|
+
if (options.name) {
|
|
556
|
+
this.namedRoutes.set(options.name, { method: 'WS', path: fullPath, version: wsRoute.version });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Handle WebSocket upgrade request
|
|
561
|
+
* @param {Object} request - HTTP request object
|
|
562
|
+
* @param {Object} socket - Socket object
|
|
563
|
+
* @param {Buffer} head - First packet of the upgraded stream
|
|
564
|
+
*/
|
|
565
|
+
handleWebSocketUpgrade(request, socket, head) {
|
|
566
|
+
if (!this.enableWebSockets) {
|
|
567
|
+
socket.end('HTTP/1.1 501 Not Implemented\r\n\r\n');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
571
|
+
const pathname = url.pathname;
|
|
572
|
+
// Find matching WebSocket route
|
|
573
|
+
let matchedRoute = null;
|
|
574
|
+
for (const wsRoute of this.wsRoutes) {
|
|
575
|
+
let params = null;
|
|
576
|
+
if (this.enableCompilation && wsRoute.compiled) {
|
|
577
|
+
params = this.matchCompiledRoute(wsRoute.compiled, pathname);
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
params = extractParams(wsRoute.path, pathname);
|
|
581
|
+
}
|
|
582
|
+
if (params !== null) {
|
|
583
|
+
matchedRoute = { route: wsRoute, params };
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (!matchedRoute) {
|
|
588
|
+
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Create WebSocket connection
|
|
592
|
+
this.createWebSocketConnection(request, socket, head, matchedRoute);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Create WebSocket connection
|
|
596
|
+
* @param {Object} request - HTTP request object
|
|
597
|
+
* @param {Object} socket - Socket object
|
|
598
|
+
* @param {Buffer} head - First packet of the upgraded stream
|
|
599
|
+
* @param {Object} matchedRoute - Matched WebSocket route
|
|
600
|
+
* @private
|
|
601
|
+
*/
|
|
602
|
+
createWebSocketConnection(request, socket, head, matchedRoute) {
|
|
603
|
+
// WebSocket handshake
|
|
604
|
+
const key = request.headers['sec-websocket-key'];
|
|
605
|
+
if (!key) {
|
|
606
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const acceptKey = createHash('sha1')
|
|
610
|
+
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
611
|
+
.digest('base64');
|
|
612
|
+
const responseHeaders = [
|
|
613
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
614
|
+
'Upgrade: websocket',
|
|
615
|
+
'Connection: Upgrade',
|
|
616
|
+
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
617
|
+
'', ''
|
|
618
|
+
].join('\r\n');
|
|
619
|
+
socket.write(responseHeaders);
|
|
620
|
+
// Create WebSocket wrapper
|
|
621
|
+
const ws = this.createWebSocketWrapper(socket, matchedRoute);
|
|
622
|
+
// Track connection
|
|
623
|
+
const connectionId = randomBytes(16).toString('hex');
|
|
624
|
+
this.wsConnections.set(connectionId, ws);
|
|
625
|
+
if (this.enableMetrics) {
|
|
626
|
+
this.metrics.wsConnections++;
|
|
627
|
+
}
|
|
628
|
+
// Add connection metadata
|
|
629
|
+
ws.id = connectionId;
|
|
630
|
+
ws.params = matchedRoute.params;
|
|
631
|
+
ws.path = matchedRoute.route.path;
|
|
632
|
+
// Handle connection cleanup
|
|
633
|
+
socket.on('close', () => {
|
|
634
|
+
// Call the route handler's close callback before cleanup
|
|
635
|
+
if (matchedRoute.route.handler.onClose) {
|
|
636
|
+
matchedRoute.route.handler.onClose(ws);
|
|
637
|
+
}
|
|
638
|
+
// Store connection info before deletion for potential use in handlers
|
|
639
|
+
// const connectionInfo = { id: connectionId, path: ws.path };
|
|
640
|
+
// Fire any custom close handlers set by the route handler
|
|
641
|
+
if (ws.onclose) {
|
|
642
|
+
try {
|
|
643
|
+
ws.onclose();
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
console.error('WebSocket onclose handler error:', error);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
this.wsConnections.delete(connectionId);
|
|
650
|
+
if (this.enableMetrics) {
|
|
651
|
+
this.metrics.wsConnections--;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
// Call route handler
|
|
655
|
+
try {
|
|
656
|
+
matchedRoute.route.handler(ws, request);
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
console.error('WebSocket upgrade error:', err);
|
|
660
|
+
socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Create WebSocket wrapper with message handling
|
|
665
|
+
* @param {Object} socket - Raw socket
|
|
666
|
+
* @param {Object} matchedRoute - Matched route info
|
|
667
|
+
* @returns {Object} WebSocket wrapper
|
|
668
|
+
* @private
|
|
669
|
+
*/
|
|
670
|
+
createWebSocketWrapper(socket) {
|
|
671
|
+
const ws = {
|
|
672
|
+
socket,
|
|
673
|
+
readyState: 1, // OPEN
|
|
674
|
+
send(data) {
|
|
675
|
+
if (this.readyState !== 1)
|
|
676
|
+
return;
|
|
677
|
+
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
|
678
|
+
const frame = this.createFrame(message);
|
|
679
|
+
socket.write(frame);
|
|
680
|
+
if (ws.router && ws.router.enableMetrics) {
|
|
681
|
+
ws.router.metrics.wsMessages++;
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
close(code = 1000, reason = '') {
|
|
685
|
+
if (this.readyState !== 1)
|
|
686
|
+
return;
|
|
687
|
+
this.readyState = 3; // CLOSED
|
|
688
|
+
const frame = this.createCloseFrame(code, reason);
|
|
689
|
+
socket.write(frame);
|
|
690
|
+
socket.destroy(); // Force close the socket to trigger 'close' event
|
|
691
|
+
},
|
|
692
|
+
ping(data = Buffer.alloc(0)) {
|
|
693
|
+
if (this.readyState !== 1)
|
|
694
|
+
return;
|
|
695
|
+
const frame = this.createPingFrame(data);
|
|
696
|
+
socket.write(frame);
|
|
697
|
+
},
|
|
698
|
+
createFrame(data) {
|
|
699
|
+
const payload = Buffer.from(data, 'utf8');
|
|
700
|
+
const payloadLength = payload.length;
|
|
701
|
+
let frame;
|
|
702
|
+
if (payloadLength < 126) {
|
|
703
|
+
frame = Buffer.allocUnsafe(2 + payloadLength);
|
|
704
|
+
frame[0] = 0x81; // FIN + text frame
|
|
705
|
+
frame[1] = payloadLength;
|
|
706
|
+
payload.copy(frame, 2);
|
|
707
|
+
}
|
|
708
|
+
else if (payloadLength < 65536) {
|
|
709
|
+
frame = Buffer.allocUnsafe(4 + payloadLength);
|
|
710
|
+
frame[0] = 0x81;
|
|
711
|
+
frame[1] = 126;
|
|
712
|
+
frame.writeUInt16BE(payloadLength, 2);
|
|
713
|
+
payload.copy(frame, 4);
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
frame = Buffer.allocUnsafe(10 + payloadLength);
|
|
717
|
+
frame[0] = 0x81;
|
|
718
|
+
frame[1] = 127;
|
|
719
|
+
frame.writeUInt32BE(0, 2);
|
|
720
|
+
frame.writeUInt32BE(payloadLength, 6);
|
|
721
|
+
payload.copy(frame, 10);
|
|
722
|
+
}
|
|
723
|
+
return frame;
|
|
724
|
+
},
|
|
725
|
+
createCloseFrame(code, reason) {
|
|
726
|
+
const reasonBuffer = Buffer.from(reason, 'utf8');
|
|
727
|
+
const frame = Buffer.allocUnsafe(4 + reasonBuffer.length);
|
|
728
|
+
frame[0] = 0x88; // FIN + close frame
|
|
729
|
+
frame[1] = 2 + reasonBuffer.length;
|
|
730
|
+
frame.writeUInt16BE(code, 2);
|
|
731
|
+
reasonBuffer.copy(frame, 4);
|
|
732
|
+
return frame;
|
|
733
|
+
},
|
|
734
|
+
createPingFrame(data) {
|
|
735
|
+
const frame = Buffer.allocUnsafe(2 + data.length);
|
|
736
|
+
frame[0] = 0x89; // FIN + ping frame
|
|
737
|
+
frame[1] = data.length;
|
|
738
|
+
data.copy(frame, 2);
|
|
739
|
+
return frame;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
ws.router = this;
|
|
743
|
+
// Handle incoming messages
|
|
744
|
+
socket.on('data', (buffer) => {
|
|
745
|
+
try {
|
|
746
|
+
const message = this.parseWebSocketFrame(buffer);
|
|
747
|
+
if (message && ws.onmessage) {
|
|
748
|
+
ws.onmessage({ data: message });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
// Handle socket errors
|
|
755
|
+
socket.on('error', (err) => {
|
|
756
|
+
console.log('WebSocket socket error (connection likely closed):', err.code);
|
|
757
|
+
// Don't re-throw the error, just log it
|
|
758
|
+
});
|
|
759
|
+
return ws;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Parse WebSocket frame
|
|
763
|
+
* @param {Buffer} buffer - Raw frame data
|
|
764
|
+
* @returns {string|null} Parsed message
|
|
765
|
+
* @private
|
|
766
|
+
*/
|
|
767
|
+
parseWebSocketFrame(buffer) {
|
|
768
|
+
if (buffer.length < 2)
|
|
769
|
+
return null;
|
|
770
|
+
const firstByte = buffer[0];
|
|
771
|
+
const secondByte = buffer[1];
|
|
772
|
+
const opcode = firstByte & 0x0f;
|
|
773
|
+
const masked = (secondByte & 0x80) === 0x80;
|
|
774
|
+
let payloadLength = secondByte & 0x7f;
|
|
775
|
+
// Handle close frame (opcode 8)
|
|
776
|
+
if (opcode === 8) {
|
|
777
|
+
return null; // Close frame, don't process as message
|
|
778
|
+
}
|
|
779
|
+
// Only process text frames (opcode 1)
|
|
780
|
+
if (opcode !== 1) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
let offset = 2;
|
|
784
|
+
if (payloadLength === 126) {
|
|
785
|
+
if (buffer.length < offset + 2)
|
|
786
|
+
return null;
|
|
787
|
+
payloadLength = buffer.readUInt16BE(offset);
|
|
788
|
+
offset += 2;
|
|
789
|
+
}
|
|
790
|
+
else if (payloadLength === 127) {
|
|
791
|
+
if (buffer.length < offset + 8)
|
|
792
|
+
return null;
|
|
793
|
+
payloadLength = buffer.readUInt32BE(offset + 4); // Ignore high 32 bits
|
|
794
|
+
offset += 8;
|
|
795
|
+
}
|
|
796
|
+
if (masked) {
|
|
797
|
+
if (buffer.length < offset + 4 + payloadLength)
|
|
798
|
+
return null;
|
|
799
|
+
const maskKey = buffer.slice(offset, offset + 4);
|
|
800
|
+
offset += 4;
|
|
801
|
+
const payload = buffer.slice(offset, offset + payloadLength);
|
|
802
|
+
for (let i = 0; i < payload.length; i++) {
|
|
803
|
+
payload[i] ^= maskKey[i % 4];
|
|
804
|
+
}
|
|
805
|
+
return payload.toString('utf8');
|
|
806
|
+
}
|
|
807
|
+
if (buffer.length < offset + payloadLength)
|
|
808
|
+
return null;
|
|
809
|
+
return buffer.slice(offset, offset + payloadLength).toString('utf8');
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Broadcast message to all WebSocket connections on a path
|
|
813
|
+
* @param {string} path - WebSocket path pattern
|
|
814
|
+
* @param {*} message - Message to broadcast
|
|
815
|
+
* @param {string} [excludeId=null] - Connection ID to exclude from broadcast
|
|
816
|
+
*/
|
|
817
|
+
broadcast(path, message, excludeId = null) {
|
|
818
|
+
for (const [id, ws] of this.wsConnections) {
|
|
819
|
+
if (id === excludeId)
|
|
820
|
+
continue;
|
|
821
|
+
if (ws.path === path || (path === '*' && ws.path)) {
|
|
822
|
+
try {
|
|
823
|
+
ws.send(message);
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
console.log('Failed to send message to connection:', id);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Get active WebSocket connections
|
|
833
|
+
* @returns {Array} Array of connection info
|
|
834
|
+
*/
|
|
835
|
+
getWebSocketConnections() {
|
|
836
|
+
return Array.from(this.wsConnections.entries()).map(([id, ws]) => ({
|
|
837
|
+
id,
|
|
838
|
+
path: ws.path,
|
|
839
|
+
params: ws.params,
|
|
840
|
+
readyState: ws.readyState
|
|
841
|
+
}));
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Get version from request
|
|
845
|
+
* @param {Object} req - Request object
|
|
846
|
+
* @returns {string} API version
|
|
847
|
+
* @private
|
|
848
|
+
*/
|
|
849
|
+
getRequestVersion(req) {
|
|
850
|
+
// Check header first
|
|
851
|
+
if (req.headers[this.versionHeader]) {
|
|
852
|
+
return req.headers[this.versionHeader];
|
|
853
|
+
}
|
|
854
|
+
// Check URL path for version prefix (e.g., /v1/users)
|
|
855
|
+
const pathMatch = req.url.match(/^\/v(\d+)/);
|
|
856
|
+
if (pathMatch) {
|
|
857
|
+
return `v${pathMatch[1]}`;
|
|
858
|
+
}
|
|
859
|
+
// Check query parameter
|
|
860
|
+
if (req.query && req.query.version) {
|
|
861
|
+
return req.query.version;
|
|
862
|
+
}
|
|
863
|
+
return this.defaultVersion;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Generate URL for named route with parameter substitution
|
|
867
|
+
*
|
|
868
|
+
* @param {string} name - Route name (set during route registration)
|
|
869
|
+
* @param {Object} [params={}] - Parameters to substitute in the URL pattern
|
|
870
|
+
* @returns {string} Generated URL with parameters substituted
|
|
871
|
+
* @throws {Error} If named route is not found
|
|
872
|
+
*
|
|
873
|
+
* @example
|
|
874
|
+
* // Route registered as: router.addRoute('GET', '/users/:id', handler, { name: 'getUser' })
|
|
875
|
+
* const url = router.url('getUser', { id: 123 }); // '/users/123'
|
|
876
|
+
*
|
|
877
|
+
* // With constrained parameters
|
|
878
|
+
* const url = router.url('getUserPosts', { userId: 123, postId: 456 }); // '/users/123/posts/456'
|
|
879
|
+
*/
|
|
880
|
+
generateUrl(name, params = {}) {
|
|
881
|
+
const route = this.namedRoutes.get(name);
|
|
882
|
+
if (!route) {
|
|
883
|
+
throw new Error(`Named route '${name}' not found`);
|
|
884
|
+
}
|
|
885
|
+
let url = route.path;
|
|
886
|
+
// Replace parameters in the URL
|
|
887
|
+
for (const [key, value] of Object.entries(params)) {
|
|
888
|
+
// Handle both simple params (:key) and constrained params (:key(regex))
|
|
889
|
+
const paramPattern = new RegExp(`:${key}(\\([^)]+\\))?`, 'g');
|
|
890
|
+
url = url.replace(paramPattern, encodeURIComponent(value));
|
|
891
|
+
}
|
|
892
|
+
return url;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Add routes from configuration object
|
|
896
|
+
*
|
|
897
|
+
* @param {Object} routeConfig - Route configuration object with nested structure
|
|
898
|
+
* @description Processes nested route objects and registers HTTP and WebSocket routes.
|
|
899
|
+
* Supports declarative route definition with automatic method detection.
|
|
900
|
+
*
|
|
901
|
+
* @example
|
|
902
|
+
* router.addRoutes({
|
|
903
|
+
* 'api': {
|
|
904
|
+
* 'users': {
|
|
905
|
+
* GET: (req, res) => ({ users: [] }),
|
|
906
|
+
* POST: (req, res) => ({ created: true })
|
|
907
|
+
* }
|
|
908
|
+
* }
|
|
909
|
+
* });
|
|
910
|
+
*/
|
|
911
|
+
addRoutes(routeConfig) {
|
|
912
|
+
processRoutes(routeConfig, this);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Add global middleware to the router
|
|
916
|
+
*
|
|
917
|
+
* @param {Function|Object} middleware - Middleware function or conditional middleware object
|
|
918
|
+
* @description Adds middleware that runs before all route handlers. Supports both
|
|
919
|
+
* simple functions and conditional middleware objects.
|
|
920
|
+
*
|
|
921
|
+
* @example
|
|
922
|
+
* // Simple middleware
|
|
923
|
+
* router.use((req, res) => {
|
|
924
|
+
* console.log(`${req.method} ${req.url}`);
|
|
925
|
+
* });
|
|
926
|
+
*
|
|
927
|
+
* // Conditional middleware
|
|
928
|
+
* router.use({
|
|
929
|
+
* condition: (req) => req.url.startsWith('/api'),
|
|
930
|
+
* middleware: authMiddleware,
|
|
931
|
+
* name: 'apiAuth'
|
|
932
|
+
* });
|
|
933
|
+
*/
|
|
934
|
+
use(middleware) {
|
|
935
|
+
if (typeof middleware === 'function') {
|
|
936
|
+
this.globalMiddleware.push(middleware);
|
|
937
|
+
}
|
|
938
|
+
else if (middleware && typeof middleware === 'object') {
|
|
939
|
+
// Conditional middleware: { condition, middleware, name }
|
|
940
|
+
this.globalMiddleware.push(this.createConditionalMiddleware(middleware));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Create conditional middleware wrapper
|
|
945
|
+
* @param {Object} config - Conditional middleware configuration
|
|
946
|
+
* @returns {Function} Wrapped middleware function
|
|
947
|
+
* @private
|
|
948
|
+
*/
|
|
949
|
+
createConditionalMiddleware(config) {
|
|
950
|
+
const { condition, middleware } = config;
|
|
951
|
+
return async (req, res) => {
|
|
952
|
+
// Evaluate condition
|
|
953
|
+
let shouldExecute = false;
|
|
954
|
+
if (typeof condition === 'function') {
|
|
955
|
+
shouldExecute = await condition(req, res);
|
|
956
|
+
}
|
|
957
|
+
else if (typeof condition === 'object') {
|
|
958
|
+
// Object-based conditions
|
|
959
|
+
shouldExecute = this.evaluateConditionObject(condition, req);
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
shouldExecute = !!condition;
|
|
963
|
+
}
|
|
964
|
+
if (shouldExecute) {
|
|
965
|
+
return await middleware(req, res);
|
|
966
|
+
}
|
|
967
|
+
return null; // Skip middleware
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Evaluate condition object
|
|
972
|
+
* @param {Object} condition - Condition object
|
|
973
|
+
* @param {Object} req - Request object
|
|
974
|
+
* @param {Object} res - Response object
|
|
975
|
+
* @returns {boolean} Whether condition is met
|
|
976
|
+
* @private
|
|
977
|
+
*/
|
|
978
|
+
evaluateConditionObject(condition, req) {
|
|
979
|
+
const { method, path, header, query, body, user } = condition;
|
|
980
|
+
// Method condition
|
|
981
|
+
if (method && !this.matchCondition(req.method, method))
|
|
982
|
+
return false;
|
|
983
|
+
// Path condition
|
|
984
|
+
if (path && !this.matchCondition(req.url, path))
|
|
985
|
+
return false;
|
|
986
|
+
// Header condition
|
|
987
|
+
if (header) {
|
|
988
|
+
for (const [key, value] of Object.entries(header)) {
|
|
989
|
+
if (!this.matchCondition(req.headers[key.toLowerCase()], value))
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// Query condition
|
|
994
|
+
if (query && req.query) {
|
|
995
|
+
for (const [key, value] of Object.entries(query)) {
|
|
996
|
+
if (!this.matchCondition(req.query[key], value))
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// Body condition
|
|
1001
|
+
if (body && req.body) {
|
|
1002
|
+
for (const [key, value] of Object.entries(body)) {
|
|
1003
|
+
if (!this.matchCondition(req.body[key], value))
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// User condition (for auth-based conditions)
|
|
1008
|
+
if (user && req.user) {
|
|
1009
|
+
for (const [key, value] of Object.entries(user)) {
|
|
1010
|
+
if (!this.matchCondition(req.user[key], value))
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Match condition value
|
|
1018
|
+
* @param {*} actual - Actual value
|
|
1019
|
+
* @param {*} expected - Expected value or condition
|
|
1020
|
+
* @returns {boolean} Whether condition matches
|
|
1021
|
+
* @private
|
|
1022
|
+
*/
|
|
1023
|
+
matchCondition(actual, expected) {
|
|
1024
|
+
if (expected instanceof RegExp) {
|
|
1025
|
+
return expected.test(String(actual || ''));
|
|
1026
|
+
}
|
|
1027
|
+
if (Array.isArray(expected)) {
|
|
1028
|
+
return expected.includes(actual);
|
|
1029
|
+
}
|
|
1030
|
+
if (typeof expected === 'function') {
|
|
1031
|
+
return expected(actual);
|
|
1032
|
+
}
|
|
1033
|
+
return actual === expected;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Create a route group with shared middleware and prefix
|
|
1037
|
+
* @param {string} prefix - Path prefix for the group
|
|
1038
|
+
* @param {Function|Array} middleware - Shared middleware
|
|
1039
|
+
* @param {Function} callback - Function to define routes in the group
|
|
1040
|
+
*/
|
|
1041
|
+
group(prefix, middleware, callback) {
|
|
1042
|
+
const group = {
|
|
1043
|
+
prefix: prefix.startsWith('/') ? prefix : `/${prefix}`,
|
|
1044
|
+
middleware: Array.isArray(middleware) ? middleware : [middleware]
|
|
1045
|
+
};
|
|
1046
|
+
this.routeGroups.push(group);
|
|
1047
|
+
callback(this);
|
|
1048
|
+
this.routeGroups.pop();
|
|
1049
|
+
return this;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Get current route prefix from active groups
|
|
1053
|
+
* @private
|
|
1054
|
+
*/
|
|
1055
|
+
getCurrentPrefix() {
|
|
1056
|
+
return this.routeGroups.map(g => g.prefix).join('');
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Get current group middleware
|
|
1060
|
+
* @private
|
|
1061
|
+
*/
|
|
1062
|
+
getCurrentGroupMiddleware() {
|
|
1063
|
+
return this.routeGroups.flatMap(g => g.middleware);
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Compile route pattern into optimized regex
|
|
1067
|
+
* @param {string} pattern - Route pattern to compile
|
|
1068
|
+
* @returns {Object} Compiled route object with regex and parameter names
|
|
1069
|
+
* @private
|
|
1070
|
+
*/
|
|
1071
|
+
compileRoute(pattern) {
|
|
1072
|
+
// Check compilation cache first
|
|
1073
|
+
if (this.routeCompilationCache.has(pattern)) {
|
|
1074
|
+
if (this.enableMetrics)
|
|
1075
|
+
this.metrics.compilationHits++;
|
|
1076
|
+
return this.routeCompilationCache.get(pattern);
|
|
1077
|
+
}
|
|
1078
|
+
const paramNames = [];
|
|
1079
|
+
let regexPattern = pattern;
|
|
1080
|
+
// Handle wildcards first
|
|
1081
|
+
if (pattern.includes('**')) {
|
|
1082
|
+
regexPattern = regexPattern.replace(/\/\*\*/g, '/(.*)');
|
|
1083
|
+
paramNames.push('splat');
|
|
1084
|
+
}
|
|
1085
|
+
else if (pattern.includes('*')) {
|
|
1086
|
+
regexPattern = regexPattern.replace(/\/\*/g, '/([^/]+)');
|
|
1087
|
+
paramNames.push('splat');
|
|
1088
|
+
}
|
|
1089
|
+
// Handle parameters with constraints and optional parameters
|
|
1090
|
+
regexPattern = regexPattern.replace(/:([^(/]+)(\([^)]+\))?(\?)?/g, (match, paramName, constraint, optional) => {
|
|
1091
|
+
paramNames.push(paramName);
|
|
1092
|
+
if (constraint) {
|
|
1093
|
+
// Use the constraint pattern directly
|
|
1094
|
+
const constraintPattern = constraint.slice(1, -1); // Remove parentheses
|
|
1095
|
+
return optional ? `(?:/(?:${constraintPattern}))?` : `/(${constraintPattern})`;
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
// Default parameter pattern
|
|
1099
|
+
return optional ? '(?:/([^/]+))?' : '/([^/]+)';
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
// Escape special regex characters except those we want to keep
|
|
1103
|
+
regexPattern = regexPattern
|
|
1104
|
+
.replace(/[.+?^${}|[\]\\]/g, '\\$&')
|
|
1105
|
+
.replace(/\\\(/g, '(')
|
|
1106
|
+
.replace(/\\\)/g, ')')
|
|
1107
|
+
.replace(/\\\?/g, '?');
|
|
1108
|
+
// Ensure exact match
|
|
1109
|
+
regexPattern = `^${regexPattern}$`;
|
|
1110
|
+
const compiled = {
|
|
1111
|
+
regex: new RegExp(regexPattern),
|
|
1112
|
+
paramNames,
|
|
1113
|
+
pattern
|
|
1114
|
+
};
|
|
1115
|
+
// Cache the compiled route
|
|
1116
|
+
if (this.routeCompilationCache.size < 1000) {
|
|
1117
|
+
this.routeCompilationCache.set(pattern, compiled);
|
|
1118
|
+
}
|
|
1119
|
+
return compiled;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Match path using compiled route
|
|
1123
|
+
* @param {Object} compiledRoute - Compiled route object
|
|
1124
|
+
* @param {string} path - Path to match
|
|
1125
|
+
* @returns {Object|null} Parameters object or null if no match
|
|
1126
|
+
* @private
|
|
1127
|
+
*/
|
|
1128
|
+
matchCompiledRoute(compiledRoute, path) {
|
|
1129
|
+
const match = compiledRoute.regex.exec(path);
|
|
1130
|
+
if (!match)
|
|
1131
|
+
return null;
|
|
1132
|
+
const params = {};
|
|
1133
|
+
for (let i = 0; i < compiledRoute.paramNames.length; i++) {
|
|
1134
|
+
const paramName = compiledRoute.paramNames[i];
|
|
1135
|
+
const value = match[i + 1];
|
|
1136
|
+
if (value !== undefined) {
|
|
1137
|
+
params[paramName] = value;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return params;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Get performance metrics
|
|
1144
|
+
* @returns {Object} Performance metrics object
|
|
1145
|
+
*/
|
|
1146
|
+
getMetrics() {
|
|
1147
|
+
if (!this.enableMetrics) {
|
|
1148
|
+
throw new Error('Metrics collection is disabled. Enable with { enableMetrics: true }');
|
|
1149
|
+
}
|
|
1150
|
+
const avgResponseTime = this.metrics.responseTime.length > 0
|
|
1151
|
+
? this.metrics.responseTime.reduce((a, b) => a + b, 0) / this.metrics.responseTime.length
|
|
1152
|
+
: 0;
|
|
1153
|
+
return {
|
|
1154
|
+
...this.metrics,
|
|
1155
|
+
averageResponseTime: Math.round(avgResponseTime * 100) / 100,
|
|
1156
|
+
cacheHitRate: this.metrics.requests > 0 ? `${(this.metrics.cacheHits / this.metrics.requests * 100).toFixed(2)}%` : '0%',
|
|
1157
|
+
compilationHitRate: this.metrics.requests > 0 ? `${(this.metrics.compilationHits / this.metrics.requests * 100).toFixed(2)}%` : '0%'
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Get compilation statistics
|
|
1162
|
+
* @returns {Object} Compilation statistics
|
|
1163
|
+
*/
|
|
1164
|
+
getCompilationStats() {
|
|
1165
|
+
const totalRoutes = this.routes.length;
|
|
1166
|
+
const compiledRoutes = this.routes.filter(r => r.compiled).length;
|
|
1167
|
+
const compilationCacheSize = this.routeCompilationCache.size;
|
|
1168
|
+
return {
|
|
1169
|
+
totalRoutes,
|
|
1170
|
+
compiledRoutes,
|
|
1171
|
+
compilationEnabled: this.enableCompilation,
|
|
1172
|
+
compilationCacheSize,
|
|
1173
|
+
compilationCacheHits: this.enableMetrics ? this.metrics.compilationHits : 'N/A (metrics disabled)'
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Clear route cache (useful for development)
|
|
1178
|
+
*/
|
|
1179
|
+
clearCache() {
|
|
1180
|
+
this.routeCache.clear();
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Clear compilation cache
|
|
1184
|
+
*/
|
|
1185
|
+
clearCompilationCache() {
|
|
1186
|
+
this.routeCompilationCache.clear();
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Get all registered routes with detailed information
|
|
1190
|
+
* @returns {Array} Array of route information objects
|
|
1191
|
+
*/
|
|
1192
|
+
getRoutes() {
|
|
1193
|
+
return this.routes.map(route => ({
|
|
1194
|
+
method: route.method,
|
|
1195
|
+
path: route.path,
|
|
1196
|
+
name: route.name || null,
|
|
1197
|
+
hasMiddleware: route.middleware && route.middleware.length > 0,
|
|
1198
|
+
middlewareCount: route.middleware ? route.middleware.length : 0,
|
|
1199
|
+
compiled: !!route.compiled,
|
|
1200
|
+
compiledPattern: route.compiled ? route.compiled.regex.source : null,
|
|
1201
|
+
paramNames: route.compiled ? route.compiled.paramNames : null
|
|
1202
|
+
}));
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Find routes matching a pattern or method
|
|
1206
|
+
* @param {Object} criteria - Search criteria
|
|
1207
|
+
* @returns {Array} Matching routes
|
|
1208
|
+
*/
|
|
1209
|
+
findRoutes(criteria = {}) {
|
|
1210
|
+
const { method, path, name, hasMiddleware } = criteria;
|
|
1211
|
+
return this.routes.filter(route => {
|
|
1212
|
+
if (method && route.method !== method.toUpperCase())
|
|
1213
|
+
return false;
|
|
1214
|
+
if (path && !route.path.includes(path))
|
|
1215
|
+
return false;
|
|
1216
|
+
if (name && route.name !== name)
|
|
1217
|
+
return false;
|
|
1218
|
+
if (hasMiddleware !== undefined && !!route.middleware?.length !== hasMiddleware)
|
|
1219
|
+
return false;
|
|
1220
|
+
return true;
|
|
1221
|
+
}).map(route => ({
|
|
1222
|
+
method: route.method,
|
|
1223
|
+
path: route.path,
|
|
1224
|
+
name: route.name || null,
|
|
1225
|
+
middlewareCount: route.middleware ? route.middleware.length : 0
|
|
1226
|
+
}));
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Test route matching without executing handlers
|
|
1230
|
+
* @param {string} method - HTTP method
|
|
1231
|
+
* @param {string} path - Path to test
|
|
1232
|
+
* @returns {Object} Match result with route info and extracted parameters
|
|
1233
|
+
*/
|
|
1234
|
+
testRoute(method, path) {
|
|
1235
|
+
const upperMethod = method.toUpperCase();
|
|
1236
|
+
for (const route of this.routes) {
|
|
1237
|
+
if (route.method === upperMethod) {
|
|
1238
|
+
let params = null;
|
|
1239
|
+
// Use compiled route if available
|
|
1240
|
+
if (this.enableCompilation && route.compiled) {
|
|
1241
|
+
params = this.matchCompiledRoute(route.compiled, path);
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
params = extractParams(route.path, path);
|
|
1245
|
+
}
|
|
1246
|
+
if (params !== null) {
|
|
1247
|
+
return {
|
|
1248
|
+
matched: true,
|
|
1249
|
+
route: {
|
|
1250
|
+
method: route.method,
|
|
1251
|
+
path: route.path,
|
|
1252
|
+
name: route.name || null,
|
|
1253
|
+
middlewareCount: route.middleware ? route.middleware.length : 0
|
|
1254
|
+
},
|
|
1255
|
+
params,
|
|
1256
|
+
compiledUsed: this.enableCompilation && !!route.compiled
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return { matched: false, route: null, params: null };
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Get router debug information
|
|
1265
|
+
* @returns {Object} Comprehensive debug information
|
|
1266
|
+
*/
|
|
1267
|
+
getDebugInfo() {
|
|
1268
|
+
const routesByMethod = {};
|
|
1269
|
+
const namedRoutes = {};
|
|
1270
|
+
// Group routes by method
|
|
1271
|
+
this.routes.forEach(route => {
|
|
1272
|
+
if (!routesByMethod[route.method]) {
|
|
1273
|
+
routesByMethod[route.method] = [];
|
|
1274
|
+
}
|
|
1275
|
+
routesByMethod[route.method].push({
|
|
1276
|
+
path: route.path,
|
|
1277
|
+
name: route.name,
|
|
1278
|
+
middlewareCount: route.middleware ? route.middleware.length : 0,
|
|
1279
|
+
compiled: !!route.compiled
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
// Get named routes
|
|
1283
|
+
this.namedRoutes.forEach((routeInfo, name) => {
|
|
1284
|
+
namedRoutes[name] = routeInfo;
|
|
1285
|
+
});
|
|
1286
|
+
return {
|
|
1287
|
+
totalRoutes: this.routes.length,
|
|
1288
|
+
routesByMethod,
|
|
1289
|
+
namedRoutes,
|
|
1290
|
+
globalMiddleware: this.globalMiddleware.length,
|
|
1291
|
+
activeGroups: this.routeGroups.length,
|
|
1292
|
+
cacheSize: this.routeCache.size,
|
|
1293
|
+
maxCacheSize: this.maxCacheSize,
|
|
1294
|
+
compilationEnabled: this.enableCompilation,
|
|
1295
|
+
compilationCacheSize: this.routeCompilationCache.size,
|
|
1296
|
+
metricsEnabled: this.enableMetrics
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
async handle(req, res, options = {}) {
|
|
1300
|
+
const startTime = Date.now();
|
|
1301
|
+
// Metrics collection
|
|
1302
|
+
if (this.enableMetrics) {
|
|
1303
|
+
this.metrics.requests++;
|
|
1304
|
+
}
|
|
1305
|
+
const { corsOrigin, rateLimit = { windowMs: 60000, maxRequests: 100 } } = options;
|
|
1306
|
+
// Add security headers
|
|
1307
|
+
addSecurityHeaders(res, corsOrigin);
|
|
1308
|
+
// Handle preflight requests
|
|
1309
|
+
if (req.method === 'OPTIONS') {
|
|
1310
|
+
res.writeHead(204);
|
|
1311
|
+
res.end();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
// Rate limiting
|
|
1315
|
+
const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress || 'unknown';
|
|
1316
|
+
if (!checkRateLimit(clientIP, rateLimit.windowMs, rateLimit.maxRequests)) {
|
|
1317
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1318
|
+
res.end(JSON.stringify({ error: 'Too Many Requests' }));
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
// Parse URL and query parameters
|
|
1322
|
+
const parsedUrl = parseUrl(req.url, true);
|
|
1323
|
+
const pathname = parsedUrl.pathname;
|
|
1324
|
+
req.query = parsedUrl.query || {};
|
|
1325
|
+
// Parse request body with size limits
|
|
1326
|
+
try {
|
|
1327
|
+
req.body = await parseBody(req, options.maxBodySize);
|
|
1328
|
+
}
|
|
1329
|
+
catch (error) {
|
|
1330
|
+
if (this.enableMetrics)
|
|
1331
|
+
this.metrics.errors++;
|
|
1332
|
+
const statusCode = error.message.includes('too large') ? 413 : 400;
|
|
1333
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1334
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
// Check route cache first
|
|
1338
|
+
const cacheKey = `${req.method}:${pathname}`;
|
|
1339
|
+
let matchedRoute = this.routeCache.get(cacheKey);
|
|
1340
|
+
if (matchedRoute && this.enableMetrics) {
|
|
1341
|
+
this.metrics.cacheHits++;
|
|
1342
|
+
}
|
|
1343
|
+
if (!matchedRoute) {
|
|
1344
|
+
// Get request version if versioning is enabled
|
|
1345
|
+
const requestVersion = this.enableVersioning ? this.getRequestVersion(req) : null;
|
|
1346
|
+
// Track version requests in metrics
|
|
1347
|
+
if (this.enableMetrics && requestVersion) {
|
|
1348
|
+
this.metrics.versionRequests.set(requestVersion, (this.metrics.versionRequests.get(requestVersion) || 0) + 1);
|
|
1349
|
+
}
|
|
1350
|
+
// Find matching route using compiled patterns or fallback to extractParams
|
|
1351
|
+
const routesToSearch = this.enableVersioning && this.versionedRoutes.has(requestVersion)
|
|
1352
|
+
? this.versionedRoutes.get(requestVersion)
|
|
1353
|
+
: this.routes;
|
|
1354
|
+
for (const route of routesToSearch) {
|
|
1355
|
+
if (route.method === req.method) {
|
|
1356
|
+
// Skip route if versioning is enabled and versions don't match
|
|
1357
|
+
if (this.enableVersioning && route.version !== requestVersion) {
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
let params = null;
|
|
1361
|
+
// Use compiled route if available
|
|
1362
|
+
if (this.enableCompilation && route.compiled) {
|
|
1363
|
+
params = this.matchCompiledRoute(route.compiled, pathname);
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
// Fallback to original parameter extraction
|
|
1367
|
+
params = extractParams(route.path, pathname);
|
|
1368
|
+
}
|
|
1369
|
+
if (params !== null) {
|
|
1370
|
+
matchedRoute = { route, params };
|
|
1371
|
+
// Cache the match if under size limit
|
|
1372
|
+
if (this.routeCache.size < this.maxCacheSize) {
|
|
1373
|
+
this.routeCache.set(cacheKey, matchedRoute);
|
|
1374
|
+
}
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (matchedRoute) {
|
|
1381
|
+
req.params = matchedRoute.params;
|
|
1382
|
+
// Record route match metrics
|
|
1383
|
+
if (this.enableMetrics) {
|
|
1384
|
+
const routeKey = `${req.method}:${matchedRoute.route.path}`;
|
|
1385
|
+
this.metrics.routeMatches.set(routeKey, (this.metrics.routeMatches.get(routeKey) || 0) + 1);
|
|
1386
|
+
}
|
|
1387
|
+
// Log request
|
|
1388
|
+
console.log(`${new Date().toISOString()} ${req.method} ${pathname}`);
|
|
1389
|
+
try {
|
|
1390
|
+
// Execute middleware chain
|
|
1391
|
+
if (matchedRoute.route.middleware && matchedRoute.route.middleware.length > 0) {
|
|
1392
|
+
for (const middleware of matchedRoute.route.middleware) {
|
|
1393
|
+
const result = await middleware(req, res);
|
|
1394
|
+
if (result)
|
|
1395
|
+
break; // Middleware handled response
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
// Execute handler
|
|
1399
|
+
const { route } = matchedRoute;
|
|
1400
|
+
const result = await route.handler(req, res);
|
|
1401
|
+
// Only write response if handler returned data and response hasn't been sent
|
|
1402
|
+
if (result && typeof result === 'object' && !res.headersSent) {
|
|
1403
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1404
|
+
res.end(JSON.stringify(result));
|
|
1405
|
+
}
|
|
1406
|
+
// Record response time
|
|
1407
|
+
if (this.enableMetrics) {
|
|
1408
|
+
const responseTime = Date.now() - startTime;
|
|
1409
|
+
this.metrics.responseTime.push(responseTime);
|
|
1410
|
+
// Keep only last 1000 response times to prevent memory growth
|
|
1411
|
+
if (this.metrics.responseTime.length > 1000) {
|
|
1412
|
+
this.metrics.responseTime = this.metrics.responseTime.slice(-1000);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
catch (error) {
|
|
1418
|
+
if (this.enableMetrics)
|
|
1419
|
+
this.metrics.errors++;
|
|
1420
|
+
if (!res.headersSent) {
|
|
1421
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1422
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
1423
|
+
}
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// No route found
|
|
1428
|
+
if (this.enableMetrics)
|
|
1429
|
+
this.metrics.errors++;
|
|
1430
|
+
if (!res.headersSent) {
|
|
1431
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1432
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
createServer(options = {}) {
|
|
1436
|
+
const mergedOptions = { ...this.defaultOptions, ...options };
|
|
1437
|
+
return createServer((req, res) => this.handle(req, res, mergedOptions));
|
|
1438
|
+
}
|
|
1439
|
+
// HTTP convenience methods
|
|
1440
|
+
get(path, handler, options = {}) {
|
|
1441
|
+
return this.addRoute('GET', path, handler, options);
|
|
1442
|
+
}
|
|
1443
|
+
post(path, handler, options = {}) {
|
|
1444
|
+
return this.addRoute('POST', path, handler, options);
|
|
1445
|
+
}
|
|
1446
|
+
put(path, handler, options = {}) {
|
|
1447
|
+
return this.addRoute('PUT', path, handler, options);
|
|
1448
|
+
}
|
|
1449
|
+
patch(path, handler, options = {}) {
|
|
1450
|
+
return this.addRoute('PATCH', path, handler, options);
|
|
1451
|
+
}
|
|
1452
|
+
delete(path, handler, options = {}) {
|
|
1453
|
+
return this.addRoute('DELETE', path, handler, options);
|
|
1454
|
+
}
|
|
1455
|
+
options(path, handler, options = {}) {
|
|
1456
|
+
return this.addRoute('OPTIONS', path, handler, options);
|
|
1457
|
+
}
|
|
1458
|
+
head(path, handler, options = {}) {
|
|
1459
|
+
return this.addRoute('HEAD', path, handler, options);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Creates an object-based router from nested route definitions
|
|
1464
|
+
*
|
|
1465
|
+
* @param {Object} routes - Nested route definition object
|
|
1466
|
+
* @param {Object} options - Router options (corsOrigin, rateLimit, maxBodySize)
|
|
1467
|
+
* @returns {Object} Configured router instance
|
|
1468
|
+
*
|
|
1469
|
+
* @example
|
|
1470
|
+
* const routes = {
|
|
1471
|
+
* api: {
|
|
1472
|
+
* users: {
|
|
1473
|
+
* get: { handler: () => ({ users: [] }) },
|
|
1474
|
+
* post: {
|
|
1475
|
+
* validation: userSchema,
|
|
1476
|
+
* handler: (req) => ({ user: req.body })
|
|
1477
|
+
* }
|
|
1478
|
+
* }
|
|
1479
|
+
* }
|
|
1480
|
+
* };
|
|
1481
|
+
*
|
|
1482
|
+
* const router = createObjectRouter(routes, {
|
|
1483
|
+
* corsOrigin: 'https://myapp.com',
|
|
1484
|
+
* rateLimit: { windowMs: 60000, maxRequests: 50 }
|
|
1485
|
+
* });
|
|
1486
|
+
* const server = router.createServer();
|
|
1487
|
+
* server.listen(3000);
|
|
1488
|
+
*/
|
|
1489
|
+
/**
|
|
1490
|
+
* Factory function to create a SimpleRouter instance
|
|
1491
|
+
*
|
|
1492
|
+
* @param {Object} options - Router options
|
|
1493
|
+
* @returns {SimpleRouter} Router instance
|
|
1494
|
+
*/
|
|
1495
|
+
export function createSimpleRouter(options = {}) {
|
|
1496
|
+
return new SimpleRouter(options);
|
|
1497
|
+
}
|
|
1498
|
+
export function createObjectRouter(routeConfig, options = {}) {
|
|
1499
|
+
const router = new SimpleRouter(options);
|
|
1500
|
+
router.defaultOptions = options; // Store default options
|
|
1501
|
+
if (routeConfig) {
|
|
1502
|
+
router.addRoutes(routeConfig);
|
|
1503
|
+
}
|
|
1504
|
+
return router;
|
|
1505
|
+
}
|
|
1506
|
+
export { SimpleRouter };
|
|
1507
|
+
export default createObjectRouter;
|
|
1508
|
+
//# sourceMappingURL=router.js.map
|