@enderworld/onlyapi 1.6.0 → 1.7.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.
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Minimal project template — generated by `onlyapi init`.
3
3
  *
4
- * Produces a clean ~12-file project that boots a working API with:
4
+ * Produces a clean project that boots a working API with:
5
5
  * - Health check
6
6
  * - Auth (register / login / logout)
7
7
  * - User profile (me)
8
8
  * - SQLite database (zero-config)
9
9
  * - JWT authentication
10
10
  * - CORS + rate limiting
11
- * - Structured logging
11
+ * - Pretty startup banner + colored request logging
12
+ * - Simple router with route table
12
13
  */
13
14
 
14
15
  export interface TemplateFile {
@@ -195,7 +196,9 @@ src/
195
196
  main.ts # Entry point — wires everything together
196
197
  config.ts # Environment config with validation
197
198
  database.ts # SQLite setup + migrations
198
- server.ts # HTTP server + routing
199
+ logger.ts # Colored structured logger
200
+ router.ts # Lightweight router with route table
201
+ server.ts # HTTP server, CORS, rate limiting
199
202
  handlers/
200
203
  auth.handler.ts # Register, login, logout
201
204
  health.handler.ts # Health check
@@ -232,54 +235,295 @@ docker run -e JWT_SECRET="your-secret-here" -p 3000:3000 ${projectName}
232
235
  `,
233
236
  },
234
237
 
235
- // ── src/main.ts ─────────────────────────────────────────────────────
238
+ // ── src/logger.ts ───────────────────────────────────────────────────
236
239
  {
237
- path: "src/main.ts",
238
- content: `import { loadConfig } from "./config.js";
239
- import { createDatabase } from "./database.js";
240
- import { createAuthService } from "./services/auth.service.js";
241
- import { createUserService } from "./services/user.service.js";
242
- import { createServer } from "./server.js";
243
- import { createPasswordHasher } from "./utils/password.js";
244
- import { createTokenService } from "./utils/token.js";
245
-
246
- // ── Load config ───────────────────────────────────────────────────────
247
- const config = loadConfig();
248
-
249
- // ── Database ──────────────────────────────────────────────────────────
250
- const db = createDatabase(config.databasePath);
251
-
252
- // ── Services ──────────────────────────────────────────────────────────
253
- const passwordHasher = createPasswordHasher();
254
- const tokenService = createTokenService(config.jwt.secret, config.jwt.expiresIn);
255
- const authService = createAuthService(db, passwordHasher, tokenService);
256
- const userService = createUserService(db, passwordHasher);
257
-
258
- // ── Server ────────────────────────────────────────────────────────────
259
- const server = createServer({
260
- config,
261
- authService,
262
- userService,
263
- tokenService,
264
- });
240
+ path: "src/logger.ts",
241
+ content: [
242
+ "/**",
243
+ " * Colored structured logger — zero dependencies.",
244
+ " */",
245
+ "",
246
+ 'type LogLevel = "debug" | "info" | "warn" | "error";',
247
+ "",
248
+ "const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };",
249
+ "",
250
+ "// ANSI colors",
251
+ 'const esc = (c: string) => "\\x1b[" + c + "m";',
252
+ 'const reset = esc("0");',
253
+ 'const bold = (s: string) => esc("1") + s + reset;',
254
+ 'const dim = (s: string) => esc("2") + s + reset;',
255
+ 'const green = (s: string) => esc("32") + s + reset;',
256
+ 'const yellow = (s: string) => esc("33") + s + reset;',
257
+ 'const red = (s: string) => esc("31") + s + reset;',
258
+ 'const cyan = (s: string) => esc("36") + s + reset;',
259
+ 'const gray = (s: string) => esc("90") + s + reset;',
260
+ 'const white = (s: string) => esc("97") + s + reset;',
261
+ 'const magenta = (s: string) => esc("35") + s + reset;',
262
+ 'const blue = (s: string) => esc("34") + s + reset;',
263
+ "",
264
+ "const timestamp = (): string => {",
265
+ " const d = new Date();",
266
+ ' const h = String(d.getHours()).padStart(2, "0");',
267
+ ' const m = String(d.getMinutes()).padStart(2, "0");',
268
+ ' const s = String(d.getSeconds()).padStart(2, "0");',
269
+ ' const ms = String(d.getMilliseconds()).padStart(3, "0");',
270
+ ' return h + ":" + m + ":" + s + "." + ms;',
271
+ "};",
272
+ "",
273
+ "const badge = (level: LogLevel): string => {",
274
+ " switch (level) {",
275
+ ' case "debug": return gray("DBG");',
276
+ ' case "info": return green("INF");',
277
+ ' case "warn": return yellow("WRN");',
278
+ ' case "error": return red("ERR");',
279
+ " }",
280
+ "};",
281
+ "",
282
+ "export interface Logger {",
283
+ " debug(msg: string, extra?: Record<string, unknown>): void;",
284
+ " info(msg: string, extra?: Record<string, unknown>): void;",
285
+ " warn(msg: string, extra?: Record<string, unknown>): void;",
286
+ " error(msg: string, extra?: Record<string, unknown>): void;",
287
+ "}",
288
+ "",
289
+ 'export const createLogger = (minLevel: LogLevel = "debug"): Logger => {',
290
+ " const threshold = LEVELS[minLevel];",
291
+ "",
292
+ " const log = (level: LogLevel, msg: string, extra?: Record<string, unknown>) => {",
293
+ " if (LEVELS[level] < threshold) return;",
294
+ ' let line = " " + badge(level) + " " + dim(timestamp()) + " " + msg;',
295
+ " if (extra) {",
296
+ " const parts = Object.entries(extra)",
297
+ ' .map(([k, v]) => gray(k + "=") + white(String(v)));',
298
+ ' line += " " + parts.join(" ");',
299
+ " }",
300
+ " console.log(line);",
301
+ " };",
302
+ "",
303
+ " return {",
304
+ ' debug: (msg, extra) => log("debug", msg, extra),',
305
+ ' info: (msg, extra) => log("info", msg, extra),',
306
+ ' warn: (msg, extra) => log("warn", msg, extra),',
307
+ ' error: (msg, extra) => log("error", msg, extra),',
308
+ " };",
309
+ "};",
310
+ "",
311
+ "// ── Request logging ─────────────────────────────────────────────────",
312
+ "",
313
+ "const methodColor = (method: string): string => {",
314
+ " switch (method) {",
315
+ ' case "GET": return green(bold(method.padEnd(7)));',
316
+ ' case "POST": return cyan(bold(method.padEnd(7)));',
317
+ ' case "PATCH": return yellow(bold(method.padEnd(7)));',
318
+ ' case "PUT": return yellow(bold(method.padEnd(7)));',
319
+ ' case "DELETE": return red(bold(method.padEnd(7)));',
320
+ " default: return white(bold(method.padEnd(7)));",
321
+ " }",
322
+ "};",
323
+ "",
324
+ "const statusColor = (status: number): string => {",
325
+ " if (status < 300) return green(String(status));",
326
+ " if (status < 400) return cyan(String(status));",
327
+ " if (status < 500) return yellow(String(status));",
328
+ " return red(String(status));",
329
+ "};",
330
+ "",
331
+ "export const formatRequest = (",
332
+ " method: string,",
333
+ " path: string,",
334
+ " status: number,",
335
+ " durationMs: number,",
336
+ " ip: string,",
337
+ "): string => {",
338
+ ' const arrow = gray("←");',
339
+ ' const dur = dim(durationMs.toFixed(2) + "ms");',
340
+ " return (",
341
+ ' " " + arrow + " " +',
342
+ ' dim(timestamp()) + " " +',
343
+ ' methodColor(method) + " " +',
344
+ ' statusColor(status) + " " +',
345
+ ' path + " " +',
346
+ ' dur + " " +',
347
+ ' gray("ip=" + ip)',
348
+ " );",
349
+ "};",
350
+ "",
351
+ "// ── Startup banner ──────────────────────────────────────────────────",
352
+ "",
353
+ "export { bold, cyan, dim, gray, green, magenta, blue, white, yellow, red };",
354
+ "",
355
+ ].join("\n"),
356
+ },
265
357
 
266
- console.log(\`
267
- ⚡ \${config.nodeEnv === "production" ? "PRODUCTION" : "DEV"} server running
268
- http://\${config.host}:\${config.port}
269
- → SQLite: \${config.databasePath}
270
- \`);
271
-
272
- // ── Graceful shutdown ─────────────────────────────────────────────────
273
- const shutdown = () => {
274
- console.log("\\nShutting down...");
275
- server.stop();
276
- db.close();
277
- process.exit(0);
278
- };
358
+ // ── src/router.ts ───────────────────────────────────────────────────
359
+ {
360
+ path: "src/router.ts",
361
+ content: [
362
+ "/**",
363
+ " * Lightweight router with O(1) static route lookup.",
364
+ " */",
365
+ "",
366
+ "type Handler = (req: Request, params?: Record<string, string>) => Promise<Response> | Response;",
367
+ "",
368
+ "interface Route {",
369
+ " method: string;",
370
+ " path: string;",
371
+ " handler: Handler;",
372
+ " auth: boolean;",
373
+ " description: string;",
374
+ "}",
375
+ "",
376
+ "export interface Router {",
377
+ " add(method: string, path: string, handler: Handler, opts?: { auth?: boolean; description?: string }): void;",
378
+ " match(method: string, path: string): { handler: Handler; auth: boolean } | null;",
379
+ " routes(): ReadonlyArray<{ method: string; path: string; auth: boolean; description: string }>;",
380
+ "}",
381
+ "",
382
+ "export const createRouter = (): Router => {",
383
+ " const table = new Map<string, Route>();",
384
+ " const list: Route[] = [];",
385
+ "",
386
+ " return {",
387
+ " add(method, path, handler, opts = {}) {",
388
+ ' const key = method + " " + path;',
389
+ ' const route: Route = { method, path, handler, auth: opts.auth ?? false, description: opts.description ?? "" };',
390
+ " table.set(key, route);",
391
+ " list.push(route);",
392
+ " },",
393
+ "",
394
+ " match(method, path) {",
395
+ ' const route = table.get(method + " " + path);',
396
+ " if (route) return { handler: route.handler, auth: route.auth };",
397
+ " return null;",
398
+ " },",
399
+ "",
400
+ " routes() {",
401
+ " return list.map(r => ({ method: r.method, path: r.path, auth: r.auth, description: r.description }));",
402
+ " },",
403
+ " };",
404
+ "};",
405
+ "",
406
+ ].join("\n"),
407
+ },
279
408
 
280
- process.on("SIGINT", shutdown);
281
- process.on("SIGTERM", shutdown);
282
- `,
409
+ // ── src/main.ts ─────────────────────────────────────────────────────
410
+ {
411
+ path: "src/main.ts",
412
+ content: [
413
+ 'import { loadConfig } from "./config.js";',
414
+ 'import { createDatabase } from "./database.js";',
415
+ 'import { createLogger, bold, cyan, dim, gray, green, white, magenta, blue, yellow } from "./logger.js";',
416
+ 'import { createRouter } from "./router.js";',
417
+ 'import { createAuthService } from "./services/auth.service.js";',
418
+ 'import { createUserService } from "./services/user.service.js";',
419
+ 'import { createServer } from "./server.js";',
420
+ 'import { createPasswordHasher } from "./utils/password.js";',
421
+ 'import { createTokenService } from "./utils/token.js";',
422
+ "",
423
+ "const startTime = performance.now();",
424
+ "",
425
+ "// ── Config ──────────────────────────────────────────────────────────",
426
+ "const config = loadConfig();",
427
+ "const log = createLogger(config.logLevel);",
428
+ "",
429
+ "// ── Database ────────────────────────────────────────────────────────",
430
+ "const db = createDatabase(config.databasePath, log);",
431
+ "",
432
+ "// ── Services ────────────────────────────────────────────────────────",
433
+ "const passwordHasher = createPasswordHasher();",
434
+ "const tokenService = createTokenService(config.jwt.secret, config.jwt.expiresIn);",
435
+ "const authService = createAuthService(db, passwordHasher, tokenService);",
436
+ "const userService = createUserService(db, passwordHasher);",
437
+ "",
438
+ "// ── Router ──────────────────────────────────────────────────────────",
439
+ "const router = createRouter();",
440
+ "",
441
+ 'import { authHandlers } from "./handlers/auth.handler.js";',
442
+ 'import { healthHandler } from "./handlers/health.handler.js";',
443
+ 'import { userHandlers } from "./handlers/user.handler.js";',
444
+ "",
445
+ "const auth = authHandlers(authService);",
446
+ "const health = healthHandler();",
447
+ "const users = userHandlers(userService);",
448
+ "",
449
+ "// Public",
450
+ 'router.add("GET", "/health", health.check, { description: "Health check" });',
451
+ 'router.add("POST", "/api/v1/auth/register", auth.register, { description: "Register user" });',
452
+ 'router.add("POST", "/api/v1/auth/login", auth.login, { description: "Login" });',
453
+ "",
454
+ "// Protected",
455
+ 'router.add("POST", "/api/v1/auth/logout", auth.logout, { auth: true, description: "Logout" });',
456
+ 'router.add("GET", "/api/v1/users/me", users.getProfile, { auth: true, description: "Get profile" });',
457
+ 'router.add("PATCH", "/api/v1/users/me", users.updateProfile, { auth: true, description: "Update profile" });',
458
+ 'router.add("DELETE", "/api/v1/users/me", users.deleteAccount, { auth: true, description: "Delete account" });',
459
+ "",
460
+ "// ── Server ──────────────────────────────────────────────────────────",
461
+ "const server = createServer({ config, router, tokenService, log });",
462
+ "",
463
+ "// ── Startup banner ──────────────────────────────────────────────────",
464
+ "const bootMs = performance.now() - startTime;",
465
+ "",
466
+ 'const envBadge = config.nodeEnv === "production"',
467
+ ' ? "\\x1b[42m\\x1b[30m PRODUCTION \\x1b[0m"',
468
+ ' : "\\x1b[46m\\x1b[30m DEVELOPMENT \\x1b[0m";',
469
+ "",
470
+ 'console.log("");',
471
+ 'console.log(bold(cyan(" ┌─────────────────────────────────────────┐")));',
472
+ 'console.log(bold(cyan(" │")) + " " + bold(cyan("│")));',
473
+ `console.log(bold(cyan(" │")) + " " + bold(white("⚡ " + ${JSON.stringify(projectName)})) + " " + bold(cyan("│")));`,
474
+ 'console.log(bold(cyan(" │")) + " " + dim(gray("Built with onlyApi")) + " " + bold(cyan("│")));',
475
+ 'console.log(bold(cyan(" │")) + " " + bold(cyan("│")));',
476
+ 'console.log(bold(cyan(" └─────────────────────────────────────────┘")));',
477
+ 'console.log("");',
478
+ 'console.log(" " + envBadge + " " + dim("booted in") + " " + bold(green(bootMs.toFixed(0) + "ms")));',
479
+ 'console.log("");',
480
+ 'console.log(" " + bold(white("→")) + " " + dim("Local:") + " " + bold(cyan("http://localhost:" + config.port)));',
481
+ 'console.log(" " + bold(white("→")) + " " + dim("Network:") + " " + bold(cyan("http://" + config.host + ":" + config.port)));',
482
+ 'console.log(" " + bold(white("→")) + " " + dim("SQLite:") + " " + dim(config.databasePath));',
483
+ 'console.log("");',
484
+ "",
485
+ "// Process info",
486
+ 'console.log(" " + gray("├─") + " " + dim("PID") + " " + white(String(process.pid)));',
487
+ 'console.log(" " + gray("├─") + " " + dim("Runtime") + " " + magenta("Bun " + Bun.version));',
488
+ 'console.log(" " + gray("├─") + " " + dim("TypeScript") + " " + blue("strict"));',
489
+ 'console.log(" " + gray("├─") + " " + dim("Rate limit") + " " + white(config.rateLimitMax + " req/" + (config.rateLimitWindowMs / 1000) + "s"));',
490
+ 'console.log(" " + gray("└─") + " " + dim("Log level") + " " + white(config.logLevel));',
491
+ 'console.log("");',
492
+ "",
493
+ "// Route table",
494
+ "const allRoutes = router.routes();",
495
+ 'console.log(" " + bold(white("Routes")) + " " + dim("(" + allRoutes.length + ")"));',
496
+ 'console.log(" " + gray("─".repeat(60)));',
497
+ "for (const r of allRoutes) {",
498
+ ' const lock = r.auth ? yellow("🔒") : " ";',
499
+ " const mc = (() => {",
500
+ " switch (r.method) {",
501
+ ' case "GET": return green(bold(r.method.padEnd(7)));',
502
+ ' case "POST": return cyan(bold(r.method.padEnd(7)));',
503
+ ' case "PATCH": return yellow(bold(r.method.padEnd(7)));',
504
+ ' case "DELETE": return "\\x1b[31m\\x1b[1m" + r.method.padEnd(7) + "\\x1b[0m";',
505
+ " default: return white(bold(r.method.padEnd(7)));",
506
+ " }",
507
+ " })();",
508
+ ' console.log(" " + lock + " " + mc + " " + r.path.padEnd(30) + " " + dim(gray(r.description)));',
509
+ "}",
510
+ 'console.log(" " + gray("─".repeat(60)));',
511
+ 'console.log("");',
512
+ 'console.log(" " + dim("press Ctrl+C to stop"));',
513
+ 'console.log("");',
514
+ "",
515
+ "// ── Graceful shutdown ─────────────────────────────────────────────",
516
+ "const shutdown = () => {",
517
+ ' log.info("Shutting down...");',
518
+ " server.stop();",
519
+ " db.close();",
520
+ " process.exit(0);",
521
+ "};",
522
+ "",
523
+ 'process.on("SIGINT", shutdown);',
524
+ 'process.on("SIGTERM", shutdown);',
525
+ "",
526
+ ].join("\n"),
283
527
  },
284
528
 
285
529
  // ── src/config.ts ───────────────────────────────────────────────────
@@ -339,181 +583,169 @@ export const loadConfig = (): AppConfig => {
339
583
  // ── src/database.ts ─────────────────────────────────────────────────
340
584
  {
341
585
  path: "src/database.ts",
342
- content: `import { Database } from "bun:sqlite";
343
- import { existsSync, mkdirSync } from "node:fs";
344
- import { dirname } from "node:path";
345
-
346
- export const createDatabase = (dbPath: string): Database => {
347
- // Ensure data directory exists
348
- const dir = dirname(dbPath);
349
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
350
-
351
- const db = new Database(dbPath, { create: true });
352
-
353
- // Performance settings
354
- db.exec("PRAGMA journal_mode = WAL");
355
- db.exec("PRAGMA synchronous = NORMAL");
356
- db.exec("PRAGMA foreign_keys = ON");
357
-
358
- // Run migrations
359
- migrate(db);
360
-
361
- return db;
362
- };
363
-
364
- const migrate = (db: Database): void => {
365
- db.exec(\`
366
- CREATE TABLE IF NOT EXISTS users (
367
- id TEXT PRIMARY KEY,
368
- email TEXT NOT NULL UNIQUE,
369
- password TEXT NOT NULL,
370
- name TEXT NOT NULL DEFAULT '',
371
- role TEXT NOT NULL DEFAULT 'user',
372
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
373
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
374
- )
375
- \`);
376
-
377
- db.exec(\`
378
- CREATE TABLE IF NOT EXISTS token_blacklist (
379
- token_id TEXT PRIMARY KEY,
380
- expires_at TEXT NOT NULL
381
- )
382
- \`);
383
-
384
- console.log(" ✓ Database ready");
385
- };
386
- `,
586
+ content: [
587
+ 'import { Database } from "bun:sqlite";',
588
+ 'import { existsSync, mkdirSync } from "node:fs";',
589
+ 'import { dirname } from "node:path";',
590
+ 'import type { Logger } from "./logger.js";',
591
+ "",
592
+ "export const createDatabase = (dbPath: string, log: Logger): Database => {",
593
+ " const dir = dirname(dbPath);",
594
+ " if (!existsSync(dir)) mkdirSync(dir, { recursive: true });",
595
+ "",
596
+ " const db = new Database(dbPath, { create: true });",
597
+ "",
598
+ ' db.exec("PRAGMA journal_mode = WAL");',
599
+ ' db.exec("PRAGMA synchronous = NORMAL");',
600
+ ' db.exec("PRAGMA foreign_keys = ON");',
601
+ "",
602
+ " migrate(db, log, dbPath);",
603
+ " return db;",
604
+ "};",
605
+ "",
606
+ "const migrate = (db: Database, log: Logger, dbPath: string): void => {",
607
+ " db.exec(`",
608
+ " CREATE TABLE IF NOT EXISTS users (",
609
+ " id TEXT PRIMARY KEY,",
610
+ " email TEXT NOT NULL UNIQUE,",
611
+ " password TEXT NOT NULL,",
612
+ " name TEXT NOT NULL DEFAULT '',",
613
+ " role TEXT NOT NULL DEFAULT 'user',",
614
+ " created_at TEXT NOT NULL DEFAULT (datetime('now')),",
615
+ " updated_at TEXT NOT NULL DEFAULT (datetime('now'))",
616
+ " )",
617
+ " `);",
618
+ "",
619
+ " db.exec(`",
620
+ " CREATE TABLE IF NOT EXISTS token_blacklist (",
621
+ " token_id TEXT PRIMARY KEY,",
622
+ " expires_at TEXT NOT NULL",
623
+ " )",
624
+ " `);",
625
+ "",
626
+ ' log.info("SQLite database ready", { path: dbPath });',
627
+ "};",
628
+ "",
629
+ ].join("\n"),
387
630
  },
388
631
 
389
632
  // ── src/server.ts ───────────────────────────────────────────────────
390
633
  {
391
634
  path: "src/server.ts",
392
- content: `import type { AuthService } from "./services/auth.service.js";
393
- import type { UserService } from "./services/user.service.js";
394
- import type { TokenService } from "./utils/token.js";
395
- import type { AppConfig } from "./config.js";
396
- import { authHandlers } from "./handlers/auth.handler.js";
397
- import { healthHandler } from "./handlers/health.handler.js";
398
- import { userHandlers } from "./handlers/user.handler.js";
399
- import { authenticate } from "./middleware/auth.js";
400
- import { json } from "./utils/response.js";
401
-
402
- interface ServerDeps {
403
- config: AppConfig;
404
- authService: AuthService;
405
- userService: UserService;
406
- tokenService: TokenService;
407
- }
408
-
409
- export const createServer = (deps: ServerDeps) => {
410
- const { config, tokenService } = deps;
411
- const auth = authHandlers(deps.authService);
412
- const health = healthHandler();
413
- const users = userHandlers(deps.userService);
414
-
415
- // ── Rate limiting (in-memory) ───────────────────────────────────────
416
- const hits = new Map<string, { count: number; resetAt: number }>();
417
-
418
- const isRateLimited = (ip: string): boolean => {
419
- const now = Date.now();
420
- const entry = hits.get(ip);
421
-
422
- if (!entry || now > entry.resetAt) {
423
- hits.set(ip, { count: 1, resetAt: now + config.rateLimitWindowMs });
424
- return false;
425
- }
426
-
427
- entry.count++;
428
- return entry.count > config.rateLimitMax;
429
- };
430
-
431
- // ── CORS headers ───────────────────────────────────────────────────
432
- const corsHeaders = (origin: string | null): Record<string, string> => {
433
- const allowed = config.corsOrigins === "*" || (origin && config.corsOrigins.includes(origin));
434
- return {
435
- "Access-Control-Allow-Origin": allowed ? (origin ?? "*") : "",
436
- "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
437
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
438
- "Access-Control-Max-Age": "86400",
439
- };
440
- };
441
-
442
- // ── Request handler ────────────────────────────────────────────────
443
- const server = Bun.serve({
444
- port: config.port,
445
- hostname: config.host,
446
-
447
- async fetch(req) {
448
- const url = new URL(req.url);
449
- const { pathname } = url;
450
- const method = req.method;
451
- const origin = req.headers.get("origin");
452
-
453
- // CORS preflight
454
- if (method === "OPTIONS") {
455
- return new Response(null, { status: 204, headers: corsHeaders(origin) });
456
- }
457
-
458
- // Rate limiting
459
- const ip = server.requestIP(req)?.address ?? "unknown";
460
- if (isRateLimited(ip)) {
461
- return json({ error: "Too many requests" }, 429, corsHeaders(origin));
462
- }
463
-
464
- try {
465
- let response: Response;
466
-
467
- // ── Public routes ──
468
- if (pathname === "/health" && method === "GET") {
469
- response = health.check();
470
- } else if (pathname === "/api/v1/auth/register" && method === "POST") {
471
- response = await auth.register(req);
472
- } else if (pathname === "/api/v1/auth/login" && method === "POST") {
473
- response = await auth.login(req);
474
- }
475
-
476
- // ── Protected routes ──
477
- else if (pathname === "/api/v1/auth/logout" && method === "POST") {
478
- const authResult = await authenticate(req, tokenService);
479
- if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
480
- response = await auth.logout(authResult.userId, req);
481
- } else if (pathname === "/api/v1/users/me" && method === "GET") {
482
- const authResult = await authenticate(req, tokenService);
483
- if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
484
- response = await users.getProfile(authResult.userId);
485
- } else if (pathname === "/api/v1/users/me" && method === "PATCH") {
486
- const authResult = await authenticate(req, tokenService);
487
- if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
488
- response = await users.updateProfile(authResult.userId, req);
489
- } else if (pathname === "/api/v1/users/me" && method === "DELETE") {
490
- const authResult = await authenticate(req, tokenService);
491
- if (!authResult.ok) return json({ error: authResult.error }, 401, corsHeaders(origin));
492
- response = await users.deleteAccount(authResult.userId);
493
- }
494
-
495
- // ── 404 ──
496
- else {
497
- response = json({ error: "Not found" }, 404);
498
- }
499
-
500
- // Add CORS headers to every response
501
- const headers = corsHeaders(origin);
502
- for (const [k, v] of Object.entries(headers)) {
503
- response.headers.set(k, v);
504
- }
505
-
506
- return response;
507
- } catch (err) {
508
- console.error("Unhandled error:", err);
509
- return json({ error: "Internal server error" }, 500, corsHeaders(origin));
510
- }
511
- },
512
- });
513
-
514
- return server;
515
- };
516
- `,
635
+ content: [
636
+ 'import type { AppConfig } from "./config.js";',
637
+ 'import type { Logger } from "./logger.js";',
638
+ 'import { formatRequest } from "./logger.js";',
639
+ 'import type { Router } from "./router.js";',
640
+ 'import type { TokenService } from "./utils/token.js";',
641
+ 'import { authenticate } from "./middleware/auth.js";',
642
+ 'import { json } from "./utils/response.js";',
643
+ "",
644
+ "interface ServerDeps {",
645
+ " config: AppConfig;",
646
+ " router: Router;",
647
+ " tokenService: TokenService;",
648
+ " log: Logger;",
649
+ "}",
650
+ "",
651
+ "export const createServer = (deps: ServerDeps) => {",
652
+ " const { config, router, tokenService, log } = deps;",
653
+ "",
654
+ " // ── Rate limiting (in-memory) ──────────────────────────────────",
655
+ " const hits = new Map<string, { count: number; resetAt: number }>();",
656
+ "",
657
+ " const isRateLimited = (ip: string): boolean => {",
658
+ " const now = Date.now();",
659
+ " const entry = hits.get(ip);",
660
+ " if (!entry || now > entry.resetAt) {",
661
+ " hits.set(ip, { count: 1, resetAt: now + config.rateLimitWindowMs });",
662
+ " return false;",
663
+ " }",
664
+ " entry.count++;",
665
+ " return entry.count > config.rateLimitMax;",
666
+ " };",
667
+ "",
668
+ " // ── CORS headers ───────────────────────────────────────────────",
669
+ " const corsHeaders = (origin: string | null): Record<string, string> => {",
670
+ ' const allowed = config.corsOrigins === "*" || (origin && config.corsOrigins.includes(origin));',
671
+ " return {",
672
+ ' "Access-Control-Allow-Origin": allowed ? (origin ?? "*") : "",',
673
+ ' "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",',
674
+ ' "Access-Control-Allow-Headers": "Content-Type, Authorization",',
675
+ ' "Access-Control-Max-Age": "86400",',
676
+ " };",
677
+ " };",
678
+ "",
679
+ " // ── Server ─────────────────────────────────────────────────────",
680
+ " const server = Bun.serve({",
681
+ " port: config.port,",
682
+ " hostname: config.host,",
683
+ "",
684
+ " async fetch(req) {",
685
+ " const start = performance.now();",
686
+ " const url = new URL(req.url);",
687
+ " const { pathname } = url;",
688
+ " const method = req.method;",
689
+ ' const origin = req.headers.get("origin");',
690
+ ' const ip = server.requestIP(req)?.address ?? "unknown";',
691
+ "",
692
+ " // CORS preflight",
693
+ ' if (method === "OPTIONS") {',
694
+ " return new Response(null, { status: 204, headers: corsHeaders(origin) });",
695
+ " }",
696
+ "",
697
+ " // Rate limiting",
698
+ " if (isRateLimited(ip)) {",
699
+ " const dur = performance.now() - start;",
700
+ " console.log(formatRequest(method, pathname, 429, dur, ip));",
701
+ ' return json({ error: "Too many requests" }, 429, corsHeaders(origin));',
702
+ " }",
703
+ "",
704
+ " try {",
705
+ " const match = router.match(method, pathname);",
706
+ "",
707
+ " if (!match) {",
708
+ " const dur = performance.now() - start;",
709
+ " console.log(formatRequest(method, pathname, 404, dur, ip));",
710
+ ' return json({ error: "Not found" }, 404, corsHeaders(origin));',
711
+ " }",
712
+ "",
713
+ " // Auth check",
714
+ " if (match.auth) {",
715
+ " const authResult = await authenticate(req, tokenService);",
716
+ " if (!authResult.ok) {",
717
+ " const dur = performance.now() - start;",
718
+ " console.log(formatRequest(method, pathname, 401, dur, ip));",
719
+ " return json({ error: authResult.error }, 401, corsHeaders(origin));",
720
+ " }",
721
+ " // Inject userId into request via header",
722
+ ' req.headers.set("x-user-id", authResult.userId);',
723
+ " }",
724
+ "",
725
+ " const response = await match.handler(req);",
726
+ "",
727
+ " // Add CORS headers",
728
+ " const headers = corsHeaders(origin);",
729
+ " for (const [k, v] of Object.entries(headers)) {",
730
+ " response.headers.set(k, v);",
731
+ " }",
732
+ "",
733
+ " const dur = performance.now() - start;",
734
+ " console.log(formatRequest(method, pathname, response.status, dur, ip));",
735
+ " return response;",
736
+ " } catch (err) {",
737
+ ' log.error("Unhandled error", { path: pathname, error: String(err) });',
738
+ " const dur = performance.now() - start;",
739
+ " console.log(formatRequest(method, pathname, 500, dur, ip));",
740
+ ' return json({ error: "Internal server error" }, 500, corsHeaders(origin));',
741
+ " }",
742
+ " },",
743
+ " });",
744
+ "",
745
+ " return server;",
746
+ "};",
747
+ "",
748
+ ].join("\n"),
517
749
  },
518
750
 
519
751
  // ── src/handlers/health.handler.ts ──────────────────────────────────
@@ -567,7 +799,7 @@ export const authHandlers = (authService: AuthService) => ({
567
799
  return json({ data: result.data });
568
800
  },
569
801
 
570
- logout: async (userId: string, req: Request): Promise<Response> => {
802
+ logout: async (req: Request): Promise<Response> => {
571
803
  const header = req.headers.get("authorization") ?? "";
572
804
  const token = header.replace("Bearer ", "");
573
805
 
@@ -581,43 +813,48 @@ export const authHandlers = (authService: AuthService) => ({
581
813
  // ── src/handlers/user.handler.ts ────────────────────────────────────
582
814
  {
583
815
  path: "src/handlers/user.handler.ts",
584
- content: `import type { UserService } from "../services/user.service.js";
585
- import { json } from "../utils/response.js";
586
- import { z } from "zod";
587
-
588
- const updateSchema = z.object({
589
- name: z.string().min(1).optional(),
590
- email: z.string().email().optional(),
591
- });
592
-
593
- export const userHandlers = (userService: UserService) => ({
594
- getProfile: async (userId: string): Promise<Response> => {
595
- const user = userService.findById(userId);
596
- if (!user) return json({ error: "User not found" }, 404);
597
-
598
- const { password: _, ...profile } = user;
599
- return json({ data: profile });
600
- },
601
-
602
- updateProfile: async (userId: string, req: Request): Promise<Response> => {
603
- const body = updateSchema.safeParse(await req.json());
604
- if (!body.success) return json({ error: body.error.issues }, 400);
605
-
606
- const updated = userService.update(userId, body.data);
607
- if (!updated) return json({ error: "User not found" }, 404);
608
-
609
- const { password: _, ...profile } = updated;
610
- return json({ data: profile });
611
- },
612
-
613
- deleteAccount: async (userId: string): Promise<Response> => {
614
- const deleted = userService.delete(userId);
615
- if (!deleted) return json({ error: "User not found" }, 404);
616
-
617
- return json({ data: { message: "Account deleted" } });
618
- },
619
- });
620
- `,
816
+ content: [
817
+ 'import type { UserService } from "../services/user.service.js";',
818
+ 'import { json } from "../utils/response.js";',
819
+ 'import { z } from "zod";',
820
+ "",
821
+ "const updateSchema = z.object({",
822
+ " name: z.string().min(1).optional(),",
823
+ " email: z.string().email().optional(),",
824
+ "});",
825
+ "",
826
+ "export const userHandlers = (userService: UserService) => ({",
827
+ " getProfile: async (req: Request): Promise<Response> => {",
828
+ ' const userId = req.headers.get("x-user-id") ?? "";',
829
+ " const user = userService.findById(userId);",
830
+ ' if (!user) return json({ error: "User not found" }, 404);',
831
+ "",
832
+ " const { password: _, ...profile } = user;",
833
+ " return json({ data: profile });",
834
+ " },",
835
+ "",
836
+ " updateProfile: async (req: Request): Promise<Response> => {",
837
+ ' const userId = req.headers.get("x-user-id") ?? "";',
838
+ " const body = updateSchema.safeParse(await req.json());",
839
+ " if (!body.success) return json({ error: body.error.issues }, 400);",
840
+ "",
841
+ " const updated = userService.update(userId, body.data);",
842
+ ' if (!updated) return json({ error: "User not found" }, 404);',
843
+ "",
844
+ " const { password: _, ...profile } = updated;",
845
+ " return json({ data: profile });",
846
+ " },",
847
+ "",
848
+ " deleteAccount: async (req: Request): Promise<Response> => {",
849
+ ' const userId = req.headers.get("x-user-id") ?? "";',
850
+ " const deleted = userService.delete(userId);",
851
+ ' if (!deleted) return json({ error: "User not found" }, 404);',
852
+ "",
853
+ ' return json({ data: { message: "Account deleted" } });',
854
+ " },",
855
+ "});",
856
+ "",
857
+ ].join("\n"),
621
858
  },
622
859
 
623
860
  // ── src/middleware/auth.ts ──────────────────────────────────────────
@@ -681,7 +918,6 @@ export const createAuthService = (
681
918
  tokenService: TokenService,
682
919
  ): AuthService => ({
683
920
  async register(input) {
684
- // Check if user exists
685
921
  const existing = db.query("SELECT id FROM users WHERE email = ?").get(input.email);
686
922
  if (existing) return { ok: false, error: "Email already registered" };
687
923
 
@@ -689,10 +925,7 @@ export const createAuthService = (
689
925
  const hashedPassword = await passwordHasher.hash(input.password);
690
926
 
691
927
  db.query("INSERT INTO users (id, email, password, name) VALUES (?, ?, ?, ?)").run(
692
- id,
693
- input.email,
694
- hashedPassword,
695
- input.name ?? "",
928
+ id, input.email, hashedPassword, input.name ?? "",
696
929
  );
697
930
 
698
931
  const token = tokenService.sign({ sub: id, email: input.email, role: "user" });
@@ -729,58 +962,60 @@ export const createAuthService = (
729
962
  // ── src/services/user.service.ts ────────────────────────────────────
730
963
  {
731
964
  path: "src/services/user.service.ts",
732
- content: `import type { Database } from "bun:sqlite";
733
- import type { PasswordHasher } from "../utils/password.js";
734
-
735
- interface User {
736
- id: string;
737
- email: string;
738
- password: string;
739
- name: string;
740
- role: string;
741
- created_at: string;
742
- updated_at: string;
743
- }
744
-
745
- export interface UserService {
746
- findById(id: string): User | null;
747
- update(id: string, data: { name?: string; email?: string }): User | null;
748
- delete(id: string): boolean;
749
- }
750
-
751
- export const createUserService = (db: Database, _passwordHasher: PasswordHasher): UserService => ({
752
- findById(id) {
753
- return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;
754
- },
755
-
756
- update(id, data) {
757
- const fields: string[] = [];
758
- const values: unknown[] = [];
759
-
760
- if (data.name !== undefined) {
761
- fields.push("name = ?");
762
- values.push(data.name);
763
- }
764
- if (data.email !== undefined) {
765
- fields.push("email = ?");
766
- values.push(data.email);
767
- }
768
-
769
- if (fields.length === 0) return this.findById(id);
770
-
771
- fields.push("updated_at = datetime('now')");
772
- values.push(id);
773
-
774
- db.query(\`UPDATE users SET \${fields.join(", ")} WHERE id = ?\`).run(...values);
775
- return this.findById(id);
776
- },
777
-
778
- delete(id) {
779
- const result = db.query("DELETE FROM users WHERE id = ?").run(id);
780
- return result.changes > 0;
781
- },
782
- });
783
- `,
965
+ content: [
966
+ 'import type { Database } from "bun:sqlite";',
967
+ 'import type { PasswordHasher } from "../utils/password.js";',
968
+ "",
969
+ "interface User {",
970
+ " id: string;",
971
+ " email: string;",
972
+ " password: string;",
973
+ " name: string;",
974
+ " role: string;",
975
+ " created_at: string;",
976
+ " updated_at: string;",
977
+ "}",
978
+ "",
979
+ "export interface UserService {",
980
+ " findById(id: string): User | null;",
981
+ " update(id: string, data: { name?: string; email?: string }): User | null;",
982
+ " delete(id: string): boolean;",
983
+ "}",
984
+ "",
985
+ "export const createUserService = (db: Database, _passwordHasher: PasswordHasher): UserService => ({",
986
+ " findById(id) {",
987
+ ' return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;',
988
+ " },",
989
+ "",
990
+ " update(id, data) {",
991
+ " const fields: string[] = [];",
992
+ " const values: unknown[] = [];",
993
+ "",
994
+ " if (data.name !== undefined) {",
995
+ ' fields.push("name = ?");',
996
+ " values.push(data.name);",
997
+ " }",
998
+ " if (data.email !== undefined) {",
999
+ ' fields.push("email = ?");',
1000
+ " values.push(data.email);",
1001
+ " }",
1002
+ "",
1003
+ " if (fields.length === 0) return this.findById(id);",
1004
+ "",
1005
+ " fields.push(\"updated_at = datetime('now')\");",
1006
+ " values.push(id);",
1007
+ "",
1008
+ ' db.query("UPDATE users SET " + fields.join(", ") + " WHERE id = ?").run(...values);',
1009
+ " return this.findById(id);",
1010
+ " },",
1011
+ "",
1012
+ " delete(id) {",
1013
+ ' const result = db.query("DELETE FROM users WHERE id = ?").run(id);',
1014
+ " return result.changes > 0;",
1015
+ " },",
1016
+ "});",
1017
+ "",
1018
+ ].join("\n"),
784
1019
  },
785
1020
 
786
1021
  // ── src/utils/password.ts ───────────────────────────────────────────
@@ -944,7 +1179,6 @@ export const json = (
944
1179
 
945
1180
  describe("Health check", () => {
946
1181
  it("should return ok", async () => {
947
- // Start server for test
948
1182
  const response = await fetch("http://localhost:3000/health");
949
1183
  expect(response.status).toBe(200);
950
1184