@enderworld/onlyapi 1.5.1

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 (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,272 @@
1
+ import type { AdminService } from "../../application/services/admin.service.js";
2
+ import type { ApiKeyService } from "../../application/services/api-key.service.js";
3
+ import type { AuthService } from "../../application/services/auth.service.js";
4
+ import type { HealthService } from "../../application/services/health.service.js";
5
+ import type { UserService } from "../../application/services/user.service.js";
6
+ import type { EventBus } from "../../core/ports/event-bus.js";
7
+ import type { Logger } from "../../core/ports/logger.js";
8
+ import type { MetricsCollector } from "../../core/ports/metrics.js";
9
+ import type { OAuthProvider } from "../../core/ports/oauth.js";
10
+ import type { TokenService } from "../../core/ports/token-service.js";
11
+ import type { WebhookRegistry } from "../../core/ports/webhook.js";
12
+ import type { RequestContext } from "../context.js";
13
+ import { adminHandlers } from "../handlers/admin.handler.js";
14
+ import { apiKeyHandlers } from "../handlers/api-key.handler.js";
15
+ import { authHandlers } from "../handlers/auth.handler.js";
16
+ import { healthHandler } from "../handlers/health.handler.js";
17
+ import { metricsHandler } from "../handlers/metrics.handler.js";
18
+ import { oauthHandlers } from "../handlers/oauth.handler.js";
19
+ import { openApiHandler } from "../handlers/openapi.handler.js";
20
+ import { createSseHandler } from "../handlers/sse.handler.js";
21
+ import { userHandlers } from "../handlers/user.handler.js";
22
+ import { webhookHandlers } from "../handlers/webhook.handler.js";
23
+
24
+ /**
25
+ * Zero-alloc radix-style router.
26
+ * Static routes use O(1) map lookup.
27
+ * Parametric routes (admin) use prefix matching.
28
+ */
29
+ type RouteHandler = (req: Request, ctx: RequestContext) => Promise<Response>;
30
+
31
+ interface RouterDeps {
32
+ readonly authService: AuthService;
33
+ readonly userService: UserService;
34
+ readonly healthService: HealthService;
35
+ readonly adminService: AdminService;
36
+ readonly apiKeyService: ApiKeyService;
37
+ readonly tokenService: TokenService;
38
+ readonly metricsCollector: MetricsCollector;
39
+ readonly oauthProviders: ReadonlyMap<string, OAuthProvider>;
40
+ readonly eventBus: EventBus;
41
+ readonly webhookRegistry: WebhookRegistry;
42
+ readonly logger: Logger;
43
+ }
44
+
45
+ /** Admin route prefix for parametric matching */
46
+ const ADMIN_PREFIX = "/api/v1/admin/users/";
47
+ /** API key route prefix for parametric matching */
48
+ const API_KEY_PREFIX = "/api/v1/api-keys/";
49
+ /** OAuth route prefix */
50
+ const OAUTH_PREFIX = "/api/v1/auth/oauth/";
51
+ /** Webhook route prefix for parametric matching */
52
+ const WEBHOOK_PREFIX = "/api/v1/webhooks/";
53
+
54
+ export const createRouter = (deps: RouterDeps) => {
55
+ const { logger } = deps;
56
+ const health = healthHandler(deps.healthService);
57
+ const auth = authHandlers(
58
+ deps.authService,
59
+ deps.tokenService,
60
+ logger.child({ layer: "handler", handler: "auth" }),
61
+ );
62
+ const users = userHandlers(
63
+ deps.userService,
64
+ deps.tokenService,
65
+ logger.child({ layer: "handler", handler: "user" }),
66
+ );
67
+ const admin = adminHandlers(
68
+ deps.adminService,
69
+ deps.tokenService,
70
+ logger.child({ layer: "handler", handler: "admin" }),
71
+ );
72
+ const apiKeys = apiKeyHandlers(
73
+ deps.apiKeyService,
74
+ deps.tokenService,
75
+ logger.child({ layer: "handler", handler: "api-key" }),
76
+ );
77
+ const oauth = oauthHandlers(
78
+ deps.authService,
79
+ deps.oauthProviders,
80
+ logger.child({ layer: "handler", handler: "oauth" }),
81
+ );
82
+ const webhooks = webhookHandlers(
83
+ deps.webhookRegistry,
84
+ deps.tokenService,
85
+ logger.child({ layer: "handler", handler: "webhook" }),
86
+ );
87
+ const sse = createSseHandler({
88
+ tokenService: deps.tokenService,
89
+ eventBus: deps.eventBus,
90
+ logger: logger.child({ layer: "handler", handler: "sse" }),
91
+ });
92
+ const docs = openApiHandler();
93
+ const metrics = metricsHandler(deps.metricsCollector);
94
+
95
+ /** Pre-computed 404 body template */
96
+ const notFound404 = (method: string, path: string): Response => {
97
+ logger.debug("Route not found", { method, path });
98
+ const body = `{"error":{"code":"NOT_FOUND","message":"${method} ${path} not found"}}`;
99
+ return new Response(body, {
100
+ status: 404,
101
+ headers: { "Content-Type": "application/json; charset=utf-8" },
102
+ });
103
+ };
104
+
105
+ /** Static route table — fastest possible lookup */
106
+ const routes = new Map<string, RouteHandler>([
107
+ // Health — shallow (instant) for probes, deep for readiness
108
+ ["GET /health", async (_req, _ctx) => health.shallowCheck()],
109
+ ["GET /readiness", async (_req, _ctx) => health.deepCheck()],
110
+
111
+ // Auth
112
+ ["POST /api/v1/auth/register", auth.register],
113
+ ["POST /api/v1/auth/login", auth.login],
114
+ ["POST /api/v1/auth/refresh", auth.refresh],
115
+ ["POST /api/v1/auth/logout", auth.logout],
116
+
117
+ // Auth — v1.4 email verification & password reset
118
+ ["POST /api/v1/auth/verify-email", auth.verifyEmail],
119
+ ["POST /api/v1/auth/resend-verification", auth.resendVerification],
120
+ ["POST /api/v1/auth/forgot-password", auth.forgotPassword],
121
+ ["POST /api/v1/auth/reset-password", auth.resetPassword],
122
+
123
+ // Auth — v1.4 MFA/2FA
124
+ ["POST /api/v1/auth/mfa/setup", auth.mfaSetup],
125
+ ["POST /api/v1/auth/mfa/enable", auth.mfaEnable],
126
+ ["POST /api/v1/auth/mfa/disable", auth.mfaDisable],
127
+ ["POST /api/v1/auth/mfa/verify", auth.mfaVerify],
128
+
129
+ // Users (authenticated)
130
+ ["GET /api/v1/users/me", users.getMe],
131
+ ["PATCH /api/v1/users/me", users.updateMe],
132
+ ["DELETE /api/v1/users/me", users.deleteMe],
133
+
134
+ // API Keys — v1.4
135
+ ["POST /api/v1/api-keys", apiKeys.create],
136
+ ["GET /api/v1/api-keys", apiKeys.list],
137
+
138
+ // Webhooks — v1.5
139
+ ["POST /api/v1/webhooks", webhooks.create],
140
+ ["GET /api/v1/webhooks", webhooks.list],
141
+
142
+ // SSE — v1.5
143
+ ["GET /api/v1/events/stream", (req, ctx) => sse.stream(req, ctx)],
144
+
145
+ // Admin — list endpoint (no userId param)
146
+ ["GET /api/v1/admin/users", admin.listUsers],
147
+
148
+ // OpenAPI documentation
149
+ ["GET /docs", async (_req, _ctx) => docs.json()],
150
+ ["GET /docs/html", async (_req, _ctx) => docs.html()],
151
+
152
+ // Prometheus metrics
153
+ ["GET /metrics", async (_req, _ctx) => metrics.serve()],
154
+ ]);
155
+
156
+ /**
157
+ * Match parametric admin routes: /api/v1/admin/users/:id{/action}
158
+ * Returns the handler or null.
159
+ */
160
+ const matchAdmin = (
161
+ method: string,
162
+ path: string,
163
+ ): // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: parametric route matching requires many branches
164
+ ((req: Request, ctx: RequestContext) => Promise<Response>) | null => {
165
+ if (!path.startsWith(ADMIN_PREFIX)) return null;
166
+
167
+ const rest = path.substring(ADMIN_PREFIX.length);
168
+ const slashIdx = rest.indexOf("/");
169
+
170
+ if (slashIdx === -1) {
171
+ // /api/v1/admin/users/:id
172
+ const userId = rest;
173
+ if (method === "GET") return (req, ctx) => admin.getUser(req, ctx, userId);
174
+ return null;
175
+ }
176
+
177
+ const userId = rest.substring(0, slashIdx);
178
+ const action = rest.substring(slashIdx + 1);
179
+
180
+ if (method === "PATCH" && action === "role") {
181
+ return (req, ctx) => admin.changeRole(req, ctx, userId);
182
+ }
183
+ if (method === "POST" && action === "ban") {
184
+ return (req, ctx) => admin.banUser(req, ctx, userId);
185
+ }
186
+ if (method === "POST" && action === "unban") {
187
+ return (req, ctx) => admin.unbanUser(req, ctx, userId);
188
+ }
189
+
190
+ return null;
191
+ };
192
+
193
+ /**
194
+ * Match parametric API key routes: DELETE /api/v1/api-keys/:id
195
+ */
196
+ const matchApiKey = (
197
+ method: string,
198
+ path: string,
199
+ ): ((req: Request, ctx: RequestContext) => Promise<Response>) | null => {
200
+ if (method !== "DELETE" || !path.startsWith(API_KEY_PREFIX)) return null;
201
+ const keyId = path.substring(API_KEY_PREFIX.length);
202
+ if (!keyId || keyId.includes("/")) return null;
203
+ return (req, ctx) => apiKeys.revoke(req, ctx, keyId);
204
+ };
205
+
206
+ /**
207
+ * Match parametric OAuth routes:
208
+ * GET /api/v1/auth/oauth/:provider
209
+ * POST /api/v1/auth/oauth/:provider/callback
210
+ */
211
+ const matchOAuth = (
212
+ method: string,
213
+ path: string,
214
+ ): ((req: Request, ctx: RequestContext) => Promise<Response>) | null => {
215
+ if (!path.startsWith(OAUTH_PREFIX)) return null;
216
+ const rest = path.substring(OAUTH_PREFIX.length);
217
+ const slashIdx = rest.indexOf("/");
218
+
219
+ if (slashIdx === -1) {
220
+ // /api/v1/auth/oauth/:provider
221
+ if (method === "GET") return (req, ctx) => oauth.authorize(req, ctx, rest);
222
+ return null;
223
+ }
224
+
225
+ const provider = rest.substring(0, slashIdx);
226
+ const action = rest.substring(slashIdx + 1);
227
+
228
+ if (method === "POST" && action === "callback") {
229
+ return (req, ctx) => oauth.callback(req, ctx, provider);
230
+ }
231
+
232
+ return null;
233
+ };
234
+
235
+ /**
236
+ * Match parametric webhook routes: DELETE /api/v1/webhooks/:id
237
+ */
238
+ const matchWebhook = (
239
+ method: string,
240
+ path: string,
241
+ ): ((req: Request, ctx: RequestContext) => Promise<Response>) | null => {
242
+ if (method !== "DELETE" || !path.startsWith(WEBHOOK_PREFIX)) return null;
243
+ const webhookId = path.substring(WEBHOOK_PREFIX.length);
244
+ if (!webhookId || webhookId.includes("/")) return null;
245
+ return (req, ctx) => webhooks.remove(req, ctx, webhookId);
246
+ };
247
+
248
+ return {
249
+ handle(req: Request, ctx: RequestContext, path: string): Promise<Response> {
250
+ // 1. Try static routes first (O(1))
251
+ const handler = routes.get(`${req.method} ${path}`);
252
+ if (handler) return handler(req, ctx);
253
+
254
+ // 2. Try parametric routes
255
+ const adminHandler = matchAdmin(req.method, path);
256
+ if (adminHandler) return adminHandler(req, ctx);
257
+
258
+ const apiKeyHandler = matchApiKey(req.method, path);
259
+ if (apiKeyHandler) return apiKeyHandler(req, ctx);
260
+
261
+ const oauthHandler = matchOAuth(req.method, path);
262
+ if (oauthHandler) return oauthHandler(req, ctx);
263
+
264
+ const webhookHandler = matchWebhook(req.method, path);
265
+ if (webhookHandler) return webhookHandler(req, ctx);
266
+
267
+ return Promise.resolve(notFound404(req.method, path));
268
+ },
269
+ };
270
+ };
271
+
272
+ export type Router = ReturnType<typeof createRouter>;
@@ -0,0 +1,381 @@
1
+ import type { Logger } from "../core/ports/logger.js";
2
+ import type { MetricsCollector } from "../core/ports/metrics.js";
3
+ import { brand } from "../core/types/brand.js";
4
+ import type { AppConfig } from "../infrastructure/config/config.js";
5
+ import { formatTraceparent, resolveTraceContext } from "../infrastructure/tracing/trace-context.js";
6
+ import { formatAccessLog, formatCorsRejectLog, formatRateLimitLog } from "../shared/log-format.js";
7
+ import { generateId } from "../shared/utils/id.js";
8
+ import type { RequestContext } from "./context.js";
9
+ import type { WebSocketManager, WsConnectionData } from "./handlers/websocket.handler.js";
10
+ import { createI18nContext } from "./i18n/index.js";
11
+ import { securityHeaders } from "./middleware/security-headers.js";
12
+ import { addVersionHeaders, normalizeVersionedPath } from "./middleware/versioning.js";
13
+ import type { Router } from "./routes/router.js";
14
+
15
+ interface ServerDeps {
16
+ readonly config: AppConfig;
17
+ readonly logger: Logger;
18
+ readonly router: Router;
19
+ readonly metrics: MetricsCollector;
20
+ readonly wsManager?: WebSocketManager | undefined;
21
+ }
22
+
23
+ /**
24
+ * Extract pathname from a full URL string WITHOUT allocating a URL object.
25
+ * `new URL()` is one of the most expensive ops per-request (~2-4µs).
26
+ * This does it in ~50ns with pure string slicing.
27
+ */
28
+ const extractPath = (url: string): string => {
29
+ // url format: "http://host:port/path?query"
30
+ // Find the third '/' which starts the pathname
31
+ const start = url.indexOf("/", url.indexOf("//") + 2);
32
+ if (start === -1) return "/";
33
+ const qIdx = url.indexOf("?", start);
34
+ return qIdx === -1 ? url.substring(start) : url.substring(start, qIdx);
35
+ };
36
+
37
+ export const createServer = (deps: ServerDeps) => {
38
+ const { config, logger, router, metrics, wsManager } = deps;
39
+
40
+ // ── Pre-compute EVERYTHING possible at boot, not per-request ──
41
+
42
+ /** Frozen security header entries — computed once */
43
+ const secHeaders = securityHeaders(config);
44
+ const secHeaderEntries: ReadonlyArray<readonly [string, string]> = Object.freeze(
45
+ Object.entries(secHeaders),
46
+ );
47
+
48
+ /** Pre-computed CORS headers for wildcard origin mode (most common) */
49
+ const isWildcardCors = config.cors.origins.includes("*");
50
+ const allowedOriginSet = new Set(config.cors.origins);
51
+
52
+ /** Frozen CORS headers template */
53
+ const corsBase: ReadonlyArray<readonly [string, string]> = Object.freeze([
54
+ ["Access-Control-Allow-Credentials", "true"],
55
+ ["Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"],
56
+ ["Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id"],
57
+ ["Access-Control-Max-Age", "86400"],
58
+ ["Vary", "Origin"],
59
+ ]);
60
+
61
+ /** Pre-built 204 preflight headers (fully static — zero alloc on preflight) */
62
+ const preflightBaseHeaders = new Headers();
63
+ preflightBaseHeaders.set("Access-Control-Allow-Credentials", "true");
64
+ preflightBaseHeaders.set(
65
+ "Access-Control-Allow-Methods",
66
+ "GET, POST, PUT, PATCH, DELETE, OPTIONS",
67
+ );
68
+ preflightBaseHeaders.set(
69
+ "Access-Control-Allow-Headers",
70
+ "Content-Type, Authorization, X-Request-Id",
71
+ );
72
+ preflightBaseHeaders.set("Access-Control-Max-Age", "86400");
73
+ preflightBaseHeaders.set("Vary", "Origin");
74
+ for (const [k, v] of secHeaderEntries) preflightBaseHeaders.set(k, v);
75
+
76
+ /** Rate limit config cached as locals */
77
+ const rlWindowMs = config.rateLimit.windowMs;
78
+ const rlMax = config.rateLimit.maxRequests;
79
+ const rlMaxStr = String(rlMax);
80
+
81
+ /** Pre-serialized error responses (allocated once, reused forever) */
82
+ const rateLimitedBody = JSON.stringify({
83
+ error: { code: "RATE_LIMITED", message: "Too many requests" },
84
+ });
85
+
86
+ const internalErrorBody = JSON.stringify({
87
+ error: { code: "INTERNAL", message: "Internal server error" },
88
+ });
89
+
90
+ // ── Rate limiter inlined for zero function-call overhead ──
91
+ const rlStore = new Map<string, { count: number; resetAt: number }>();
92
+ let lastPrune = 0;
93
+
94
+ // ── Batched async logger — accumulate log entries, flush in bulk ──
95
+ // In production: batches to avoid per-request stdout syscall (the #1 bottleneck in benchmarks)
96
+ // In development: writes immediately so logs appear instantly in the terminal
97
+ let logBuffer: string[] = [];
98
+ let logFlushScheduled = false;
99
+ const isDev = config.env !== "production";
100
+ const LOG_FLUSH_INTERVAL_MS = 100; // flush every 100ms (production only)
101
+
102
+ const flushLogs = (): void => {
103
+ if (logBuffer.length === 0) {
104
+ logFlushScheduled = false;
105
+ return;
106
+ }
107
+ const batch = logBuffer;
108
+ logBuffer = [];
109
+ logFlushScheduled = false;
110
+
111
+ // Single write syscall for all accumulated entries
112
+ process.stdout.write(batch.join(""));
113
+ };
114
+
115
+ /** Write a log line — immediate in dev, batched in production */
116
+ const writeLog = (line: string): void => {
117
+ if (isDev) {
118
+ // console.log ensures immediate, unbuffered output in Bun.serve handlers
119
+ const trimmed = line.endsWith("\n") ? line.slice(0, -1) : line;
120
+ console.log(trimmed);
121
+ return;
122
+ }
123
+ logBuffer.push(line);
124
+ if (!logFlushScheduled) {
125
+ logFlushScheduled = true;
126
+ setTimeout(flushLogs, LOG_FLUSH_INTERVAL_MS);
127
+ }
128
+ };
129
+
130
+ const shouldLog = config.log.level !== "fatal"; // "fatal" = effectively no access log
131
+
132
+ // ── Hot path ──
133
+
134
+ const handleRequest = async (
135
+ req: Request,
136
+ server: ReturnType<typeof Bun.serve>,
137
+ ): // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: HTTP hot path handles many concerns
138
+ Promise<Response> => {
139
+ const method = req.method;
140
+
141
+ // Fast-path: OPTIONS preflight — skip ALL middleware, near-zero alloc
142
+ if (method === "OPTIONS") {
143
+ const origin = req.headers.get("origin");
144
+ if (origin !== null && (isWildcardCors || allowedOriginSet.has(origin))) {
145
+ const h = new Headers(preflightBaseHeaders);
146
+ h.set("Access-Control-Allow-Origin", origin);
147
+ return new Response(null, { status: 204, headers: h });
148
+ }
149
+ // Rejected CORS preflight
150
+ if (shouldLog) {
151
+ const rejOrigin = req.headers.get("origin") ?? "none";
152
+ writeLog(formatCorsRejectLog(rejOrigin));
153
+ }
154
+ return new Response(null, { status: 403 });
155
+ }
156
+
157
+ // Extract path WITHOUT new URL() — ~12x faster
158
+ const path = extractPath(req.url);
159
+
160
+ // Generate or reuse request id
161
+ const requestId = req.headers.get("x-request-id") ?? generateId();
162
+
163
+ // Inline rate limit check — avoid function call + Result allocation
164
+ const ip = server.requestIP(req)?.address ?? "0";
165
+ const now = Date.now();
166
+
167
+ // Prune at most once per minute
168
+ if (now - lastPrune > 60_000) {
169
+ lastPrune = now;
170
+ for (const [key, entry] of rlStore) {
171
+ if (entry.resetAt <= now) rlStore.delete(key);
172
+ }
173
+ }
174
+
175
+ let rlEntry = rlStore.get(ip);
176
+ if (!rlEntry || rlEntry.resetAt <= now) {
177
+ rlEntry = { count: 1, resetAt: now + rlWindowMs };
178
+ rlStore.set(ip, rlEntry);
179
+ } else {
180
+ rlEntry.count++;
181
+ }
182
+
183
+ if (rlEntry.count > rlMax) {
184
+ // Batched rate-limit warning — avoids per-hit syscall
185
+ if (shouldLog) {
186
+ writeLog(formatRateLimitLog(ip, rlEntry.count));
187
+ }
188
+
189
+ const h = new Headers();
190
+ h.set("Content-Type", "application/json");
191
+ h.set("X-Request-Id", requestId);
192
+ h.set("X-RateLimit-Limit", rlMaxStr);
193
+ h.set("X-RateLimit-Remaining", "0");
194
+ h.set("X-RateLimit-Reset", String(Math.ceil(rlEntry.resetAt / 1000)));
195
+ h.set("Retry-After", String(Math.ceil((rlEntry.resetAt - now) / 1000)));
196
+ for (const [k, v] of secHeaderEntries) h.set(k, v);
197
+ return new Response(rateLimitedBody, { status: 429, headers: h });
198
+ }
199
+
200
+ // ── WebSocket upgrade ──
201
+ if (wsManager && path === "/ws" && method === "GET") {
202
+ const upgradeData = wsManager.handleUpgrade(req, ip);
203
+ if (upgradeData) {
204
+ const upgraded = server.upgrade(req, { data: upgradeData });
205
+ if (upgraded) return undefined as unknown as Response; // Bun handles the upgrade
206
+ }
207
+ return new Response("WebSocket upgrade failed", { status: 400 });
208
+ }
209
+
210
+ // ── Route & handle ──
211
+ const trace = resolveTraceContext(req.headers.get("traceparent"));
212
+
213
+ // API versioning — normalize /api/v2/... → /api/v1/... for handler lookup
214
+ const { normalized: routePath, version: apiVersion } = normalizeVersionedPath(path);
215
+
216
+ // i18n — resolve locale from Accept-Language header
217
+ const i18n = createI18nContext(req, config.i18n.supportedLocales, config.i18n.defaultLocale);
218
+
219
+ const ctx: RequestContext = {
220
+ requestId: brand<string, "RequestId">(requestId),
221
+ startTime: shouldLog ? performance.now() : 0,
222
+ ip,
223
+ method,
224
+ path,
225
+ trace,
226
+ logger: logger.child({ requestId, traceId: trace.traceId, spanId: trace.spanId }),
227
+ apiVersion,
228
+ i18n,
229
+ };
230
+
231
+ // Track active connections
232
+ metrics.httpActiveConnections.inc();
233
+
234
+ let response: Response;
235
+ try {
236
+ response = await router.handle(req, ctx, routePath);
237
+ } catch (e: unknown) {
238
+ metrics.httpActiveConnections.dec();
239
+ metrics.httpErrorsTotal.inc({ method, status: "500" });
240
+ metrics.httpRequestsTotal.inc({ method, status: "500", path });
241
+ logger.error("Unhandled error", {
242
+ requestId,
243
+ traceId: trace.traceId,
244
+ error: e instanceof Error ? e.message : String(e),
245
+ });
246
+ const h = new Headers();
247
+ h.set("Content-Type", "application/json");
248
+ h.set("X-Request-Id", requestId);
249
+ h.set("traceparent", formatTraceparent(trace));
250
+ for (const [k, v] of secHeaderEntries) h.set(k, v);
251
+ return new Response(internalErrorBody, { status: 500, headers: h });
252
+ }
253
+
254
+ // ── ETag / conditional GET ──
255
+ // For GET 200 responses with JSON content, compute ETag and check If-None-Match
256
+ if (method === "GET" && response.status === 200) {
257
+ const body = await response.text();
258
+ const hash = new Bun.CryptoHasher("md5").update(body).digest("hex");
259
+ const etag = `"${hash}"`;
260
+
261
+ const ifNoneMatch = req.headers.get("if-none-match");
262
+ if (ifNoneMatch === etag) {
263
+ // 304 Not Modified — no body, just headers
264
+ const notModifiedHeaders = new Headers();
265
+ notModifiedHeaders.set("ETag", etag);
266
+ notModifiedHeaders.set("X-Request-Id", requestId);
267
+ notModifiedHeaders.set("traceparent", formatTraceparent(trace));
268
+ for (const [k, v] of secHeaderEntries) notModifiedHeaders.set(k, v);
269
+
270
+ if (shouldLog) {
271
+ const durationMs = Math.round((performance.now() - ctx.startTime) * 100) / 100;
272
+ metrics.httpRequestDurationMs.observe(durationMs, { method, path });
273
+ writeLog(formatAccessLog(method, path, 304, durationMs, ip, requestId));
274
+ }
275
+
276
+ metrics.httpActiveConnections.dec();
277
+ metrics.httpRequestsTotal.inc({ method, status: "304", path });
278
+
279
+ return new Response(null, { status: 304, headers: notModifiedHeaders });
280
+ }
281
+
282
+ // Rebuild response with ETag header and original body
283
+ response = new Response(body, {
284
+ status: response.status,
285
+ headers: response.headers,
286
+ });
287
+ response.headers.set("ETag", etag);
288
+ }
289
+
290
+ // ── Append cross-cutting headers to the response ──
291
+ const resHeaders = response.headers;
292
+ resHeaders.set("X-Request-Id", requestId);
293
+ resHeaders.set("X-RateLimit-Limit", rlMaxStr);
294
+ resHeaders.set("X-RateLimit-Remaining", String(rlMax - rlEntry.count));
295
+ resHeaders.set("X-RateLimit-Reset", String(Math.ceil(rlEntry.resetAt / 1000)));
296
+ resHeaders.set("traceparent", formatTraceparent(trace));
297
+ resHeaders.set("Content-Language", i18n.locale);
298
+
299
+ // API version headers (deprecation for v1)
300
+ addVersionHeaders(
301
+ response,
302
+ apiVersion,
303
+ apiVersion === "v1" ? path.replace("/v1/", "/v2/") : undefined,
304
+ );
305
+
306
+ for (const [k, v] of secHeaderEntries) resHeaders.set(k, v);
307
+
308
+ // CORS headers (only if origin present)
309
+ const origin = req.headers.get("origin");
310
+ if (origin !== null && (isWildcardCors || allowedOriginSet.has(origin))) {
311
+ resHeaders.set("Access-Control-Allow-Origin", origin);
312
+ for (const [k, v] of corsBase) resHeaders.set(k, v);
313
+ }
314
+
315
+ // ── Metrics ──
316
+ const statusStr = String(response.status);
317
+ metrics.httpRequestsTotal.inc({ method, status: statusStr, path });
318
+ if (response.status >= 400) {
319
+ metrics.httpErrorsTotal.inc({ method, status: statusStr });
320
+ }
321
+ metrics.httpActiveConnections.dec();
322
+
323
+ // Access log — immediate in dev, batched in production
324
+ if (shouldLog) {
325
+ const durationMs = Math.round((performance.now() - ctx.startTime) * 100) / 100;
326
+ metrics.httpRequestDurationMs.observe(durationMs, { method, path });
327
+ writeLog(formatAccessLog(method, path, response.status, durationMs, ip, requestId));
328
+ }
329
+
330
+ return response;
331
+ };
332
+
333
+ return {
334
+ start() {
335
+ const baseConfig = {
336
+ port: config.port,
337
+ hostname: config.host,
338
+ fetch: handleRequest,
339
+ reusePort: true, // Enables SO_REUSEPORT — critical for multi-process scaling
340
+ maxRequestBodySize: 1_048_576, // 1 MiB
341
+ idleTimeout: 30, // seconds
342
+ };
343
+
344
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.serve overloads require different shapes with/without websocket
345
+ let server: ReturnType<typeof Bun.serve<any>>;
346
+
347
+ if (wsManager) {
348
+ const mgr = wsManager;
349
+ server = Bun.serve({
350
+ ...baseConfig,
351
+ websocket: {
352
+ open(ws: import("bun").ServerWebSocket<WsConnectionData>) {
353
+ mgr.onOpen(ws);
354
+ },
355
+ message(ws: import("bun").ServerWebSocket<WsConnectionData>, message: string | Buffer) {
356
+ mgr.onMessage(ws, message);
357
+ },
358
+ close(
359
+ ws: import("bun").ServerWebSocket<WsConnectionData>,
360
+ code: number,
361
+ reason: string,
362
+ ) {
363
+ mgr.onClose(ws, code, reason);
364
+ },
365
+ },
366
+ });
367
+ } else {
368
+ server = Bun.serve(baseConfig);
369
+ }
370
+
371
+ // Banner is printed by main.ts / cluster.ts — no duplicate logging here
372
+
373
+ // Flush logs on shutdown
374
+ process.on("beforeExit", flushLogs);
375
+
376
+ return server;
377
+ },
378
+ /** Force-flush any buffered access logs (call before exit) */
379
+ flush: flushLogs,
380
+ };
381
+ };