@lithia-js/core 1.0.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +13 -0
  3. package/LICENSE +21 -0
  4. package/README.md +60 -0
  5. package/dist/config.d.ts +101 -0
  6. package/dist/config.js +113 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/context/event-context.d.ts +53 -0
  9. package/dist/context/event-context.js +42 -0
  10. package/dist/context/event-context.js.map +1 -0
  11. package/dist/context/index.d.ts +16 -0
  12. package/dist/context/index.js +29 -0
  13. package/dist/context/index.js.map +1 -0
  14. package/dist/context/lithia-context.d.ts +47 -0
  15. package/dist/context/lithia-context.js +43 -0
  16. package/dist/context/lithia-context.js.map +1 -0
  17. package/dist/context/route-context.d.ts +74 -0
  18. package/dist/context/route-context.js +42 -0
  19. package/dist/context/route-context.js.map +1 -0
  20. package/dist/env.d.ts +1 -0
  21. package/dist/env.js +32 -0
  22. package/dist/env.js.map +1 -0
  23. package/dist/errors.d.ts +51 -0
  24. package/dist/errors.js +80 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/hooks/dependency-hooks.d.ts +105 -0
  27. package/dist/hooks/dependency-hooks.js +96 -0
  28. package/dist/hooks/dependency-hooks.js.map +1 -0
  29. package/dist/hooks/event-hooks.d.ts +61 -0
  30. package/dist/hooks/event-hooks.js +70 -0
  31. package/dist/hooks/event-hooks.js.map +1 -0
  32. package/dist/hooks/index.d.ts +41 -0
  33. package/dist/hooks/index.js +59 -0
  34. package/dist/hooks/index.js.map +1 -0
  35. package/dist/hooks/route-hooks.d.ts +154 -0
  36. package/dist/hooks/route-hooks.js +174 -0
  37. package/dist/hooks/route-hooks.js.map +1 -0
  38. package/dist/lib.d.ts +10 -0
  39. package/dist/lib.js +30 -0
  40. package/dist/lib.js.map +1 -0
  41. package/dist/lithia.d.ts +447 -0
  42. package/dist/lithia.js +649 -0
  43. package/dist/lithia.js.map +1 -0
  44. package/dist/logger.d.ts +11 -0
  45. package/dist/logger.js +55 -0
  46. package/dist/logger.js.map +1 -0
  47. package/dist/module-loader.d.ts +12 -0
  48. package/dist/module-loader.js +78 -0
  49. package/dist/module-loader.js.map +1 -0
  50. package/dist/server/event-processor.d.ts +195 -0
  51. package/dist/server/event-processor.js +253 -0
  52. package/dist/server/event-processor.js.map +1 -0
  53. package/dist/server/http-server.d.ts +196 -0
  54. package/dist/server/http-server.js +295 -0
  55. package/dist/server/http-server.js.map +1 -0
  56. package/dist/server/middlewares/validation.d.ts +12 -0
  57. package/dist/server/middlewares/validation.js +34 -0
  58. package/dist/server/middlewares/validation.js.map +1 -0
  59. package/dist/server/request-processor.d.ts +400 -0
  60. package/dist/server/request-processor.js +652 -0
  61. package/dist/server/request-processor.js.map +1 -0
  62. package/dist/server/request.d.ts +73 -0
  63. package/dist/server/request.js +207 -0
  64. package/dist/server/request.js.map +1 -0
  65. package/dist/server/response.d.ts +69 -0
  66. package/dist/server/response.js +173 -0
  67. package/dist/server/response.js.map +1 -0
  68. package/package.json +46 -0
  69. package/src/config.ts +212 -0
  70. package/src/context/event-context.ts +66 -0
  71. package/src/context/index.ts +32 -0
  72. package/src/context/lithia-context.ts +59 -0
  73. package/src/context/route-context.ts +89 -0
  74. package/src/env.ts +31 -0
  75. package/src/errors.ts +96 -0
  76. package/src/hooks/dependency-hooks.ts +122 -0
  77. package/src/hooks/event-hooks.ts +69 -0
  78. package/src/hooks/index.ts +58 -0
  79. package/src/hooks/route-hooks.ts +177 -0
  80. package/src/lib.ts +27 -0
  81. package/src/lithia.ts +777 -0
  82. package/src/logger.ts +66 -0
  83. package/src/module-loader.ts +45 -0
  84. package/src/server/event-processor.ts +344 -0
  85. package/src/server/http-server.ts +371 -0
  86. package/src/server/middlewares/validation.ts +46 -0
  87. package/src/server/request-processor.ts +860 -0
  88. package/src/server/request.ts +247 -0
  89. package/src/server/response.ts +204 -0
  90. package/tsconfig.build.tsbuildinfo +1 -0
  91. package/tsconfig.json +8 -0
@@ -0,0 +1,860 @@
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
+ }