@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.
- package/dist/cli.js +18 -313
- package/package.json +1 -1
- package/src/cli/commands/init.ts +3 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/template.ts +548 -314
package/src/cli/template.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal project template — generated by `onlyapi init`.
|
|
3
3
|
*
|
|
4
|
-
* Produces a clean
|
|
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
|
-
* -
|
|
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
|
-
|
|
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/
|
|
238
|
+
// ── src/logger.ts ───────────────────────────────────────────────────
|
|
236
239
|
{
|
|
237
|
-
path: "src/
|
|
238
|
-
content:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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:
|
|
343
|
-
import {
|
|
344
|
-
import {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
355
|
-
db.exec("PRAGMA synchronous = NORMAL");
|
|
356
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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:
|
|
393
|
-
import type {
|
|
394
|
-
import type {
|
|
395
|
-
import
|
|
396
|
-
import {
|
|
397
|
-
import {
|
|
398
|
-
import {
|
|
399
|
-
import {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
"
|
|
436
|
-
"
|
|
437
|
-
"
|
|
438
|
-
"
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 (
|
|
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:
|
|
585
|
-
import {
|
|
586
|
-
import {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
if (!
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
return json({
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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:
|
|
733
|
-
import type {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|