bunigniter 0.2.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/README.md +229 -0
  5. package/dist/base/controller.ts +324 -0
  6. package/dist/base/index.ts +5 -0
  7. package/dist/base/service.ts +21 -0
  8. package/dist/cli/index.ts +318 -0
  9. package/dist/cli/list-routes.ts +72 -0
  10. package/dist/cli/repl.ts +461 -0
  11. package/dist/cli/templates.ts +283 -0
  12. package/dist/client/index.ts +159 -0
  13. package/dist/db/drizzle.ts +550 -0
  14. package/dist/db/validators.ts +229 -0
  15. package/dist/edge-builder.ts +120 -0
  16. package/dist/edge.ts +69 -0
  17. package/dist/helpers/cache.ts +173 -0
  18. package/dist/helpers/cors.ts +103 -0
  19. package/dist/helpers/csrf.ts +155 -0
  20. package/dist/helpers/debug.ts +158 -0
  21. package/dist/helpers/env.ts +147 -0
  22. package/dist/helpers/handler.ts +158 -0
  23. package/dist/helpers/http.ts +194 -0
  24. package/dist/helpers/image.ts +217 -0
  25. package/dist/helpers/jwt.ts +147 -0
  26. package/dist/helpers/logger.ts +96 -0
  27. package/dist/helpers/mail.ts +272 -0
  28. package/dist/helpers/middleware-loader.ts +116 -0
  29. package/dist/helpers/middleware.ts +57 -0
  30. package/dist/helpers/modules.ts +115 -0
  31. package/dist/helpers/openapi.ts +140 -0
  32. package/dist/helpers/pagination.ts +159 -0
  33. package/dist/helpers/queue.ts +186 -0
  34. package/dist/helpers/request-context.ts +13 -0
  35. package/dist/helpers/request.ts +376 -0
  36. package/dist/helpers/schedule.ts +173 -0
  37. package/dist/helpers/session-middleware.ts +89 -0
  38. package/dist/helpers/session.ts +286 -0
  39. package/dist/helpers/sse.ts +90 -0
  40. package/dist/helpers/throttle.ts +156 -0
  41. package/dist/helpers/upload.ts +417 -0
  42. package/dist/helpers/validator.ts +287 -0
  43. package/dist/helpers/ws.ts +123 -0
  44. package/dist/index.ts +221 -0
  45. package/dist/package.json +70 -0
  46. package/dist/router/file-router.ts +541 -0
  47. package/dist/router/server-router.ts +103 -0
  48. package/dist/view/page.ts +96 -0
  49. package/dist/view/renderer.tsx +390 -0
  50. package/dist/view/view-response.ts +10 -0
  51. package/package.json +70 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * File Router — CodeIgniter-style file-path routing.
3
+ *
4
+ * Maps file paths to URL routes automatically:
5
+ * ```
6
+ * pages/
7
+ * ├── users.ts → GET/POST /api/users
8
+ * ├── users/
9
+ * │ └── [id].ts → GET /api/users/:id
10
+ * ├── auth/
11
+ * │ └── login.ts → GET/POST /api/auth/login
12
+ * └── index.ts → GET /api
13
+ * ```
14
+ *
15
+ * Convention:
16
+ * - `pages/users.ts` → `/api/users`
17
+ * - `pages/users/[id].ts` → `/api/users/:id`
18
+ * - `pages/index.ts` → `/api/`
19
+ * - Exported class extending `Controller` is auto-registered
20
+ * - Method names map to HTTP verbs: `index`=GET, `show`=GET/:id, `create`=POST, `update`=PUT, `destroy`=DELETE
21
+ */
22
+
23
+ import { readdirSync, statSync, existsSync } from "node:fs";
24
+ import { join, basename } from "node:path";
25
+ import { type Elysia, t } from "elysia";
26
+ import type { Controller } from "../base/controller";
27
+ import type { DbClient } from "../db/drizzle";
28
+ import type { Cache } from "../helpers/cache";
29
+ import type { Queue } from "../helpers/queue";
30
+ import type { Upload } from "../helpers/upload";
31
+ import type { Mail } from "../helpers/mail";
32
+ import { Session } from "../helpers/session";
33
+ import { PageResponse } from "../view/page";
34
+ import { ViewResponse } from "../view/view-response";
35
+ import { renderView } from "../view/renderer";
36
+ import { generateToolbar, getStore } from "../helpers/debug";
37
+ import { setRequestContext } from "../helpers/request-context";
38
+
39
+ export interface FileRouterOptions {
40
+ /** Directory containing route files. Default: `routes` */
41
+ directory?: string;
42
+
43
+ /** Views directory for module support. Overrides global views. */
44
+ viewsDir?: string;
45
+
46
+ /** URL prefix for all routes. Default: `/api` */
47
+ prefix?: string;
48
+
49
+ /** Database instance to inject into controllers. */
50
+ db?: DbClient;
51
+
52
+ /** Named databases (multi-database support). */
53
+ dbs?: Record<string, DbClient>;
54
+
55
+ /** Cache instance. */
56
+ cache?: Cache;
57
+
58
+ /** Queue instance. */
59
+ queue?: Queue;
60
+
61
+ /** Upload instance. */
62
+ upload?: Upload;
63
+
64
+ /** Mail instance. */
65
+ mail?: Mail;
66
+
67
+ /** Called when a controller is registered (for DI/decoration). */
68
+ onRegister?: (controller: Controller) => void;
69
+ }
70
+
71
+ interface LoaderExport {
72
+ loader?: (ctx: any) => Promise<Record<string, any>>;
73
+ action?:
74
+ | ((ctx: any, args?: any) => Promise<void>)
75
+ | { config?: any; fn?: any };
76
+ }
77
+
78
+ /** Render a page component to HTML (server-side). */
79
+ function renderPage(component: string, props: Record<string, any>): string {
80
+ // Build props JSON to embed in HTML shell
81
+ const propsJson = escapeHtml(JSON.stringify(props));
82
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${escapeHtml(component)}</title></head><body><div id="app" data-page='${propsJson}'></div></body></html>`;
83
+ }
84
+
85
+ function escapeHtml(s: string): string {
86
+ return s
87
+ .replace(/&/g, "&amp;")
88
+ .replace(/'/g, "&#39;")
89
+ .replace(/"/g, "&quot;")
90
+ .replace(/</g, "&lt;")
91
+ .replace(/>/g, "&gt;");
92
+ }
93
+
94
+ /** Default method-to-verb mapping (CodeIgniter-style). */
95
+ const METHOD_MAP: Record<string, string> = Object.assign(Object.create(null), {
96
+ index: "GET",
97
+ show: "GET",
98
+ create: "POST",
99
+ store: "POST",
100
+ update: "PUT",
101
+ destroy: "DELETE",
102
+ edit: "GET",
103
+ });
104
+
105
+ /** Methods that need a param ID. */
106
+ const ID_METHODS = new Set(["show", "update", "destroy", "edit"]);
107
+
108
+ /**
109
+ * Scan a directory and auto-register routes.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * // routes/users.ts — handles GET /api/users, POST /api/users
114
+ * export class Users extends Controller {
115
+ * async index() { return this.json(await this.db.query('SELECT * FROM users')) }
116
+ * async show(id: number) { return this.json(await this.db.query('SELECT * FROM users WHERE id = ?', [id])) }
117
+ * async create() { const body = await this.body; ... }
118
+ * }
119
+ * ```
120
+ */
121
+ export async function registerFileRoutes(
122
+ app: Elysia,
123
+ options: FileRouterOptions = {},
124
+ ): Promise<void> {
125
+ const dir = options.directory ?? "routes";
126
+ const prefix = options.prefix ?? "/api";
127
+
128
+ // Ensure directory exists
129
+ try {
130
+ statSync(dir);
131
+ } catch {
132
+ console.warn(`[router] routes directory not found: ${dir}`);
133
+ return;
134
+ }
135
+
136
+ // First pass: register .server.ts loader/action routes
137
+ const serverFiles = scanDir(dir, ".server.ts");
138
+ for (const file of serverFiles) {
139
+ const fullPath = join(dir, file);
140
+ const serverMod = (await import(
141
+ /* @vite-ignore */ join(process.cwd(), dir, file)
142
+ )) as LoaderExport;
143
+ const componentName = file.replace(/\.server\.ts$/, "");
144
+ const urlPath = filePathToUrl(componentName + ".ts", prefix);
145
+
146
+ if (serverMod.loader) {
147
+ const handler = async (_ctx: any) => {
148
+ const props = await (serverMod.loader as Function)(_ctx);
149
+ // Serve as HTML shell (SSR placeholder) + JSON props
150
+ return new Response(renderPage(componentName, props), {
151
+ headers: { "content-type": "text/html; charset=utf-8" },
152
+ });
153
+ };
154
+ registerRoute(app, "GET", urlPath, handler, null as any, options);
155
+ }
156
+
157
+ if (serverMod.action) {
158
+ const action = serverMod.action as any;
159
+ const handler = async (_ctx: any) => {
160
+ let body;
161
+ try {
162
+ body = await _ctx.request.json();
163
+ } catch {
164
+ body = {};
165
+ }
166
+ if (action.fn) {
167
+ await action.fn(_ctx, { body });
168
+ } else {
169
+ await action(_ctx, { body });
170
+ }
171
+ return new Response(null, { status: 204 });
172
+ };
173
+ registerRoute(app, "POST", urlPath, handler, null as any, options);
174
+ }
175
+ }
176
+
177
+ // Second pass: register Controller routes
178
+ const files = scanDir(dir, ".ts");
179
+
180
+ for (const file of files) {
181
+ if (file.endsWith(".server.ts")) continue;
182
+
183
+ const fullPath = join(process.cwd(), dir, file);
184
+ const mod = await import(/* @vite-ignore */ fullPath);
185
+
186
+ // Find the Controller subclass
187
+ const ControllerClass = findController(mod);
188
+
189
+ // If file has ws.handle() calls without a Controller, still import it
190
+ // (WS handlers register via side-effect at module load time)
191
+ if (!ControllerClass) continue;
192
+
193
+ const controller = new ControllerClass() as Controller;
194
+
195
+ // Inject services
196
+ if (options.db) {
197
+ Object.defineProperty(controller, "db", {
198
+ value: options.db,
199
+ writable: false,
200
+ });
201
+ }
202
+ if (options.dbs) {
203
+ Object.defineProperty(controller, "dbs", {
204
+ value: options.dbs,
205
+ writable: false,
206
+ });
207
+ }
208
+ if (options.cache) {
209
+ Object.defineProperty(controller, "cache", {
210
+ value: options.cache,
211
+ writable: false,
212
+ });
213
+ }
214
+ if (options.queue) {
215
+ Object.defineProperty(controller, "queue", {
216
+ value: options.queue,
217
+ writable: false,
218
+ });
219
+ }
220
+ if (options.upload) {
221
+ Object.defineProperty(controller, "upload", {
222
+ value: options.upload,
223
+ writable: false,
224
+ });
225
+ }
226
+ if (options.mail) {
227
+ Object.defineProperty(controller, "mail", {
228
+ value: options.mail,
229
+ writable: false,
230
+ });
231
+ }
232
+
233
+ // Call onRegister hook
234
+ options.onRegister?.(controller);
235
+
236
+ // Convert file path to URL path
237
+ const urlPath = filePathToUrl(file, prefix);
238
+
239
+ // Register routes for each Controller method
240
+ const isIndex = basename(file, ".ts") === "index";
241
+
242
+ for (const method of ["index", "show", "create", "update", "destroy"]) {
243
+ if (typeof (controller as any)[method] !== "function") continue;
244
+ const verb = METHOD_MAP[method];
245
+ const handler = (controller as any)[method].bind(controller);
246
+ const methodPath = ID_METHODS.has(method)
247
+ ? isIndex
248
+ ? `${prefix}/:id`
249
+ : `${urlPath}/:id`
250
+ : isIndex
251
+ ? prefix
252
+ : urlPath;
253
+
254
+ registerRoute(app, verb, methodPath, handler, controller, options);
255
+ }
256
+ }
257
+ }
258
+
259
+ // ─── Internal Helpers ──────────────────────────────────────────
260
+
261
+ let startTime = 0;
262
+
263
+ function formatBytes2(bytes: number): string {
264
+ if (bytes === 0) return "0 MB";
265
+ const mb = bytes / (1024 * 1024);
266
+ return `${mb.toFixed(1)} MB`;
267
+ }
268
+
269
+ /** Inject debug toolbar into HTML if enabled. */
270
+ async function injectDebug(
271
+ html: string,
272
+ ctx: any,
273
+ controller: any,
274
+ status: number,
275
+ ): Promise<string> {
276
+ const dbgParam = new URL(
277
+ ctx.request?.url ?? "http://localhost",
278
+ ).searchParams.get("debug");
279
+ const isDebug = dbgParam === "1" || process.env.DEBUG === "true";
280
+ if (!isDebug || !html.includes("</body>")) return html;
281
+
282
+ try {
283
+ const debugData = getStore(ctx);
284
+ debugData.status = status;
285
+ debugData.duration =
286
+ Math.round((performance.now() - startTime) * 100) / 100;
287
+ debugData.memory = formatBytes2((process as any).memoryUsage?.()?.rss ?? 0);
288
+ debugData.timestamp = new Date().toLocaleString();
289
+ if (controller?.session) debugData.session = controller.session.all();
290
+ if (ctx.request?.headers) {
291
+ const h: Record<string, string> = {};
292
+ for (const [k, v] of ctx.request.headers.entries()) h[k] = v;
293
+ debugData.headers = h;
294
+ }
295
+ const toolbar = await generateToolbar(debugData);
296
+ if (toolbar && toolbar.length > 50) {
297
+ const bodyIdx = html.lastIndexOf("</body>");
298
+ if (bodyIdx > 0) {
299
+ return html.slice(0, bodyIdx) + toolbar + "\n" + html.slice(bodyIdx);
300
+ }
301
+ }
302
+ return html;
303
+ } catch (e) {
304
+ console.error("[debug] toolbar error:", e, (e as Error).stack);
305
+ }
306
+ return html;
307
+ }
308
+
309
+ function scanDir(dir: string, ext: string, baseDir = ""): string[] {
310
+ const files: string[] = [];
311
+ const entries = readdirSync(dir, { withFileTypes: true });
312
+ for (const entry of entries) {
313
+ const relPath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
314
+ if (entry.isDirectory()) {
315
+ files.push(...scanDir(join(dir, entry.name), ext, relPath));
316
+ } else if (
317
+ entry.isFile() &&
318
+ entry.name.endsWith(ext) &&
319
+ !entry.name.startsWith("_")
320
+ ) {
321
+ files.push(relPath);
322
+ }
323
+ }
324
+ return files.sort();
325
+ }
326
+
327
+ function filePathToUrl(file: string, prefix: string): string {
328
+ const url = file
329
+ .replace(/\.ts$/, "")
330
+ .replace(/\[\.\.\.\]/g, "*")
331
+ .replace(/\[([^\]]+)\]/g, ":$1")
332
+ .replace(/\/index$/, "")
333
+ .replace(/\\/g, "/");
334
+
335
+ return `${prefix}${url ? `/${url}` : ""}`;
336
+ }
337
+
338
+ function findController(
339
+ mod: Record<string, any>,
340
+ ): (new () => Controller) | null {
341
+ for (const key of Object.keys(mod)) {
342
+ const val = mod[key];
343
+ if (
344
+ typeof val === "function" &&
345
+ val.prototype &&
346
+ val.prototype.constructor
347
+ ) {
348
+ // Check if it extends Controller
349
+ let proto = val.prototype;
350
+ while (proto) {
351
+ if (proto.constructor.name === "Controller") return val;
352
+ proto = Object.getPrototypeOf(proto);
353
+ }
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+
359
+ function registerRoute(
360
+ app: Elysia,
361
+ verb: string,
362
+ path: string,
363
+ handler: (...args: any[]) => any,
364
+ controller: Controller,
365
+ options: FileRouterOptions,
366
+ ): void {
367
+ const lowerVerb = verb.toLowerCase() as
368
+ | "get"
369
+ | "post"
370
+ | "put"
371
+ | "delete"
372
+ | "patch";
373
+
374
+ // Determine if this route needs an ID param based on the path
375
+ const needsId = path.endsWith("/:id");
376
+
377
+ // Wrap handler: inject ctx + session + auth into controller
378
+ const wrappedHandler = async (_ctx: any) => {
379
+ setRequestContext(_ctx);
380
+ startTime = performance.now();
381
+ let session: Session | null = null;
382
+ const cookieName = "nexus_session";
383
+
384
+ if (controller) {
385
+ (controller as any).ctx = _ctx;
386
+
387
+ // Set upload body for auto-detection
388
+ if ((controller as any).upload) {
389
+ (controller as any).upload.body = _ctx.body;
390
+ }
391
+
392
+ // Create session from cookie
393
+ session = new Session();
394
+ const cookieHeader = _ctx.request?.headers?.get("cookie") ?? "";
395
+ const match = cookieHeader.match(new RegExp(cookieName + "=([^;]+)"));
396
+ session.load(match?.[1]);
397
+ (controller as any).session = session;
398
+
399
+ // Create auth facade
400
+ (controller as any).auth = {
401
+ user: () => session?.get("user"),
402
+ login: (user: any) => {
403
+ session?.set("user", user);
404
+ session?.regenerate();
405
+ },
406
+ logout: () => {
407
+ session?.delete("user");
408
+ session?.clear();
409
+ },
410
+ check: () => !!session?.get("user"),
411
+ };
412
+ }
413
+
414
+ try {
415
+ // Handle _method override for HTML forms (PUT/DELETE via POST)
416
+ const body = _ctx.body ?? {};
417
+ const overrideMethod = body?._method?.toUpperCase();
418
+ if (
419
+ overrideMethod &&
420
+ ["PUT", "DELETE", "PATCH"].includes(overrideMethod)
421
+ ) {
422
+ _ctx.__method = overrideMethod;
423
+ }
424
+
425
+ // Call _before() lifecycle hook (can short-circuit with a Response)
426
+ if (controller) {
427
+ const beforeResult = (controller as any)._before?.();
428
+ if (beforeResult instanceof Response) {
429
+ return beforeResult;
430
+ }
431
+ }
432
+
433
+ // Call handler — pass context for server routes, ID for Controller routes
434
+ const id = _ctx.params?.id ? Number(_ctx.params.id) : undefined;
435
+ let result;
436
+ if (controller) {
437
+ result = needsId ? await handler(id) : await handler();
438
+ } else {
439
+ result = await handler(_ctx);
440
+ }
441
+
442
+ // Save session cookie AFTER handler runs
443
+ if (session) {
444
+ const serialized = session.serialize();
445
+ if (serialized) {
446
+ if (!_ctx.set.headers) _ctx.set.headers = {};
447
+ _ctx.set.headers["Set-Cookie"] =
448
+ `${cookieName}=${serialized.value}; Max-Age=${serialized.maxAge}; Path=/; HttpOnly; SameSite=Lax`;
449
+ }
450
+ }
451
+
452
+ if (result instanceof Response) return result;
453
+
454
+ // Handle ViewResponse — SSR render (React or Rendu HTML)
455
+ if (result instanceof ViewResponse) {
456
+ const viewBase = options.viewsDir
457
+ ? join(process.cwd(), options.viewsDir)
458
+ : undefined;
459
+ const htmlOrRes = await renderView(
460
+ result.name,
461
+ result.props,
462
+ result.options,
463
+ viewBase,
464
+ );
465
+ if (htmlOrRes instanceof Response) {
466
+ let text = await htmlOrRes.text();
467
+ text = await injectDebug(text, _ctx, controller, htmlOrRes.status);
468
+ return new Response(text, {
469
+ status: htmlOrRes.status,
470
+ headers: { "content-type": "text/html; charset=utf-8" },
471
+ });
472
+ }
473
+ const html = await injectDebug(htmlOrRes, _ctx, controller, 200);
474
+ return new Response(html, {
475
+ headers: { "content-type": "text/html; charset=utf-8" },
476
+ });
477
+ }
478
+
479
+ // Handle PageResponse — Inertia-style page
480
+ if (result instanceof PageResponse) {
481
+ const isInertia =
482
+ _ctx.headers?.["x-inertia"] === "true" ||
483
+ _ctx.request?.headers?.get("X-Inertia") === "true";
484
+
485
+ if (isInertia) {
486
+ return new Response(result.toInertiaJson(controller?._sharedProps), {
487
+ status: result.options.status ?? 200,
488
+ headers: {
489
+ "content-type": "application/json",
490
+ "x-inertia": "true",
491
+ },
492
+ });
493
+ }
494
+
495
+ // First load: full HTML shell
496
+ const url = _ctx.request?.url ?? "/";
497
+ let html = result.toHtml(controller?._sharedProps, url);
498
+
499
+ // Client script inject
500
+ if (html.includes("</body>")) {
501
+ const publicPath = join(process.cwd(), "public", "app.js");
502
+ if (existsSync(publicPath)) {
503
+ html = html.replace(
504
+ "</body>",
505
+ '<script src="/public/app.js"></script>\n</body>',
506
+ );
507
+ }
508
+ html = await injectDebug(
509
+ html,
510
+ _ctx,
511
+ controller,
512
+ result.options.status ?? 200,
513
+ );
514
+ }
515
+
516
+ return new Response(html, {
517
+ status: result.options.status ?? 200,
518
+ headers: { "content-type": "text/html; charset=utf-8" },
519
+ });
520
+ }
521
+
522
+ if (result !== undefined && result !== null) {
523
+ const status = (result as any)._status ?? 200;
524
+ return new Response(JSON.stringify(result), {
525
+ status,
526
+ headers: { "content-type": "application/json" },
527
+ });
528
+ }
529
+ return new Response(null, { status: 204 });
530
+ } catch (err) {
531
+ console.error(`[ERROR] ${verb} ${path}:`, (err as Error).message);
532
+ return new Response(JSON.stringify({ error: (err as Error).message }), {
533
+ status: 500,
534
+ headers: { "content-type": "application/json" },
535
+ });
536
+ }
537
+ };
538
+
539
+ // Elysia v2.0: .get(path, handler) — schema precedes handler in v2
540
+ (app as any)[lowerVerb](path, wrappedHandler);
541
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Server Router — Void-style file-based routing with method exports.
3
+ *
4
+ * Scans `routes/` directory. Each file exports
5
+ * HTTP method constants (`GET`, `POST`, etc.) created via `defineHandler`.
6
+ *
7
+ * Directory structure:
8
+ * ```
9
+ * routes/
10
+ * ├── api/
11
+ * │ ├── hello.ts → GET|POST /api/hello
12
+ * │ └── users/
13
+ * │ ├── index.ts → GET|POST /api/users
14
+ * │ └── [id].ts → GET|PUT|DELETE /api/users/:id
15
+ * └── webhooks/
16
+ * └── stripe.ts → POST /webhooks/stripe
17
+ * ```
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * // routes/api/users.ts
22
+ * import { defineHandler } from 'nexusts'
23
+ *
24
+ * export const GET = defineHandler(async (c) => {
25
+ * return db.select().from(users)
26
+ * })
27
+ *
28
+ * export const POST = defineHandler.withValidator({
29
+ * body: insertUserSchema
30
+ * })(async (c, { body }) => {
31
+ * return db.insert(users).values(body).returning()
32
+ * })
33
+ * ```
34
+ */
35
+ import { readdirSync, statSync, existsSync } from 'node:fs'
36
+ import { join, basename } from 'node:path'
37
+ import { Elysia } from 'elysia'
38
+
39
+ /**
40
+ * Register all routes from the `routes/` directory.
41
+ * Each file exports `GET`, `POST`, `PUT`, `DELETE`, `PATCH` named constants.
42
+ */
43
+ export async function registerServerRoutes(app: Elysia, dir: string = 'routes', prefix: string = ''): Promise<void> {
44
+ if (!existsSync(dir)) return
45
+
46
+ const files = scanRouteFiles(dir)
47
+
48
+ for (const file of files) {
49
+ const fullPath = join(process.cwd(), dir, file)
50
+ let mod
51
+ try {
52
+ mod = await import(fullPath)
53
+ } catch (e: any) {
54
+ console.error('[server-router] Error importing', file, ':', e.message)
55
+ continue
56
+ }
57
+
58
+ // Build URL path from file path
59
+ const urlPath = routeFilePathToUrl(file, prefix)
60
+
61
+ // Register each exported HTTP method
62
+ const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const
63
+ for (const method of methods) {
64
+ const handler = mod[method]
65
+ if (typeof handler !== 'function') continue
66
+
67
+ const lower = method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch'
68
+ ;(app as any)[lower](urlPath, async (ctx: any) => {
69
+ return handler(ctx)
70
+ })
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Scan for route files, excluding _prefix and .server.ts. */
76
+ function scanRouteFiles(baseDir: string, relativeDir = ''): string[] {
77
+ const dir = join(baseDir, relativeDir)
78
+ const files: string[] = []
79
+ const entries = readdirSync(dir, { withFileTypes: true })
80
+
81
+ for (const entry of entries) {
82
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
83
+
84
+ if (entry.isDirectory()) {
85
+ files.push(...scanRouteFiles(baseDir, relativeDir ? `${relativeDir}/${entry.name}` : entry.name))
86
+ } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.server.ts') && !entry.name.endsWith('.test.ts')) {
87
+ files.push(relativeDir ? `${relativeDir}/${entry.name}` : entry.name)
88
+ }
89
+ }
90
+
91
+ return files.sort()
92
+ }
93
+
94
+ /** Convert file path to URL path, matching Void conventions. */
95
+ function routeFilePathToUrl(file: string, prefix: string): string {
96
+ let url = file
97
+ .replace(/\.ts$/, '')
98
+ .replace(/\/index$/, '')
99
+ .replace(/\[\.\.\.\]/g, '*')
100
+ .replace(/\[(\w+)\]/g, ':$1')
101
+
102
+ return `${prefix}/${url}`
103
+ }