@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.
- package/CHANGELOG.md +201 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/dist/cli.js +14 -0
- package/package.json +69 -0
- package/src/application/dtos/admin.dto.ts +25 -0
- package/src/application/dtos/auth.dto.ts +97 -0
- package/src/application/dtos/index.ts +40 -0
- package/src/application/index.ts +2 -0
- package/src/application/services/admin.service.ts +150 -0
- package/src/application/services/api-key.service.ts +65 -0
- package/src/application/services/auth.service.ts +606 -0
- package/src/application/services/health.service.ts +97 -0
- package/src/application/services/index.ts +10 -0
- package/src/application/services/user.service.ts +95 -0
- package/src/cli/commands/help.ts +86 -0
- package/src/cli/commands/init.ts +301 -0
- package/src/cli/commands/upgrade.ts +471 -0
- package/src/cli/index.ts +76 -0
- package/src/cli/ui.ts +189 -0
- package/src/cluster.ts +62 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/entities/user.entity.ts +24 -0
- package/src/core/errors/app-error.ts +81 -0
- package/src/core/errors/index.ts +15 -0
- package/src/core/index.ts +7 -0
- package/src/core/ports/account-lockout.ts +15 -0
- package/src/core/ports/alert-sink.ts +27 -0
- package/src/core/ports/api-key.ts +37 -0
- package/src/core/ports/audit-log.ts +46 -0
- package/src/core/ports/cache.ts +24 -0
- package/src/core/ports/circuit-breaker.ts +42 -0
- package/src/core/ports/event-bus.ts +78 -0
- package/src/core/ports/index.ts +62 -0
- package/src/core/ports/job-queue.ts +73 -0
- package/src/core/ports/logger.ts +21 -0
- package/src/core/ports/metrics.ts +49 -0
- package/src/core/ports/oauth.ts +55 -0
- package/src/core/ports/password-hasher.ts +10 -0
- package/src/core/ports/password-history.ts +23 -0
- package/src/core/ports/password-policy.ts +43 -0
- package/src/core/ports/refresh-token-store.ts +37 -0
- package/src/core/ports/retry.ts +23 -0
- package/src/core/ports/token-blacklist.ts +16 -0
- package/src/core/ports/token-service.ts +23 -0
- package/src/core/ports/totp-service.ts +16 -0
- package/src/core/ports/user.repository.ts +40 -0
- package/src/core/ports/verification-token.ts +41 -0
- package/src/core/ports/webhook.ts +58 -0
- package/src/core/types/brand.ts +19 -0
- package/src/core/types/index.ts +19 -0
- package/src/core/types/pagination.ts +28 -0
- package/src/core/types/result.ts +52 -0
- package/src/infrastructure/alerting/index.ts +1 -0
- package/src/infrastructure/alerting/webhook.ts +100 -0
- package/src/infrastructure/cache/in-memory-cache.ts +111 -0
- package/src/infrastructure/cache/index.ts +6 -0
- package/src/infrastructure/cache/redis-cache.ts +204 -0
- package/src/infrastructure/config/config.ts +185 -0
- package/src/infrastructure/config/index.ts +1 -0
- package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
- package/src/infrastructure/database/index.ts +37 -0
- package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
- package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
- package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
- package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
- package/src/infrastructure/database/migrations/runner.ts +120 -0
- package/src/infrastructure/database/mssql/index.ts +14 -0
- package/src/infrastructure/database/mssql/migrations.ts +299 -0
- package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
- package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
- package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
- package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
- package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
- package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
- package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
- package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
- package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
- package/src/infrastructure/database/postgres/index.ts +14 -0
- package/src/infrastructure/database/postgres/migrations.ts +235 -0
- package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
- package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
- package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
- package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
- package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
- package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
- package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
- package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
- package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
- package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
- package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
- package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
- package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
- package/src/infrastructure/database/sqlite-password-history.ts +54 -0
- package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
- package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
- package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
- package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
- package/src/infrastructure/events/event-bus.ts +105 -0
- package/src/infrastructure/events/event-factory.ts +31 -0
- package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
- package/src/infrastructure/events/index.ts +4 -0
- package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
- package/src/infrastructure/index.ts +58 -0
- package/src/infrastructure/jobs/index.ts +1 -0
- package/src/infrastructure/jobs/job-queue.ts +185 -0
- package/src/infrastructure/logging/index.ts +1 -0
- package/src/infrastructure/logging/logger.ts +63 -0
- package/src/infrastructure/metrics/index.ts +1 -0
- package/src/infrastructure/metrics/prometheus.ts +231 -0
- package/src/infrastructure/oauth/github.ts +116 -0
- package/src/infrastructure/oauth/google.ts +83 -0
- package/src/infrastructure/oauth/index.ts +2 -0
- package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
- package/src/infrastructure/resilience/index.ts +2 -0
- package/src/infrastructure/resilience/retry.ts +50 -0
- package/src/infrastructure/security/account-lockout.ts +73 -0
- package/src/infrastructure/security/index.ts +6 -0
- package/src/infrastructure/security/password-hasher.ts +31 -0
- package/src/infrastructure/security/password-policy.ts +77 -0
- package/src/infrastructure/security/token-blacklist.ts +45 -0
- package/src/infrastructure/security/token-service.ts +144 -0
- package/src/infrastructure/security/totp-service.ts +142 -0
- package/src/infrastructure/tracing/index.ts +7 -0
- package/src/infrastructure/tracing/trace-context.ts +93 -0
- package/src/main.ts +479 -0
- package/src/presentation/context.ts +26 -0
- package/src/presentation/handlers/admin.handler.ts +114 -0
- package/src/presentation/handlers/api-key.handler.ts +68 -0
- package/src/presentation/handlers/auth.handler.ts +218 -0
- package/src/presentation/handlers/health.handler.ts +27 -0
- package/src/presentation/handlers/index.ts +15 -0
- package/src/presentation/handlers/metrics.handler.ts +21 -0
- package/src/presentation/handlers/oauth.handler.ts +61 -0
- package/src/presentation/handlers/openapi.handler.ts +543 -0
- package/src/presentation/handlers/response.ts +29 -0
- package/src/presentation/handlers/sse.handler.ts +165 -0
- package/src/presentation/handlers/user.handler.ts +81 -0
- package/src/presentation/handlers/webhook.handler.ts +92 -0
- package/src/presentation/handlers/websocket.handler.ts +226 -0
- package/src/presentation/i18n/index.ts +254 -0
- package/src/presentation/index.ts +5 -0
- package/src/presentation/middleware/api-key.ts +18 -0
- package/src/presentation/middleware/auth.ts +39 -0
- package/src/presentation/middleware/cors.ts +41 -0
- package/src/presentation/middleware/index.ts +12 -0
- package/src/presentation/middleware/rate-limit.ts +65 -0
- package/src/presentation/middleware/security-headers.ts +18 -0
- package/src/presentation/middleware/validate.ts +16 -0
- package/src/presentation/middleware/versioning.ts +69 -0
- package/src/presentation/routes/index.ts +1 -0
- package/src/presentation/routes/router.ts +272 -0
- package/src/presentation/server.ts +381 -0
- package/src/shared/cli.ts +294 -0
- package/src/shared/container.ts +65 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/log-format.ts +148 -0
- package/src/shared/utils/id.ts +5 -0
- package/src/shared/utils/index.ts +2 -0
- 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
|
+
};
|