@lithia-js/core 1.0.0-canary.2 → 1.0.0-canary.4
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/CHANGELOG.md +20 -0
- package/dist/config.d.ts +6 -6
- package/dist/config.js +4 -6
- package/dist/config.js.map +1 -1
- package/dist/env.d.ts +1 -1
- package/dist/env.js +2 -1
- package/dist/env.js.map +1 -1
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +7 -1
- package/dist/errors.js.map +1 -1
- package/dist/lithia.d.ts +43 -42
- package/dist/lithia.js +29 -39
- package/dist/lithia.js.map +1 -1
- package/dist/module-loader.js +1 -3
- package/dist/module-loader.js.map +1 -1
- package/dist/server/http-server.js +3 -3
- package/dist/server/http-server.js.map +1 -1
- package/package.json +3 -3
- package/src/config.ts +0 -212
- package/src/context/event-context.ts +0 -66
- package/src/context/index.ts +0 -32
- package/src/context/lithia-context.ts +0 -59
- package/src/context/route-context.ts +0 -89
- package/src/env.ts +0 -31
- package/src/errors.ts +0 -96
- package/src/hooks/dependency-hooks.ts +0 -122
- package/src/hooks/event-hooks.ts +0 -69
- package/src/hooks/index.ts +0 -58
- package/src/hooks/route-hooks.ts +0 -177
- package/src/lib.ts +0 -27
- package/src/lithia.ts +0 -777
- package/src/logger.ts +0 -66
- package/src/module-loader.ts +0 -45
- package/src/server/event-processor.ts +0 -344
- package/src/server/http-server.ts +0 -371
- package/src/server/middlewares/validation.ts +0 -46
- package/src/server/request-processor.ts +0 -860
- package/src/server/request.ts +0 -247
- package/src/server/response.ts +0 -204
- package/tsconfig.json +0 -8
|
@@ -1,860 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Request processor module for HTTP request handling.
|
|
3
|
-
*
|
|
4
|
-
* This module is the core of Lithia's request processing pipeline. It handles:
|
|
5
|
-
* - Route matching and parameter extraction
|
|
6
|
-
* - Static file serving
|
|
7
|
-
* - CORS preflight and headers
|
|
8
|
-
* - Middleware execution chain
|
|
9
|
-
* - Route handler invocation
|
|
10
|
-
* - Error handling and logging
|
|
11
|
-
*
|
|
12
|
-
* @module server/request-processor
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
16
|
-
import { statSync } from "node:fs";
|
|
17
|
-
import { extname, join } from "node:path";
|
|
18
|
-
import type { Route } from "@lithia-js/native";
|
|
19
|
-
import { cyan, green, red, yellow } from "@lithia-js/utils";
|
|
20
|
-
import { type RouteContext, routeContext } from "../context/route-context";
|
|
21
|
-
import {
|
|
22
|
-
InvalidRouteModuleError,
|
|
23
|
-
LithiaError,
|
|
24
|
-
StaticFileMimeMissingError,
|
|
25
|
-
ValidationError,
|
|
26
|
-
} from "../errors";
|
|
27
|
-
import type { Lithia } from "../lithia";
|
|
28
|
-
import { logger } from "../logger";
|
|
29
|
-
import { coldImport, isAsyncFunction } from "../module-loader";
|
|
30
|
-
import type { HttpServer } from "./http-server";
|
|
31
|
-
import type { LithiaRequest, Params } from "./request";
|
|
32
|
-
import type { LithiaResponse } from "./response";
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Route handler function signature.
|
|
36
|
-
*
|
|
37
|
-
* The primary function exported from route files to handle requests.
|
|
38
|
-
*
|
|
39
|
-
* @param req - The incoming HTTP request
|
|
40
|
-
* @param res - The HTTP response object
|
|
41
|
-
*/
|
|
42
|
-
export type LithiaHandler = (
|
|
43
|
-
req: LithiaRequest,
|
|
44
|
-
res: LithiaResponse,
|
|
45
|
-
) => Promise<void>;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Middleware function signature.
|
|
49
|
-
*
|
|
50
|
-
* Middlewares run before the route handler and can modify the request/response,
|
|
51
|
-
* perform authentication, logging, or other cross-cutting concerns.
|
|
52
|
-
*
|
|
53
|
-
* @param req - The incoming HTTP request
|
|
54
|
-
* @param res - The HTTP response object
|
|
55
|
-
* @param next - Function to call to proceed to the next middleware or handler
|
|
56
|
-
*
|
|
57
|
-
* @remarks
|
|
58
|
-
* Middlewares must call `next()` to continue the chain. Not calling `next()`
|
|
59
|
-
* will stop execution and prevent the route handler from running.
|
|
60
|
-
*
|
|
61
|
-
* @example
|
|
62
|
-
* ```typescript
|
|
63
|
-
* export const middlewares = [
|
|
64
|
-
* async (req, res, next) => {
|
|
65
|
-
* console.log('Before handler');
|
|
66
|
-
* await next();
|
|
67
|
-
* console.log('After handler');
|
|
68
|
-
* }
|
|
69
|
-
* ];
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
export type LithiaMiddleware = (
|
|
73
|
-
req: LithiaRequest,
|
|
74
|
-
res: LithiaResponse,
|
|
75
|
-
next: () => void,
|
|
76
|
-
) => Promise<void>;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Structure of a route module loaded from the file system.
|
|
80
|
-
*
|
|
81
|
-
* Route files can export a default handler and optionally an array of
|
|
82
|
-
* middlewares to run before the handler.
|
|
83
|
-
*/
|
|
84
|
-
export interface RouteModule {
|
|
85
|
-
/**
|
|
86
|
-
* The default route handler function.
|
|
87
|
-
*
|
|
88
|
-
* This is the main function that processes the request and sends a response.
|
|
89
|
-
*/
|
|
90
|
-
default?: LithiaHandler;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Optional array of middlewares executed before the handler.
|
|
94
|
-
*
|
|
95
|
-
* Middlewares run in order and must call `next()` to continue.
|
|
96
|
-
*/
|
|
97
|
-
middlewares?: Array<LithiaMiddleware>;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Standardized error response format.
|
|
102
|
-
*
|
|
103
|
-
* All errors are formatted consistently as JSON with this structure.
|
|
104
|
-
*
|
|
105
|
-
* @internal
|
|
106
|
-
*/
|
|
107
|
-
export interface RequestErrorInfo {
|
|
108
|
-
/** Error details object. */
|
|
109
|
-
error: {
|
|
110
|
-
/** Human-readable error message. */
|
|
111
|
-
message: string;
|
|
112
|
-
/** HTTP status code. */
|
|
113
|
-
statusCode: number;
|
|
114
|
-
/** ISO timestamp of when the error occurred. */
|
|
115
|
-
timestamp: string;
|
|
116
|
-
/** Request path that caused the error. */
|
|
117
|
-
path: string;
|
|
118
|
-
/** HTTP method of the request. */
|
|
119
|
-
method: string;
|
|
120
|
-
/** Short error digest for correlation with logs. */
|
|
121
|
-
digest?: string;
|
|
122
|
-
/** Validation error details (for 400 errors). */
|
|
123
|
-
issues?: any[];
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Request processor for the Lithia HTTP pipeline.
|
|
129
|
-
*
|
|
130
|
-
* This class orchestrates the entire request processing flow from receiving
|
|
131
|
-
* an HTTP request to sending a response. It manages:
|
|
132
|
-
*
|
|
133
|
-
* - **CORS handling**: Preflight requests and CORS headers
|
|
134
|
-
* - **Static files**: Serving files from configured static directories
|
|
135
|
-
* - **Route matching**: Finding the route that matches the request path
|
|
136
|
-
* - **Parameter extraction**: Extracting dynamic route parameters
|
|
137
|
-
* - **Middleware execution**: Running global and route-specific middlewares
|
|
138
|
-
* - **Handler invocation**: Executing the route handler function
|
|
139
|
-
* - **Error handling**: Converting exceptions to structured JSON responses
|
|
140
|
-
* - **Request logging**: Logging all requests with timing and status codes
|
|
141
|
-
*
|
|
142
|
-
* @remarks
|
|
143
|
-
* The processor uses AsyncLocalStorage to provide request context to hooks,
|
|
144
|
-
* allowing route handlers to access request/response without explicit parameters.
|
|
145
|
-
*
|
|
146
|
-
* In development mode, route modules are reloaded on each request (cache busting).
|
|
147
|
-
* In production, modules are cached for better performance.
|
|
148
|
-
*/
|
|
149
|
-
export class RequestProcessor {
|
|
150
|
-
/**
|
|
151
|
-
* Creates a new request processor.
|
|
152
|
-
*
|
|
153
|
-
* @param lithia - The Lithia application instance
|
|
154
|
-
*/
|
|
155
|
-
constructor(
|
|
156
|
-
private lithia: Lithia,
|
|
157
|
-
private httpServer: HttpServer,
|
|
158
|
-
) {}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Processes an incoming HTTP request through the complete pipeline.
|
|
162
|
-
*
|
|
163
|
-
* This is the main entry point for request processing. The method executes
|
|
164
|
-
* the following steps in order:
|
|
165
|
-
*
|
|
166
|
-
* 1. Initialize request context for hooks
|
|
167
|
-
* 2. Handle CORS preflight (OPTIONS) requests
|
|
168
|
-
* 3. Add standard response headers
|
|
169
|
-
* 4. Attempt to serve static files
|
|
170
|
-
* 5. Match request to a route
|
|
171
|
-
* 6. Extract dynamic route parameters
|
|
172
|
-
* 7. Load the route module
|
|
173
|
-
* 8. Execute global middlewares
|
|
174
|
-
* 9. Execute route-specific middlewares
|
|
175
|
-
* 10. Execute the route handler
|
|
176
|
-
* 11. Log the request with timing
|
|
177
|
-
*
|
|
178
|
-
* Any errors thrown during this process are caught and handled by
|
|
179
|
-
* {@link handleError}, which sends a structured error response.
|
|
180
|
-
*
|
|
181
|
-
* @param req - The incoming HTTP request
|
|
182
|
-
* @param res - The HTTP response object
|
|
183
|
-
*
|
|
184
|
-
* @example
|
|
185
|
-
* ```typescript
|
|
186
|
-
* const processor = new RequestProcessor(lithia);
|
|
187
|
-
* await processor.processRequest(req, res);
|
|
188
|
-
* ```
|
|
189
|
-
*/
|
|
190
|
-
async processRequest(req: LithiaRequest, res: LithiaResponse): Promise<void> {
|
|
191
|
-
const start = process.hrtime.bigint();
|
|
192
|
-
|
|
193
|
-
// Initialize context for hooks
|
|
194
|
-
const routeCtx: RouteContext = {
|
|
195
|
-
req,
|
|
196
|
-
res,
|
|
197
|
-
socketServer: this.httpServer.socketIO!,
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
await this.lithia.runWithContext(async () => {
|
|
201
|
-
await routeContext.run(routeCtx, async () => {
|
|
202
|
-
try {
|
|
203
|
-
// Handle CORS
|
|
204
|
-
if (this.handleCors(req, res)) {
|
|
205
|
-
this.logRequest(req, res, start);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Add basic headers
|
|
210
|
-
res.addHeader("X-Powered-By", "Lithia");
|
|
211
|
-
|
|
212
|
-
// Serve static files
|
|
213
|
-
if (await this.serveStaticFile(req, res)) {
|
|
214
|
-
this.logRequest(req, res, start);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Find matching route
|
|
219
|
-
const route = this.findMatchingRoute(req.pathname, req.method);
|
|
220
|
-
|
|
221
|
-
if (!route) {
|
|
222
|
-
this.sendNotFound(req, res);
|
|
223
|
-
this.logRequest(req, res, start);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Update context with matched route
|
|
228
|
-
routeCtx.route = route;
|
|
229
|
-
|
|
230
|
-
// Extract params if dynamic route
|
|
231
|
-
if (route.dynamic) {
|
|
232
|
-
(req as any).params = this.extractParams(req.pathname, route);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Import route module
|
|
236
|
-
const module = await this.importRouteModule(route);
|
|
237
|
-
|
|
238
|
-
// Execute global middlewares
|
|
239
|
-
if (
|
|
240
|
-
this.lithia.globalMiddlewares &&
|
|
241
|
-
this.lithia.globalMiddlewares.length > 0
|
|
242
|
-
) {
|
|
243
|
-
const globalMiddlewareError = await this.executeMiddlewares(
|
|
244
|
-
this.lithia.globalMiddlewares,
|
|
245
|
-
req,
|
|
246
|
-
res,
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
if (res._ended || globalMiddlewareError) {
|
|
250
|
-
if (globalMiddlewareError) {
|
|
251
|
-
throw globalMiddlewareError;
|
|
252
|
-
}
|
|
253
|
-
this.logRequest(req, res, start);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Execute middlewares if present
|
|
259
|
-
if (module.middlewares && module.middlewares.length > 0) {
|
|
260
|
-
const middlewareError = await this.executeMiddlewares(
|
|
261
|
-
module.middlewares,
|
|
262
|
-
req,
|
|
263
|
-
res,
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
// If middleware ended response or errored, stop processing
|
|
267
|
-
if (res._ended || middlewareError) {
|
|
268
|
-
if (middlewareError) {
|
|
269
|
-
throw middlewareError;
|
|
270
|
-
}
|
|
271
|
-
this.logRequest(req, res, start);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Execute route handler
|
|
277
|
-
if (module.default) {
|
|
278
|
-
await module.default(req, res);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// End response if not already ended
|
|
282
|
-
if (!res._ended) {
|
|
283
|
-
res.end();
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
this.logRequest(req, res, start);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
this.handleError(err, req, res, start);
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Executes an array of middlewares sequentially.
|
|
296
|
-
*
|
|
297
|
-
* Middlewares are executed in order. Each middleware must call `next()`
|
|
298
|
-
* to continue to the next middleware in the chain. If a middleware does
|
|
299
|
-
* not call `next()`, the chain stops and subsequent middlewares are not
|
|
300
|
-
* executed.
|
|
301
|
-
*
|
|
302
|
-
* If any middleware throws an error, execution stops immediately and the
|
|
303
|
-
* error is returned.
|
|
304
|
-
*
|
|
305
|
-
* @param middlewares - Array of middleware functions to execute
|
|
306
|
-
* @param req - The HTTP request object
|
|
307
|
-
* @param res - The HTTP response object
|
|
308
|
-
* @returns null if successful, or an Error if a middleware threw
|
|
309
|
-
*
|
|
310
|
-
* @private
|
|
311
|
-
*
|
|
312
|
-
* @example
|
|
313
|
-
* ```typescript
|
|
314
|
-
* const error = await this.executeMiddlewares(middlewares, req, res);
|
|
315
|
-
* if (error) {
|
|
316
|
-
* throw error;
|
|
317
|
-
* }
|
|
318
|
-
* ```
|
|
319
|
-
*/
|
|
320
|
-
private async executeMiddlewares(
|
|
321
|
-
middlewares: Array<LithiaMiddleware>,
|
|
322
|
-
req: LithiaRequest,
|
|
323
|
-
res: LithiaResponse,
|
|
324
|
-
): Promise<Error | null> {
|
|
325
|
-
let currentIndex = 0;
|
|
326
|
-
|
|
327
|
-
const next = () => {
|
|
328
|
-
currentIndex++;
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
for (let i = 0; i < middlewares.length; i++) {
|
|
333
|
-
currentIndex = i;
|
|
334
|
-
await middlewares[i](req, res, next);
|
|
335
|
-
|
|
336
|
-
// If response was ended by middleware, stop processing
|
|
337
|
-
if (res._ended) {
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// If next() wasn't called, stop middleware chain
|
|
342
|
-
if (currentIndex === i) {
|
|
343
|
-
return null;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return null;
|
|
348
|
-
} catch (err) {
|
|
349
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Sends a structured 404 JSON response for unmatched routes.
|
|
355
|
-
*
|
|
356
|
-
* This method is called when no route matches the requested path.
|
|
357
|
-
* It sends a standardized error response with status 404.
|
|
358
|
-
*
|
|
359
|
-
* @param req - The HTTP request that didn't match any route
|
|
360
|
-
* @param res - The HTTP response object
|
|
361
|
-
*
|
|
362
|
-
* @private
|
|
363
|
-
*/
|
|
364
|
-
private sendNotFound(req: LithiaRequest, res: LithiaResponse): void {
|
|
365
|
-
const response: RequestErrorInfo = {
|
|
366
|
-
error: {
|
|
367
|
-
message: "The requested resource was not found",
|
|
368
|
-
statusCode: 404,
|
|
369
|
-
timestamp: new Date().toISOString(),
|
|
370
|
-
path: req.pathname,
|
|
371
|
-
method: req.method,
|
|
372
|
-
},
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
res.status(404).json(response);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Centralized error handler for the request pipeline.
|
|
380
|
-
*
|
|
381
|
-
* This method handles all errors thrown during request processing:
|
|
382
|
-
*
|
|
383
|
-
* **Development mode:**
|
|
384
|
-
* - Returns detailed error messages
|
|
385
|
-
* - Includes full error stacks
|
|
386
|
-
* - Shows validation issues if present
|
|
387
|
-
*
|
|
388
|
-
* **Production mode:**
|
|
389
|
-
* - Returns generic error messages
|
|
390
|
-
* - Includes error digest for log correlation
|
|
391
|
-
* - Hides sensitive error details
|
|
392
|
-
*
|
|
393
|
-
* All server errors (5xx) are logged with the error digest for correlation
|
|
394
|
-
* between client responses and server logs.
|
|
395
|
-
*
|
|
396
|
-
* @param err - The error that was thrown
|
|
397
|
-
* @param req - The HTTP request that caused the error
|
|
398
|
-
* @param res - The HTTP response object
|
|
399
|
-
* @param start - High-resolution timestamp when request processing started
|
|
400
|
-
*
|
|
401
|
-
* @private
|
|
402
|
-
*/
|
|
403
|
-
private handleError(
|
|
404
|
-
err: unknown,
|
|
405
|
-
req: LithiaRequest,
|
|
406
|
-
res: LithiaResponse,
|
|
407
|
-
start: bigint,
|
|
408
|
-
) {
|
|
409
|
-
const isDevelopment = this.lithia.getEnvironment() === "development";
|
|
410
|
-
|
|
411
|
-
// Don't send error response if already sent
|
|
412
|
-
if (res._ended) {
|
|
413
|
-
this.logRequest(req, res, start);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Generate error digest (both dev and prod)
|
|
418
|
-
const digest = this.generateErrorDigest(err);
|
|
419
|
-
|
|
420
|
-
// Build error details
|
|
421
|
-
const errorMessage =
|
|
422
|
-
err instanceof Error ? err.message : "Internal Server Error";
|
|
423
|
-
const errorStack = err instanceof Error ? err.stack : undefined;
|
|
424
|
-
let statusCode = 500;
|
|
425
|
-
let clientMessage = isDevelopment
|
|
426
|
-
? errorMessage
|
|
427
|
-
: "An internal server error occurred";
|
|
428
|
-
let issues: any[] | undefined;
|
|
429
|
-
|
|
430
|
-
if (err instanceof ValidationError) {
|
|
431
|
-
statusCode = 400;
|
|
432
|
-
clientMessage = err.message;
|
|
433
|
-
issues = err.issues;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (statusCode >= 500) {
|
|
437
|
-
// Log error with digest (same format for both environments)
|
|
438
|
-
logger.error(`[Digest: ${red(digest)}] ${errorStack}`);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Build error response
|
|
442
|
-
const response: RequestErrorInfo = {
|
|
443
|
-
error: {
|
|
444
|
-
message: clientMessage,
|
|
445
|
-
statusCode,
|
|
446
|
-
timestamp: new Date().toISOString(),
|
|
447
|
-
path: req.pathname,
|
|
448
|
-
method: req.method,
|
|
449
|
-
digest: digest,
|
|
450
|
-
issues,
|
|
451
|
-
},
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
res.status(statusCode).json(response);
|
|
455
|
-
this.logRequest(req, res, start);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Logs a completed request with color-coded status and timing.
|
|
460
|
-
*
|
|
461
|
-
* The log includes:
|
|
462
|
-
* - HTTP status code (color-coded by range)
|
|
463
|
-
* - HTTP method (GET, POST, etc.)
|
|
464
|
-
* - Request pathname
|
|
465
|
-
* - Processing duration in milliseconds
|
|
466
|
-
*
|
|
467
|
-
* Status colors:
|
|
468
|
-
* - 2xx: Green (success)
|
|
469
|
-
* - 3xx: Cyan (redirect)
|
|
470
|
-
* - 4xx: Yellow (client error)
|
|
471
|
-
* - 5xx: Red (server error)
|
|
472
|
-
*
|
|
473
|
-
* @param req - The HTTP request that was processed
|
|
474
|
-
* @param res - The HTTP response that was sent
|
|
475
|
-
* @param start - High-resolution timestamp when processing started
|
|
476
|
-
*
|
|
477
|
-
* @private
|
|
478
|
-
*/
|
|
479
|
-
/**
|
|
480
|
-
* Logs a completed request with color-coded status and timing.
|
|
481
|
-
*
|
|
482
|
-
* The log includes:
|
|
483
|
-
* - HTTP status code (color-coded by range)
|
|
484
|
-
* - HTTP method (GET, POST, etc.)
|
|
485
|
-
* - Request pathname
|
|
486
|
-
* - Processing duration in milliseconds
|
|
487
|
-
*
|
|
488
|
-
* Status colors:
|
|
489
|
-
* - 2xx: Green (success)
|
|
490
|
-
* - 3xx: Cyan (redirect)
|
|
491
|
-
* - 4xx: Yellow (client error)
|
|
492
|
-
* - 5xx: Red (server error)
|
|
493
|
-
*
|
|
494
|
-
* Respects the `logging.requests` configuration flag. Critical errors
|
|
495
|
-
* (5xx) are always logged regardless of the flag.
|
|
496
|
-
*
|
|
497
|
-
* @param req - The HTTP request that was processed
|
|
498
|
-
* @param res - The HTTP response that was sent
|
|
499
|
-
* @param start - High-resolution timestamp when processing started
|
|
500
|
-
*
|
|
501
|
-
* @private
|
|
502
|
-
*/
|
|
503
|
-
private logRequest(req: LithiaRequest, res: LithiaResponse, start: bigint) {
|
|
504
|
-
const status = res.statusCode || 200;
|
|
505
|
-
|
|
506
|
-
// Always log critical errors (5xx), otherwise respect the logging.requests flag
|
|
507
|
-
const shouldLog =
|
|
508
|
-
status >= 500 || this.lithia.options.logging?.requests !== false;
|
|
509
|
-
|
|
510
|
-
if (!shouldLog) return;
|
|
511
|
-
|
|
512
|
-
const end = process.hrtime.bigint();
|
|
513
|
-
const duration = Number(end - start) / 1_000_000;
|
|
514
|
-
const durationStr = `${duration.toFixed(2)}ms`;
|
|
515
|
-
|
|
516
|
-
let statusStr = status.toString();
|
|
517
|
-
|
|
518
|
-
if (status >= 500) {
|
|
519
|
-
statusStr = red(statusStr);
|
|
520
|
-
} else if (status >= 400) {
|
|
521
|
-
statusStr = yellow(statusStr);
|
|
522
|
-
} else if (status >= 300) {
|
|
523
|
-
statusStr = cyan(statusStr);
|
|
524
|
-
} else {
|
|
525
|
-
statusStr = green(statusStr);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
logger.info(
|
|
529
|
-
`[${statusStr}] ${req.method} ${req.pathname} - ${durationStr}`,
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Generates a short hexadecimal digest for error correlation.
|
|
535
|
-
*
|
|
536
|
-
* The digest is used to correlate server-side error logs with client-facing
|
|
537
|
-
* error responses. This allows developers to find the detailed error in logs
|
|
538
|
-
* using the digest shown to the client.
|
|
539
|
-
*
|
|
540
|
-
* The digest is generated from:
|
|
541
|
-
* - Error message and stack
|
|
542
|
-
* - Current timestamp
|
|
543
|
-
* - Random factor for uniqueness
|
|
544
|
-
*
|
|
545
|
-
* @param err - The error to generate a digest for
|
|
546
|
-
* @returns An 8-character hexadecimal digest
|
|
547
|
-
*
|
|
548
|
-
* @private
|
|
549
|
-
*/
|
|
550
|
-
private generateErrorDigest(err: unknown): string {
|
|
551
|
-
// Create a unique digest based on error message, timestamp, and random factor
|
|
552
|
-
const errorString =
|
|
553
|
-
err instanceof Error ? `${err.message}${err.stack}` : String(err);
|
|
554
|
-
|
|
555
|
-
const hash = createHash("sha256")
|
|
556
|
-
.update(`${errorString}${Date.now()}${Math.random()}`)
|
|
557
|
-
.digest("hex");
|
|
558
|
-
|
|
559
|
-
// Return first 8 characters (similar to Next.js)
|
|
560
|
-
return hash.substring(0, 8);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Handles CORS preflight requests and applies CORS headers.
|
|
565
|
-
*
|
|
566
|
-
* This method:
|
|
567
|
-
* 1. Checks if the request origin is allowed based on CORS configuration
|
|
568
|
-
* 2. Adds appropriate CORS headers to the response
|
|
569
|
-
* 3. Handles OPTIONS preflight requests
|
|
570
|
-
*
|
|
571
|
-
* **CORS Headers Applied:**
|
|
572
|
-
* - `Access-Control-Allow-Origin`: Allowed origin
|
|
573
|
-
* - `Access-Control-Allow-Credentials`: If credentials are enabled
|
|
574
|
-
* - `Access-Control-Allow-Methods`: Allowed HTTP methods
|
|
575
|
-
* - `Access-Control-Allow-Headers`: Allowed request headers
|
|
576
|
-
* - `Access-Control-Max-Age`: Preflight cache duration
|
|
577
|
-
* - `Access-Control-Expose-Headers`: Headers exposed to client
|
|
578
|
-
*
|
|
579
|
-
* @param req - The HTTP request
|
|
580
|
-
* @param res - The HTTP response
|
|
581
|
-
* @returns true if the request was an OPTIONS preflight that was handled, false otherwise
|
|
582
|
-
*
|
|
583
|
-
* @private
|
|
584
|
-
*/
|
|
585
|
-
private handleCors(req: LithiaRequest, res: LithiaResponse): boolean {
|
|
586
|
-
const cors = this.lithia.options.http.cors;
|
|
587
|
-
if (!cors) return false;
|
|
588
|
-
|
|
589
|
-
const origin = req.headers.origin as string;
|
|
590
|
-
|
|
591
|
-
// Check if origin is allowed
|
|
592
|
-
let allowedOrigin: string | undefined;
|
|
593
|
-
|
|
594
|
-
// Handle credentials with wildcard origin
|
|
595
|
-
if (cors.credentials && cors.origin?.includes("*")) {
|
|
596
|
-
allowedOrigin = origin;
|
|
597
|
-
} else if (cors.origin?.includes("*")) {
|
|
598
|
-
allowedOrigin = "*";
|
|
599
|
-
} else if (origin && cors.origin?.includes(origin)) {
|
|
600
|
-
allowedOrigin = origin;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
if (allowedOrigin) {
|
|
604
|
-
res.addHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
605
|
-
res.addHeader("Vary", "Origin");
|
|
606
|
-
|
|
607
|
-
if (cors.credentials) {
|
|
608
|
-
res.addHeader("Access-Control-Allow-Credentials", "true");
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (req.method === "OPTIONS") {
|
|
612
|
-
if (cors.methods) {
|
|
613
|
-
res.addHeader(
|
|
614
|
-
"Access-Control-Allow-Methods",
|
|
615
|
-
cors.methods.join(", "),
|
|
616
|
-
);
|
|
617
|
-
}
|
|
618
|
-
if (cors.allowedHeaders) {
|
|
619
|
-
res.addHeader(
|
|
620
|
-
"Access-Control-Allow-Headers",
|
|
621
|
-
cors.allowedHeaders.join(", "),
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
if (cors.maxAge) {
|
|
625
|
-
res.addHeader("Access-Control-Max-Age", cors.maxAge.toString());
|
|
626
|
-
}
|
|
627
|
-
res.status(204).end();
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if (cors.exposedHeaders) {
|
|
632
|
-
res.addHeader(
|
|
633
|
-
"Access-Control-Expose-Headers",
|
|
634
|
-
cors.exposedHeaders.join(", "),
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
return false;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Attempts to serve a static file from the configured static directory.
|
|
644
|
-
*
|
|
645
|
-
* This method:
|
|
646
|
-
* 1. Checks if static file serving is enabled
|
|
647
|
-
* 2. Only serves for GET and HEAD requests
|
|
648
|
-
* 3. Strips configured prefix from the path
|
|
649
|
-
* 4. Prevents directory traversal attacks
|
|
650
|
-
* 5. Determines MIME type from file extension
|
|
651
|
-
* 6. Sends the file with appropriate Content-Type header
|
|
652
|
-
*
|
|
653
|
-
* @param req - The HTTP request
|
|
654
|
-
* @param res - The HTTP response
|
|
655
|
-
* @returns true if a static file was served, false otherwise
|
|
656
|
-
* @throws {StaticFileMimeMissingError} If file extension has no configured MIME type
|
|
657
|
-
*
|
|
658
|
-
* @private
|
|
659
|
-
*/
|
|
660
|
-
private async serveStaticFile(
|
|
661
|
-
req: LithiaRequest,
|
|
662
|
-
res: LithiaResponse,
|
|
663
|
-
): Promise<boolean> {
|
|
664
|
-
const staticConfig = this.lithia.options.static;
|
|
665
|
-
if (!staticConfig || !staticConfig.root) return false;
|
|
666
|
-
|
|
667
|
-
// Skip if method is not GET or HEAD
|
|
668
|
-
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
669
|
-
|
|
670
|
-
let filePath = req.pathname;
|
|
671
|
-
|
|
672
|
-
// Handle prefix stripping
|
|
673
|
-
if (staticConfig.prefix) {
|
|
674
|
-
if (!filePath.startsWith(staticConfig.prefix)) return false;
|
|
675
|
-
filePath = filePath.slice(staticConfig.prefix.length);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Prevent directory traversal
|
|
679
|
-
const normalizedPath = join(staticConfig.root, filePath);
|
|
680
|
-
if (filePath.includes("..")) return false;
|
|
681
|
-
|
|
682
|
-
try {
|
|
683
|
-
const stats = statSync(normalizedPath);
|
|
684
|
-
if (stats.isFile()) {
|
|
685
|
-
const ext = extname(normalizedPath).toLowerCase();
|
|
686
|
-
const mime = this.lithia.options.http.mimeTypes?.[ext];
|
|
687
|
-
|
|
688
|
-
if (!mime) {
|
|
689
|
-
throw new StaticFileMimeMissingError(ext, filePath);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
res.addHeader("Content-Type", mime);
|
|
693
|
-
res.sendFile(normalizedPath);
|
|
694
|
-
return true;
|
|
695
|
-
}
|
|
696
|
-
} catch (e) {
|
|
697
|
-
if (e instanceof LithiaError) throw e;
|
|
698
|
-
// file not found or other error, fallback to routes
|
|
699
|
-
}
|
|
700
|
-
return false;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/**
|
|
704
|
-
* Finds a route matching the given pathname and HTTP method.
|
|
705
|
-
*
|
|
706
|
-
* Routes are matched in the order they were registered. The first route
|
|
707
|
-
* that matches both the path pattern (via regex) and HTTP method is returned.
|
|
708
|
-
*
|
|
709
|
-
* @param pathname - The request pathname to match
|
|
710
|
-
* @param method - The HTTP method (GET, POST, etc.)
|
|
711
|
-
* @returns The matched Route, or undefined if no match found
|
|
712
|
-
*
|
|
713
|
-
* @private
|
|
714
|
-
*/
|
|
715
|
-
private findMatchingRoute(
|
|
716
|
-
pathname: string,
|
|
717
|
-
method: string,
|
|
718
|
-
): Route | undefined {
|
|
719
|
-
const routes = this.lithia.getRoutes();
|
|
720
|
-
|
|
721
|
-
return routes.find((route) => {
|
|
722
|
-
// Check method match
|
|
723
|
-
const methodMatches =
|
|
724
|
-
!route.method || route.method.toUpperCase() === method.toUpperCase();
|
|
725
|
-
|
|
726
|
-
// Check path match using regex
|
|
727
|
-
const pathMatches = this.matchesPath(pathname, route);
|
|
728
|
-
|
|
729
|
-
return methodMatches && pathMatches;
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* Tests whether a pathname matches a route's regex pattern.
|
|
735
|
-
*
|
|
736
|
-
* @param pathname - The pathname to test
|
|
737
|
-
* @param route - The route with the regex pattern
|
|
738
|
-
* @returns true if the pathname matches the route's regex, false otherwise
|
|
739
|
-
*
|
|
740
|
-
* @private
|
|
741
|
-
*/
|
|
742
|
-
private matchesPath(pathname: string, route: Route): boolean {
|
|
743
|
-
try {
|
|
744
|
-
const regex = new RegExp(route.regex);
|
|
745
|
-
return regex.test(pathname);
|
|
746
|
-
} catch (err) {
|
|
747
|
-
logger.error(`Invalid route regex for ${route.path}:`, err);
|
|
748
|
-
return false;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* Imports a route module from the file system.
|
|
754
|
-
*
|
|
755
|
-
* In **development mode**, uses cache-busting to reload the module on each
|
|
756
|
-
* request, enabling hot reloading without server restart.
|
|
757
|
-
*
|
|
758
|
-
* In **production mode**, uses standard dynamic imports with caching for
|
|
759
|
-
* better performance.
|
|
760
|
-
*
|
|
761
|
-
* The method also validates the module structure:
|
|
762
|
-
* - Must have a default export
|
|
763
|
-
* - Default export must be a function
|
|
764
|
-
* - Default export must be async
|
|
765
|
-
*
|
|
766
|
-
* @param route - The route whose module should be imported
|
|
767
|
-
* @returns The loaded and validated route module
|
|
768
|
-
* @throws {InvalidRouteModuleError} If the module structure is invalid
|
|
769
|
-
* @throws {Error} If the module fails to load
|
|
770
|
-
*
|
|
771
|
-
* @private
|
|
772
|
-
*/
|
|
773
|
-
private async importRouteModule(route: Route): Promise<RouteModule> {
|
|
774
|
-
try {
|
|
775
|
-
const isDevelopment = this.lithia.getEnvironment() === "development";
|
|
776
|
-
|
|
777
|
-
const mod = await coldImport<RouteModule>(route.filePath, isDevelopment);
|
|
778
|
-
|
|
779
|
-
if (!mod.default) {
|
|
780
|
-
throw new InvalidRouteModuleError(
|
|
781
|
-
route.filePath,
|
|
782
|
-
"missing default export",
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
if (typeof mod.default !== "function") {
|
|
787
|
-
throw new InvalidRouteModuleError(
|
|
788
|
-
route.filePath,
|
|
789
|
-
"default export is not a function",
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
if (!isAsyncFunction(mod.default)) {
|
|
794
|
-
throw new InvalidRouteModuleError(
|
|
795
|
-
route.filePath,
|
|
796
|
-
"default export is not an async function",
|
|
797
|
-
);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
return mod;
|
|
801
|
-
} catch (err) {
|
|
802
|
-
if (err instanceof InvalidRouteModuleError) {
|
|
803
|
-
throw err;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
throw new Error(`Failed to import route: ${route.path}`);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Extracts named route parameters from the pathname.
|
|
812
|
-
*
|
|
813
|
-
* For dynamic routes like `/users/:id/posts/:postId`, this method:
|
|
814
|
-
* 1. Matches the pathname against the route's regex
|
|
815
|
-
* 2. Extracts parameter names from the route path (e.g., "id", "postId")
|
|
816
|
-
* 3. Maps regex capture groups to parameter names
|
|
817
|
-
* 4. URL-decodes parameter values
|
|
818
|
-
*
|
|
819
|
-
* @param pathname - The request pathname
|
|
820
|
-
* @param route - The matched route with dynamic segments
|
|
821
|
-
* @returns Object mapping parameter names to their values
|
|
822
|
-
*
|
|
823
|
-
* @private
|
|
824
|
-
*
|
|
825
|
-
* @example
|
|
826
|
-
* ```typescript
|
|
827
|
-
* // Route: /users/:id/posts/:postId
|
|
828
|
-
* // Pathname: /users/123/posts/456
|
|
829
|
-
* // Returns: { id: '123', postId: '456' }
|
|
830
|
-
* ```
|
|
831
|
-
*/
|
|
832
|
-
private extractParams(pathname: string, route: Route): Params {
|
|
833
|
-
const params: Params = {};
|
|
834
|
-
|
|
835
|
-
try {
|
|
836
|
-
const regex = new RegExp(route.regex);
|
|
837
|
-
const match = pathname.match(regex);
|
|
838
|
-
|
|
839
|
-
if (!match) return params;
|
|
840
|
-
|
|
841
|
-
// Extract parameter names from the route path
|
|
842
|
-
const paramNames = (route.path.match(/:([^/]+)/g) || []).map((p) =>
|
|
843
|
-
p.slice(1),
|
|
844
|
-
);
|
|
845
|
-
|
|
846
|
-
// Match groups start at index 1 (index 0 is full match)
|
|
847
|
-
paramNames.forEach((name, idx) => {
|
|
848
|
-
const value = match[idx + 1];
|
|
849
|
-
if (value !== undefined) {
|
|
850
|
-
params[name] = decodeURIComponent(value);
|
|
851
|
-
}
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
return params;
|
|
855
|
-
} catch (err) {
|
|
856
|
-
logger.error(`Failed to extract params from ${pathname}:`, err);
|
|
857
|
-
return params;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|