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