threadforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/bin/forge.js +1050 -0
  4. package/bin/host-commands.js +344 -0
  5. package/bin/platform-commands.js +570 -0
  6. package/package.json +71 -0
  7. package/shared/auth.js +475 -0
  8. package/src/core/DirectMessageBus.js +364 -0
  9. package/src/core/EndpointResolver.js +247 -0
  10. package/src/core/ForgeContext.js +2227 -0
  11. package/src/core/ForgeHost.js +122 -0
  12. package/src/core/ForgePlatform.js +145 -0
  13. package/src/core/Ingress.js +768 -0
  14. package/src/core/Interceptors.js +420 -0
  15. package/src/core/MessageBus.js +310 -0
  16. package/src/core/Prometheus.js +305 -0
  17. package/src/core/RequestContext.js +413 -0
  18. package/src/core/RoutingStrategy.js +316 -0
  19. package/src/core/Supervisor.js +1306 -0
  20. package/src/core/ThreadAllocator.js +196 -0
  21. package/src/core/WorkerChannelManager.js +879 -0
  22. package/src/core/config.js +624 -0
  23. package/src/core/host-config.js +311 -0
  24. package/src/core/network-utils.js +166 -0
  25. package/src/core/platform-config.js +308 -0
  26. package/src/decorators/ServiceProxy.js +899 -0
  27. package/src/decorators/index.js +571 -0
  28. package/src/deploy/NginxGenerator.js +865 -0
  29. package/src/deploy/PlatformManifestGenerator.js +96 -0
  30. package/src/deploy/RouteManifestGenerator.js +112 -0
  31. package/src/deploy/index.js +984 -0
  32. package/src/frontend/FrontendDevLifecycle.js +65 -0
  33. package/src/frontend/FrontendPluginOrchestrator.js +187 -0
  34. package/src/frontend/SiteResolver.js +63 -0
  35. package/src/frontend/StaticMountRegistry.js +90 -0
  36. package/src/frontend/index.js +5 -0
  37. package/src/frontend/plugins/index.js +2 -0
  38. package/src/frontend/plugins/viteFrontend.js +79 -0
  39. package/src/frontend/types.js +35 -0
  40. package/src/index.js +56 -0
  41. package/src/internals.js +31 -0
  42. package/src/plugins/PluginManager.js +537 -0
  43. package/src/plugins/ScopedPostgres.js +192 -0
  44. package/src/plugins/ScopedRedis.js +142 -0
  45. package/src/plugins/index.js +1729 -0
  46. package/src/registry/ServiceRegistry.js +796 -0
  47. package/src/scaling/ScaleAdvisor.js +442 -0
  48. package/src/services/Service.js +195 -0
  49. package/src/services/worker-bootstrap.js +676 -0
  50. package/src/templates/auth-service.js +65 -0
  51. package/src/templates/identity-service.js +75 -0
@@ -0,0 +1,2227 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import fs from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import path from "node:path";
6
+ import { getContract } from "../decorators/index.js";
7
+ import { StaticMountRegistry } from "../frontend/StaticMountRegistry.js";
8
+ import { IngressProtection } from "./Ingress.js";
9
+ import { PrometheusMetrics } from "./Prometheus.js";
10
+ import { RequestContext } from "./RequestContext.js";
11
+ import { WorkerChannelManager } from "./WorkerChannelManager.js";
12
+ // A10: Network utilities extracted to network-utils.js
13
+ import { isPrivateNetwork, isTrustedProxy } from "./network-utils.js";
14
+
15
+ // Re-export for backward compatibility (A10)
16
+ export { isPrivateNetwork, isTrustedProxy };
17
+
18
+ const MAX_BODY_SIZE = 1_048_576; // 1MB
19
+ const MAX_WS_BUFFER = 1 * 1024 * 1024; // 1MB
20
+ const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
21
+ // S11: Body timeout reduced from 30s to 10s
22
+ const BODY_TIMEOUT_MS = 10_000;
23
+ // S1: HMAC signature max clock skew (replay protection)
24
+ const INTERNAL_SIG_MAX_AGE_MS = 30_000;
25
+ // P11: Route cache max size
26
+ const ROUTE_CACHE_MAX = 10_000;
27
+ const EXTENSION_TO_MIME = Object.freeze({
28
+ ".html": "text/html; charset=utf-8",
29
+ ".css": "text/css; charset=utf-8",
30
+ ".js": "application/javascript; charset=utf-8",
31
+ ".mjs": "application/javascript; charset=utf-8",
32
+ ".cjs": "application/javascript; charset=utf-8",
33
+ ".json": "application/json; charset=utf-8",
34
+ ".map": "application/json; charset=utf-8",
35
+ ".svg": "image/svg+xml",
36
+ ".png": "image/png",
37
+ ".jpg": "image/jpeg",
38
+ ".jpeg": "image/jpeg",
39
+ ".gif": "image/gif",
40
+ ".webp": "image/webp",
41
+ ".ico": "image/x-icon",
42
+ ".txt": "text/plain; charset=utf-8",
43
+ ".xml": "application/xml; charset=utf-8",
44
+ ".woff": "font/woff",
45
+ ".woff2": "font/woff2",
46
+ ".ttf": "font/ttf",
47
+ ".otf": "font/otf",
48
+ });
49
+
50
+ // O5: Log levels
51
+ const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
52
+
53
+ export const NOT_HANDLED = Symbol("NOT_HANDLED");
54
+
55
+ const BLOCKED_LIFECYCLE_METHODS = new Set(["onStart", "onStop", "onMessage", "onRequest"]);
56
+
57
+ // Error messages for fatal bind errors
58
+ const BIND_ERROR_MESSAGES = {
59
+ EPERM: (port) => `Permission denied binding to port ${port}. Run with elevated privileges or use a port >= 1024.`,
60
+ EACCES: (port) => `Access denied binding to port ${port}. Run with elevated privileges or use a port >= 1024.`,
61
+ EADDRNOTAVAIL: (port) => `Cannot bind to port ${port}. Address not available (restricted environment or invalid configuration).`,
62
+ EADDRINUSE: (port) => `Port ${port} is already in use. Stop the conflicting process or choose a different port.`,
63
+ };
64
+
65
+ /**
66
+ * S1: Validate HMAC-SHA256 internal signature.
67
+ * Replaces FORGE_INTERNAL_AUTH_BYPASS with per-request HMAC validation.
68
+ */
69
+ function validateInternalSignature(req, method, path) {
70
+ const secret = process.env.FORGE_INTERNAL_SECRET;
71
+ if (!secret) {
72
+ // In development, allow without signature; in production, reject
73
+ return process.env.NODE_ENV !== 'production';
74
+ }
75
+ const sig = req.headers['x-forge-internal-sig'];
76
+ const ts = req.headers['x-forge-internal-ts'];
77
+ if (!sig || !ts) return false;
78
+
79
+ // Replay protection: reject if timestamp is >30s old
80
+ const timestamp = parseInt(ts, 10);
81
+ if (Number.isNaN(timestamp)) return false;
82
+ const age = Math.abs(Date.now() - timestamp);
83
+ if (age > INTERNAL_SIG_MAX_AGE_MS) return false;
84
+
85
+ // HMAC-SHA256(secret, "METHOD:path:timestamp")
86
+ const expected = createHmac('sha256', secret)
87
+ .update(`${method}:${path}:${ts}`)
88
+ .digest('hex');
89
+
90
+ // Constant-time comparison
91
+ try {
92
+ const sigBuf = Buffer.from(sig, 'hex');
93
+ const expectedBuf = Buffer.from(expected, 'hex');
94
+ if (sigBuf.length !== expectedBuf.length) return false;
95
+ return timingSafeEqual(sigBuf, expectedBuf);
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * S2: Verify a JWT token (HMAC-SHA256) using Node built-in crypto.
103
+ * Returns the decoded payload if valid, or null if invalid/expired.
104
+ */
105
+ function verifyJwt(token) {
106
+ const secret = process.env.JWT_SECRET;
107
+ if (!secret || !token) return null;
108
+
109
+ // Strip "Bearer " prefix if present
110
+ const raw = token.startsWith('Bearer ') ? token.slice(7) : token;
111
+ const parts = raw.split('.');
112
+ if (parts.length !== 3) return null;
113
+
114
+ const [headerB64, payloadB64, signatureB64] = parts;
115
+
116
+ // SEC-C1: Validate the JWT header — enforce HS256, reject alg:none and mismatches
117
+ try {
118
+ const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
119
+ if (!header || typeof header !== 'object') return null;
120
+ const alg = (header.alg ?? '').toUpperCase();
121
+ if (alg === 'NONE' || (alg !== 'HS256' && alg !== '')) return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+
126
+ // Verify signature
127
+ const expectedSig = createHmac('sha256', secret)
128
+ .update(`${headerB64}.${payloadB64}`)
129
+ .digest('base64url');
130
+
131
+ // Normalize base64url — strip trailing '=' padding for comparison
132
+ const normalizedSig = signatureB64.replace(/=+$/, '');
133
+ const normalizedExpected = expectedSig.replace(/=+$/, '');
134
+
135
+ if (normalizedSig.length !== normalizedExpected.length) return null;
136
+ try {
137
+ if (!timingSafeEqual(Buffer.from(normalizedSig), Buffer.from(normalizedExpected))) return null;
138
+ } catch {
139
+ return null;
140
+ }
141
+
142
+ // Decode payload
143
+ try {
144
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
145
+ // Check expiration
146
+ if (payload.exp && Date.now() / 1000 > payload.exp) return null;
147
+ return payload;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * A9: Shared auth check for internal forge endpoints.
155
+ * Returns { allowed: true } or { allowed: false, error, code }.
156
+ *
157
+ * S1: When FORGE_INTERNAL_SECRET is set, the HMAC signature check
158
+ * (validateInternalSignature) already validated the caller service.
159
+ * This function checks *user-level* auth on the target method.
160
+ *
161
+ * Backward compat: FORGE_INTERNAL_AUTH_BYPASS=true still skips the
162
+ * user-auth check entirely (same behavior as before).
163
+ */
164
+ function checkForgeEndpointAuth(serviceInstance, method, rctx) {
165
+ // Legacy bypass — skip all user-auth checks if explicitly enabled
166
+ const internalAuthBypass = (process.env.FORGE_INTERNAL_AUTH_BYPASS ?? 'false') === 'true';
167
+ if (internalAuthBypass) return { allowed: true };
168
+
169
+ const invokeContract = getContract(serviceInstance?.constructor);
170
+ if (!invokeContract) return { allowed: true };
171
+
172
+ const methodMeta = invokeContract.methods.get(method);
173
+ const routeDef = invokeContract.routes.find(r => r.handlerName === method);
174
+ const authRequirement = routeDef?.auth ?? methodMeta?.options?.auth ?? invokeContract.auth;
175
+ if (!authRequirement || authRequirement === 'none') return { allowed: true };
176
+
177
+ // SEC-C1: Validate auth — 401 for missing, verify JWT signature when JWT_SECRET set
178
+ if (!rctx.auth) {
179
+ return { allowed: false, error: "Authentication required", code: 401 };
180
+ }
181
+
182
+ // When JWT_SECRET is set, require valid JWT signature — presence alone is not sufficient
183
+ if (process.env.JWT_SECRET) {
184
+ if (!rctx._rawAuthToken) {
185
+ return { allowed: false, error: "Authentication required — JWT token missing", code: 401 };
186
+ }
187
+ const verified = verifyJwt(rctx._rawAuthToken);
188
+ if (!verified) {
189
+ return { allowed: false, error: "Invalid or expired token", code: 401 };
190
+ }
191
+ }
192
+
193
+ return { allowed: true };
194
+ }
195
+
196
+ /**
197
+ * ForgeContext
198
+ *
199
+ * Injected into every Service instance. Provides:
200
+ * - HTTP router
201
+ * - IPC messaging helpers (direct worker-to-worker when available)
202
+ * - Metrics collection
203
+ * - Structured logger
204
+ * - Runtime metadata (service name, thread count, worker id, etc.)
205
+ */
206
+ export class ForgeContext {
207
+ /**
208
+ * @param {Object} options
209
+ * @param {string} options.serviceName
210
+ * @param {number} options.port
211
+ * @param {number} options.workerId
212
+ * @param {number} options.threadCount
213
+ * @param {string} options.mode
214
+ * @param {string} options.serviceType - 'edge', 'internal', or 'background'
215
+ * @param {Function} options.sendIPC - Function to send IPC messages to supervisor
216
+ * @param {Function} [options.localSend] - Direct dispatch for colocated services
217
+ * @param {Function} [options.localRequest] - Direct request for colocated services
218
+ * @param {Array} [options.staticMounts] - Optional static mount descriptors for this service
219
+ */
220
+ constructor(options) {
221
+ this.serviceName = options.serviceName;
222
+ this.port = options.port;
223
+ this.workerId = options.workerId;
224
+ this.threadCount = options.threadCount;
225
+ this.mode = options.mode;
226
+ this.serviceType = options.serviceType ?? "internal";
227
+ this._sendIPC = options.sendIPC;
228
+ this._localSend = options.localSend ?? null;
229
+ this._localRequest = options.localRequest ?? null;
230
+ this._ingressConfig = options.ingress ?? {};
231
+ this._ingressApplied = false;
232
+ this._forgeProxy = options.forgeProxy ?? process.env.FORGE_PROXY_URL ?? null;
233
+ this._serviceInstance = null; // set by worker-bootstrap after service.onStart
234
+
235
+ // Port map for HTTP-based service calls: { serviceName: port }
236
+ try {
237
+ this._servicePorts = JSON.parse(process.env.FORGE_SERVICE_PORTS || "{}");
238
+ } catch {
239
+ this._servicePorts = {};
240
+ }
241
+
242
+ /** @type {import('./EndpointResolver.js').EndpointResolver | null} */
243
+ this._endpointResolver = null;
244
+
245
+ /** @type {Router} */
246
+ this.router = new Router();
247
+
248
+ /** @type {Logger} */
249
+ this.logger = new Logger(this.serviceName, this.workerId);
250
+
251
+ /** @type {Metrics} */
252
+ this.metrics = new PrometheusMetrics(this.serviceName, this.workerId);
253
+ this._staticMounts = options.staticMounts ?? [];
254
+ this._staticRegistry = new StaticMountRegistry(this._staticMounts);
255
+
256
+ /** @type {Set<WebSocket>} Active WebSocket connections */
257
+ this._wsConnections = new Set();
258
+ /** @type {Map<string, number>} H-SEC-2: Per-IP WebSocket connection counts */
259
+ this._wsPerIpCounts = new Map();
260
+ /** H-SEC-2: Max WebSocket connections per IP (configurable via FORGE_WS_MAX_PER_IP) */
261
+ this._wsMaxPerIp = parseInt(process.env.FORGE_WS_MAX_PER_IP || '100', 10) || 100;
262
+ /** @type {Map<string, Function>} WebSocket route handlers */
263
+ this._wsHandlers = new Map();
264
+ /** @type {Array<{name: string, onWsUpgrade?: Function, onWsConnect?: Function, onWsMessage?: Function, onWsClose?: Function}>} */
265
+ this._wsPluginHooks = [];
266
+
267
+ /**
268
+ * Direct channel manager — handles MessagePort connections
269
+ * to other services, bypassing the supervisor for all
270
+ * inter-service communication.
271
+ * @type {WorkerChannelManager}
272
+ */
273
+ this.channels = new WorkerChannelManager(this.serviceName, this.workerId);
274
+ this.channels.init(this._sendIPC);
275
+
276
+ /** @private */
277
+ this._server = null;
278
+
279
+ // Compute once whether this service needs an HTTP server
280
+ const isEdge = this.serviceType === "edge" && this.port > 0;
281
+ const isMultiMachine = process.env.FORGE_REGISTRY_MODE && process.env.FORGE_REGISTRY_MODE !== "embedded";
282
+ let hasRemoteEndpoints = false;
283
+ try {
284
+ const eps = JSON.parse(process.env.FORGE_SERVICE_ENDPOINTS || "{}");
285
+ hasRemoteEndpoints = Object.values(eps).some((ep) => (Array.isArray(ep) ? ep.some((e) => e.remote) : ep?.remote));
286
+ } catch {}
287
+ this._needsHttpServer = isEdge || ((isMultiMachine || hasRemoteEndpoints) && this.port > 0);
288
+
289
+ /** @private - active request counter for graceful shutdown */
290
+ this._activeRequests = 0;
291
+
292
+ /** @private - message handlers for supervisor IPC request/response (legacy) */
293
+ this._messageHandlers = new Map();
294
+ }
295
+
296
+ setStaticMounts(mounts = []) {
297
+ this._staticMounts = mounts;
298
+ this._staticRegistry.setMounts(mounts);
299
+ }
300
+
301
+ /**
302
+ * Send a message to another service.
303
+ *
304
+ * Resolution order:
305
+ * 1. Local dispatch (colocated service in same process) — zero overhead
306
+ * 2. Direct UDS connection — bypasses supervisor
307
+ * 3. Supervisor IPC fallback — only during startup
308
+ */
309
+ async send(target, payload) {
310
+ // Try colocated first (direct function call, no serialization)
311
+ if (this._localSend?.(target, payload)) {
312
+ return;
313
+ }
314
+ // Fall through to UDS / supervisor IPC
315
+ this.channels.send(target, payload);
316
+ }
317
+
318
+ /**
319
+ * Broadcast to all workers of a target service.
320
+ * Note: channels.broadcast delivers to all workers including local via UDS.
321
+ * We only use _localSend for colocated services that share this process
322
+ * (no UDS needed), then broadcast to remote workers via channels.
323
+ */
324
+ async broadcast(target, payload) {
325
+ // Local colocated dispatch (same process, direct call)
326
+ const sentLocally = this._localSend?.(target, payload);
327
+ // UDS broadcast to all OTHER workers (channels excludes self)
328
+ this.channels.broadcast(target, payload);
329
+ }
330
+
331
+ /**
332
+ * Send a request to another service and await a response.
333
+ *
334
+ * If the target is colocated, this is a direct async function call
335
+ * with zero serialization overhead.
336
+ */
337
+ async request(target, payload, timeoutMs = 5000) {
338
+ // Try colocated first
339
+ if (this._localRequest) {
340
+ const result = await this._localRequest(target, payload);
341
+ if (result !== NOT_HANDLED) return result;
342
+ }
343
+ // Fall through to UDS / supervisor IPC
344
+ return this.channels.request(target, payload, timeoutMs);
345
+ }
346
+
347
+ /**
348
+ * Start the HTTP server for this service.
349
+ */
350
+ async startServer() {
351
+ // Auto-apply ingress protection for edge services (when no ForgeProxy)
352
+ if (this.serviceType === "edge" && !this._ingressApplied && !this._forgeProxy) {
353
+ this.ingress = new IngressProtection(this._ingressConfig ?? {});
354
+ this.router.use(this.ingress.middleware());
355
+ this._ingressApplied = true;
356
+ }
357
+
358
+ // ── S7: Prometheus metrics endpoint with optional auth ──
359
+ this.router.get("/metrics", (_req, res) => {
360
+ const metricsToken = process.env.FORGE_METRICS_TOKEN;
361
+ if (metricsToken) {
362
+ const auth = _req.headers['authorization'];
363
+ const expected = `Bearer ${metricsToken}`;
364
+ if (!auth || auth.length !== expected.length ||
365
+ !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
366
+ res.json({ error: "Unauthorized" }, 401);
367
+ return;
368
+ }
369
+ }
370
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
371
+ res.end(this.metrics.expose());
372
+ });
373
+
374
+ // ── ForgeProxy invoke endpoint ──
375
+ this.router.post("/__forge/invoke", async (req, res) => {
376
+ const remote = req.socket?.remoteAddress ?? "";
377
+ if (!isPrivateNetwork(remote)) {
378
+ res.json({ error: "Invoke endpoint not externally accessible" }, 403);
379
+ return;
380
+ }
381
+
382
+ // S1: Validate internal HMAC signature (replaces FORGE_INTERNAL_AUTH_BYPASS)
383
+ if (!validateInternalSignature(req, req.method, "/__forge/invoke")) {
384
+ res.json({ error: "Invalid internal signature" }, 403);
385
+ return;
386
+ }
387
+
388
+ const ct = (req.headers["content-type"] ?? "").toLowerCase();
389
+ if (!ct.startsWith("application/json")) {
390
+ res.json({ error: "Content-Type must be application/json" }, 415);
391
+ return;
392
+ }
393
+
394
+ const method = req.body?.method ?? req.headers["x-forge-method"];
395
+ const args = req.body?.args ?? [];
396
+ const params = req.body?.params ?? {};
397
+
398
+ // Strip x-forge-* headers — invoke endpoint should not trust propagation
399
+ // headers from the network; use explicit body fields instead
400
+ const safeHeaders = { ...req.headers };
401
+ delete safeHeaders["x-forge-auth"];
402
+ delete safeHeaders["x-forge-tenant"];
403
+ delete safeHeaders["x-forge-user"];
404
+ delete safeHeaders["x-forge-deadline"];
405
+
406
+ const rctx = req.ctx ?? RequestContext.fromPropagation(safeHeaders);
407
+ rctx.service = this.serviceName;
408
+ rctx.method = method;
409
+
410
+ if (rctx.deadline && Date.now() >= rctx.deadline) {
411
+ res.writeHead(504, { "content-type": "application/json" });
412
+ res.end(JSON.stringify({ error: "Request deadline exceeded" }));
413
+ return;
414
+ }
415
+
416
+ if (!method) {
417
+ res.json({ error: "Missing method" }, 400);
418
+ return;
419
+ }
420
+
421
+ if (!this._isMethodAllowed(method)) {
422
+ res.json({ error: `Method "${method}" is not exposed` }, 403);
423
+ return;
424
+ }
425
+
426
+ // A9: Deduplicated auth check
427
+ const authResult = checkForgeEndpointAuth(this._serviceInstance, method, rctx);
428
+ if (!authResult.allowed) {
429
+ res.json({ error: authResult.error }, authResult.code);
430
+ return;
431
+ }
432
+
433
+ const handler = this._serviceInstance?.[method];
434
+ if (typeof handler !== "function") {
435
+ res.json({ error: `No method "${method}"` }, 404);
436
+ return;
437
+ }
438
+
439
+ // Execute within RequestContext so all downstream calls inherit it
440
+ const result = await RequestContext.run(rctx, async () => {
441
+ try {
442
+ const data = args.length > 0 ? args : [params];
443
+ const result = await handler.call(this._serviceInstance, ...data);
444
+ return { result };
445
+ } catch (err) {
446
+ const code = err.statusCode ?? 500;
447
+ if (code >= 500) {
448
+ this.logger.error("Invoke handler error", { method, error: err.message });
449
+ }
450
+ return { error: code >= 500 ? "Internal server error" : err.message, _code: code };
451
+ }
452
+ });
453
+
454
+ if (result.error) {
455
+ res.json({ error: result.error }, result._code);
456
+ } else {
457
+ res.json(result);
458
+ }
459
+ });
460
+
461
+ // ── Internal-only invoke (same protocol, for service-to-service HTTP) ──
462
+ this.router.post("/__forge/internal", async (req, res) => {
463
+ const remote = req.socket?.remoteAddress ?? "";
464
+ if (!isPrivateNetwork(remote)) {
465
+ res.json({ error: "Internal endpoint — not externally accessible" }, 403);
466
+ return;
467
+ }
468
+
469
+ // S1: Validate internal HMAC signature
470
+ if (!validateInternalSignature(req, req.method, "/__forge/internal")) {
471
+ res.json({ error: "Invalid internal signature" }, 403);
472
+ return;
473
+ }
474
+
475
+ const method = req.body?.method;
476
+ const args = req.body?.args ?? [];
477
+
478
+ // Extract propagation data BEFORE stripping __forge_* fields
479
+ const propagation = {};
480
+ if (req.body && typeof req.body === "object") {
481
+ for (const key of Object.keys(req.body)) {
482
+ if (key.startsWith("__forge_")) {
483
+ propagation[key] = req.body[key];
484
+ delete req.body[key];
485
+ }
486
+ }
487
+ }
488
+
489
+ // Reconstruct RequestContext from extracted propagation data
490
+ const rctx = RequestContext.fromPropagation(propagation);
491
+ rctx.service = this.serviceName;
492
+ rctx.method = method;
493
+
494
+ if (rctx.deadline && Date.now() >= rctx.deadline) {
495
+ res.writeHead(504, { "content-type": "application/json" });
496
+ res.end(JSON.stringify({ error: "Request deadline exceeded" }));
497
+ return;
498
+ }
499
+
500
+ if (!this._isMethodAllowed(method)) {
501
+ res.json({ error: `Method "${method}" is not exposed` }, 403);
502
+ return;
503
+ }
504
+
505
+ // A9: Deduplicated auth check
506
+ const authResult = checkForgeEndpointAuth(this._serviceInstance, method, rctx);
507
+ if (!authResult.allowed) {
508
+ res.json({ error: authResult.error }, authResult.code);
509
+ return;
510
+ }
511
+
512
+ const handler = this._serviceInstance?.[method];
513
+ if (typeof handler !== "function") {
514
+ res.json({ error: `No internal method "${method}"` }, 404);
515
+ return;
516
+ }
517
+
518
+ const result = await RequestContext.run(rctx, async () => {
519
+ try {
520
+ return { result: await handler.call(this._serviceInstance, ...args) };
521
+ } catch (err) {
522
+ const code = err.statusCode ?? 500;
523
+ if (code >= 500) {
524
+ this.logger.error("Internal invoke handler error", { method, error: err.message });
525
+ }
526
+ return { error: code >= 500 ? "Internal server error" : err.message, _code: code };
527
+ }
528
+ });
529
+
530
+ if (result.error) {
531
+ res.json({ error: result.error }, result._code);
532
+ } else {
533
+ res.json(result);
534
+ }
535
+ });
536
+
537
+ // ── Cross-machine event delivery endpoint ──
538
+ this.router.post("/__forge/event", async (req, res) => {
539
+ const remote = req.socket?.remoteAddress ?? "";
540
+ if (!isPrivateNetwork(remote)) {
541
+ res.json({ error: "Event endpoint — not externally accessible" }, 403);
542
+ return;
543
+ }
544
+
545
+ // S1: Validate internal HMAC signature
546
+ if (!validateInternalSignature(req, req.method, "/__forge/event")) {
547
+ res.json({ error: "Invalid internal signature" }, 403);
548
+ return;
549
+ }
550
+
551
+ const { from, event, data } = req.body ?? {};
552
+ if (!from || !event) {
553
+ res.json({ error: "Missing from or event" }, 400);
554
+ return;
555
+ }
556
+
557
+ // Check auth from propagation context (backward compat with FORGE_INTERNAL_AUTH_BYPASS)
558
+ const internalAuthBypass = (process.env.FORGE_INTERNAL_AUTH_BYPASS ?? 'false') === 'true';
559
+ if (internalAuthBypass && process.env.NODE_ENV === 'production') {
560
+ // M-SEC-4: Warn when auth bypass is used in production
561
+ this.logger.warn('FORGE_INTERNAL_AUTH_BYPASS is enabled in production — event auth is disabled');
562
+ }
563
+ if (!internalAuthBypass) {
564
+ const propagation = {};
565
+ if (req.body && typeof req.body === "object") {
566
+ for (const key of Object.keys(req.body)) {
567
+ if (key.startsWith("__forge_")) {
568
+ propagation[key] = req.body[key];
569
+ }
570
+ }
571
+ }
572
+ const rctx = RequestContext.fromPropagation(propagation);
573
+ if (!rctx.auth) {
574
+ res.json({ error: "Authentication required" }, 401);
575
+ return;
576
+ }
577
+ // SEC-C3: When JWT_SECRET is set, verify signature — not just auth presence
578
+ if (process.env.JWT_SECRET) {
579
+ const rawToken = propagation.__forge_auth_token ?? req.headers['authorization'];
580
+ const verified = rawToken ? verifyJwt(rawToken) : null;
581
+ if (!verified) {
582
+ res.json({ error: "Invalid or expired token" }, 401);
583
+ return;
584
+ }
585
+ }
586
+ }
587
+
588
+ // Dispatch as a standard forge event — autoWireSubscriptions
589
+ // already patches onMessage to route __forge_event payloads
590
+ const payload = { __forge_event: event, __forge_data: data };
591
+
592
+ try {
593
+ if (this._serviceInstance?.onMessage) {
594
+ await this._serviceInstance.onMessage(from, payload);
595
+ }
596
+ res.json({ ok: true });
597
+ } catch (err) {
598
+ this.logger.error("Event handler error", { from, event, error: err.message });
599
+ res.json({ error: "Internal server error" }, 500);
600
+ }
601
+ });
602
+
603
+ return new Promise((resolve, reject) => {
604
+ this._server = createServer((req, res) => {
605
+ const start = performance.now();
606
+
607
+ // H8: Validate HTTP method early
608
+ if (!VALID_METHODS.has(req.method)) {
609
+ res.writeHead(405, { "Content-Type": "application/json", "Allow": "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" });
610
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
611
+ return;
612
+ }
613
+
614
+ // H11: Strip internal forge headers from external requests BEFORE body parsing
615
+ // L-SEC-1: Also strip internal signature/timestamp headers for defense-in-depth
616
+ const remoteAddrEarly = req.socket?.remoteAddress ?? "";
617
+ if (!isPrivateNetwork(remoteAddrEarly)) {
618
+ delete req.headers["x-forge-auth"];
619
+ delete req.headers["x-forge-tenant"];
620
+ delete req.headers["x-forge-user"];
621
+ delete req.headers["x-forge-deadline"];
622
+ delete req.headers["x-forge-internal-sig"];
623
+ delete req.headers["x-forge-internal-ts"];
624
+ }
625
+
626
+ if (["POST", "PUT", "PATCH"].includes(req.method)) {
627
+ const ct = (req.headers["content-type"] ?? "").toLowerCase();
628
+ if (
629
+ ct &&
630
+ !ct.includes("application/json") &&
631
+ !ct.includes("text/") &&
632
+ !ct.includes("multipart/form-data") &&
633
+ !ct.includes("application/x-www-form-urlencoded")
634
+ ) {
635
+ res.writeHead(415, { "Content-Type": "application/json" });
636
+ res.end(JSON.stringify({ error: "Unsupported media type" }));
637
+ return;
638
+ }
639
+ // Early Content-Length check — reject oversized requests without reading body
640
+ const declaredLength = req.headers["content-length"];
641
+ if (declaredLength != null) {
642
+ const cl = parseInt(declaredLength, 10);
643
+ if (!Number.isNaN(cl) && cl > MAX_BODY_SIZE) {
644
+ res.writeHead(413, { "Content-Type": "application/json" });
645
+ res.end(JSON.stringify({ error: "Request body too large" }));
646
+ req.destroy();
647
+ return;
648
+ }
649
+ }
650
+ // P7: Collect chunks in array, single Buffer.concat at end
651
+ const chunks = [];
652
+ let bodySize = 0;
653
+ let rejected = false;
654
+ // S11: Reduced body timeout from 30s to 10s
655
+ const bodyTimeout = setTimeout(() => {
656
+ if (rejected) return;
657
+ rejected = true;
658
+ if (!res.headersSent) {
659
+ res.writeHead(408, { "Content-Type": "application/json" });
660
+ res.end(JSON.stringify({ error: "Request Timeout" }));
661
+ }
662
+ req.destroy();
663
+ }, BODY_TIMEOUT_MS);
664
+ req.on("data", (chunk) => {
665
+ if (rejected) return;
666
+ bodySize += chunk.length;
667
+ if (bodySize > MAX_BODY_SIZE) {
668
+ rejected = true;
669
+ clearTimeout(bodyTimeout);
670
+ if (!res.headersSent) {
671
+ res.writeHead(413, { "Content-Type": "application/json" });
672
+ res.end(JSON.stringify({ error: "Request body too large" }));
673
+ }
674
+ req.destroy();
675
+ return;
676
+ }
677
+ chunks.push(chunk);
678
+ });
679
+ req.on("end", () => {
680
+ clearTimeout(bodyTimeout);
681
+ if (rejected) return;
682
+ const body = Buffer.concat(chunks).toString("utf-8");
683
+ // Only JSON.parse when content-type is application/json or absent (backward compat)
684
+ if (!ct || ct.includes("application/json")) {
685
+ try {
686
+ req.body = body ? JSON.parse(body, (key, value) => {
687
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
688
+ return value;
689
+ }) : {};
690
+ } catch {
691
+ // For internal forge endpoints, return 400 on malformed JSON
692
+ const urlPath = new URL(req.url, "http://localhost").pathname;
693
+ if (urlPath.startsWith("/__forge/")) {
694
+ if (!res.headersSent) {
695
+ res.writeHead(400, { "Content-Type": "application/json" });
696
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
697
+ }
698
+ return;
699
+ }
700
+ // Fall back to raw string for user routes
701
+ req.body = body;
702
+ }
703
+ } else {
704
+ req.body = body;
705
+ }
706
+ this._handleRequest(req, res, start);
707
+ });
708
+ } else {
709
+ req.body = {};
710
+ this._handleRequest(req, res, start);
711
+ }
712
+ });
713
+
714
+ // ── Client error handling ──
715
+ this._server.on("clientError", (err, socket) => {
716
+ this.logger.error("Client error", { error: err.message, code: err.code });
717
+ if (socket && !socket.destroyed) {
718
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
719
+ socket.destroy();
720
+ }
721
+ });
722
+
723
+ // ── WebSocket upgrade handling ──
724
+ this._server.on("upgrade", (req, socket, head) => {
725
+ this._handleWsUpgrade(req, socket, head).catch((err) => {
726
+ this.logger.error("WebSocket upgrade failed", { error: err.message, path: req.url });
727
+ if (!socket.destroyed) {
728
+ try {
729
+ socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
730
+ } catch {}
731
+ socket.destroy();
732
+ }
733
+ });
734
+ });
735
+
736
+ // Set request timeouts to prevent slowloris attacks
737
+ this._server.timeout = 30000;
738
+ this._server.requestTimeout = 30000;
739
+ this._server.headersTimeout = 10000;
740
+
741
+ this._server.listen(this.port, () => {
742
+ // Reduce startup noise in multi-worker mode.
743
+ if (this.workerId === 0) {
744
+ this.logger.info(`Listening on port ${this.port}`);
745
+ }
746
+
747
+ // Auto-register with ForgeProxy if configured
748
+ if (this._forgeProxy) {
749
+ this._registerWithForgeProxy().catch((err) => {
750
+ this.logger.warn(`ForgeProxy registration failed: ${err.message}`);
751
+ });
752
+ }
753
+
754
+ resolve();
755
+ });
756
+
757
+ this._server.on("error", (err) => {
758
+ // Detect fatal bind errors that should not trigger restart loops
759
+ const isFatalBindError =
760
+ err.code === "EPERM" ||
761
+ err.code === "EACCES" ||
762
+ err.code === "EADDRNOTAVAIL" ||
763
+ err.code === "EADDRINUSE";
764
+
765
+ if (isFatalBindError) {
766
+ // Notify supervisor this is a fatal error — don't restart
767
+ if (this._sendIPC) {
768
+ this._sendIPC({
769
+ type: "forge:fatal-error",
770
+ error: err.code,
771
+ message: err.message,
772
+ port: this.port,
773
+ });
774
+ }
775
+
776
+ // Augment error with clear message using constant map
777
+ const messageGenerator = BIND_ERROR_MESSAGES[err.code];
778
+ err.fatalBindError = true;
779
+ err.userMessage = messageGenerator
780
+ ? messageGenerator(this.port)
781
+ : `Failed to bind to port ${this.port}: ${err.message}`;
782
+ }
783
+
784
+ reject(err);
785
+ });
786
+ });
787
+ }
788
+
789
+ /**
790
+ * Route an incoming HTTP request.
791
+ * Creates a RequestContext that flows through the entire call chain.
792
+ */
793
+ _handleRequest(req, res, start) {
794
+ this._activeRequests++;
795
+ res.on("close", () => this._activeRequests--);
796
+
797
+ // Convenience methods on response
798
+ res.json = (data, statusCode = 200) => {
799
+ if (res.headersSent) return;
800
+ let body;
801
+ try {
802
+ body = JSON.stringify(data);
803
+ } catch {
804
+ body = JSON.stringify({ error: "Response serialization failed" });
805
+ statusCode = 500;
806
+ }
807
+ const len = Buffer.byteLength(body);
808
+ res.writeHead(statusCode, { "Content-Type": "application/json", "Content-Length": len });
809
+ res.end(body);
810
+ };
811
+
812
+ res.status = (code) => {
813
+ res.statusCode = code;
814
+ return res;
815
+ };
816
+
817
+ // ── Build RequestContext from incoming headers ──
818
+ // Note: internal forge headers already stripped in createServer callback (H11)
819
+ const remoteAddr = req.socket?.remoteAddress ?? "";
820
+
821
+ const rctx = RequestContext.fromPropagation(req.headers);
822
+ rctx.service = this.serviceName;
823
+
824
+ // Security headers
825
+ res.setHeader("X-Frame-Options", "DENY");
826
+ res.setHeader("X-Content-Type-Options", "nosniff");
827
+ res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
828
+ // HSTS should only be sent over HTTPS (RFC 6797 § 7.2)
829
+ // Only trust x-forwarded-proto from trusted proxies (FORGE_TRUSTED_PROXIES), not just any private network
830
+ if (req.socket?.encrypted || (isTrustedProxy(remoteAddr) && req.headers["x-forwarded-proto"] === "https")) {
831
+ res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
832
+ }
833
+
834
+ // Always set the correlation ID response header
835
+ res.setHeader("x-correlation-id", rctx.correlationId);
836
+ res.setHeader("x-trace-id", rctx.traceId);
837
+
838
+ // In host mode, propagate project ID to the request context
839
+ rctx._projectId ??= this._projectId;
840
+
841
+ // Attach context to request for handlers to use
842
+ req.ctx = rctx;
843
+ req.auth = rctx.auth;
844
+ req.tenantId = rctx.tenantId;
845
+ req.projectId = rctx.projectId;
846
+ req.userId = rctx.userId;
847
+ req.correlationId = rctx.correlationId;
848
+
849
+ this.metrics.httpRequestStart();
850
+
851
+ const matched = this.router.match(req.method, req.url);
852
+
853
+ if (!matched) {
854
+ this._tryServeStatic(req, res, start)
855
+ .then((served) => {
856
+ if (served) return;
857
+ this.metrics.httpRequestEnd((performance.now() - start) / 1000, {
858
+ method: req.method,
859
+ route: "unmatched",
860
+ status: 404,
861
+ });
862
+ res.json({ error: "Not Found" }, 404);
863
+ })
864
+ .catch((err) => {
865
+ this.logger.error("Static serve failure", {
866
+ error: err.message,
867
+ url: req.url,
868
+ });
869
+ if (!res.headersSent) {
870
+ res.json({ error: "Internal server error" }, 500);
871
+ }
872
+ });
873
+ return;
874
+ }
875
+
876
+ req.params = matched.params;
877
+ req.query = matched.query;
878
+
879
+ // HEAD responses MUST NOT include a message body (RFC 7231 § 4.3.2)
880
+ if (req.method === "HEAD") {
881
+ const origEnd = res.end.bind(res);
882
+ res.end = (chunk, encoding, callback) => origEnd(null, encoding, callback);
883
+ res.write = () => true;
884
+ }
885
+
886
+ // Execute middleware chain + handler INSIDE RequestContext
887
+ const handlers = [...this.router.middleware, matched.handler];
888
+ let i = 0;
889
+
890
+ const next = (err) => {
891
+ if (err) {
892
+ this.logger.error("Request error", {
893
+ error: err.message,
894
+ url: req.url,
895
+ ...rctx.toLogFields(),
896
+ });
897
+ if (!res.headersSent) {
898
+ const status = err.statusCode || 500;
899
+ const message = status >= 500 ? "Internal server error" : (err.message || "Internal error");
900
+ res.json({ error: message }, status);
901
+ }
902
+ return;
903
+ }
904
+
905
+ const handler = handlers[i++];
906
+ if (!handler) return;
907
+
908
+ let stepCalled = false;
909
+ const stepNext = (stepErr) => {
910
+ if (stepCalled) return;
911
+ stepCalled = true;
912
+ next(stepErr);
913
+ };
914
+
915
+ try {
916
+ const result = handler(req, res, stepNext);
917
+ if (result && typeof result.catch === "function") {
918
+ result.catch(stepNext);
919
+ }
920
+ } catch (e) {
921
+ stepNext(e);
922
+ }
923
+ };
924
+
925
+ // Run entire handler chain within the RequestContext.
926
+ // AsyncLocalStorage.run() properly propagates through the entire async chain,
927
+ // including middleware that does `await next()` followed by post-next work.
928
+ RequestContext.run(rctx, () => next());
929
+
930
+ // Track metrics on response finish
931
+ const onFinish = () => {
932
+ const duration = (performance.now() - start) / 1000;
933
+ const labels = {
934
+ method: req.method,
935
+ route: matched.pattern,
936
+ status: res.statusCode,
937
+ };
938
+ if (rctx.tenantId) labels.tenant_id = rctx.tenantId;
939
+ if (rctx.projectId) labels.project_id = rctx.projectId;
940
+
941
+ this.metrics.httpRequestEnd(duration, labels);
942
+ res.removeListener("finish", onFinish);
943
+ };
944
+ res.on("finish", onFinish);
945
+ }
946
+
947
+ async _tryServeStatic(req, res, start) {
948
+ if (!this._staticMounts || this._staticMounts.length === 0) return false;
949
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
950
+
951
+ const hostHeader = (req.headers.host ?? "").toLowerCase();
952
+ const host = hostHeader.includes(":") ? hostHeader.split(":")[0] : hostHeader;
953
+ const pathname = new URL(req.url, "http://localhost").pathname;
954
+ const mount = this._staticRegistry.resolve(host, pathname);
955
+ if (!mount) return false;
956
+
957
+ const resolved = this._resolveStaticFile(mount.dir, mount.relativePath);
958
+ if (!resolved.ok) {
959
+ this.metrics.httpRequestEnd((performance.now() - start) / 1000, {
960
+ method: req.method,
961
+ route: `static:${mount.siteId ?? "unknown"}`,
962
+ status: 403,
963
+ });
964
+ res.json({ error: "Forbidden" }, 403);
965
+ return true;
966
+ }
967
+
968
+ let filePath = await this._resolveExistingStaticFile(resolved.filePath);
969
+ if (!filePath && mount.spaFallback && !pathname.startsWith("/api/")) {
970
+ filePath = await this._resolveExistingStaticFile(path.resolve(mount.dir, "index.html"));
971
+ }
972
+ if (!filePath) return false;
973
+
974
+ const stat = await fs.promises.stat(filePath);
975
+ if (!stat.isFile()) return false;
976
+
977
+ const extension = path.extname(filePath).toLowerCase();
978
+ const contentType = EXTENSION_TO_MIME[extension] ?? "application/octet-stream";
979
+ const cacheControl = this._staticCacheControl(mount, filePath);
980
+ const routeLabel = `static:${mount.siteId ?? "unknown"}`;
981
+
982
+ res.setHeader("Content-Type", contentType);
983
+ res.setHeader("Content-Length", stat.size);
984
+ res.setHeader("Cache-Control", cacheControl);
985
+
986
+ const finishMetrics = () => {
987
+ this.metrics.httpRequestEnd((performance.now() - start) / 1000, {
988
+ method: req.method,
989
+ route: routeLabel,
990
+ status: res.statusCode || 200,
991
+ });
992
+ res.removeListener("finish", finishMetrics);
993
+ };
994
+ res.on("finish", finishMetrics);
995
+
996
+ if (req.method === "HEAD") {
997
+ res.writeHead(200);
998
+ res.end();
999
+ return true;
1000
+ }
1001
+
1002
+ await new Promise((resolve, reject) => {
1003
+ const stream = fs.createReadStream(filePath);
1004
+ stream.on("error", reject);
1005
+ stream.on("open", () => {
1006
+ if (!res.headersSent) res.writeHead(200);
1007
+ });
1008
+ stream.on("end", resolve);
1009
+ stream.pipe(res);
1010
+ });
1011
+
1012
+ return true;
1013
+ }
1014
+
1015
+ _resolveStaticFile(rootDir, requestPath) {
1016
+ const normalizedRoot = path.resolve(rootDir);
1017
+ let decodedPath;
1018
+ try {
1019
+ decodedPath = decodeURIComponent(requestPath || "/");
1020
+ } catch {
1021
+ return { ok: false, filePath: null };
1022
+ }
1023
+
1024
+ const normalized = path.posix.normalize(decodedPath.startsWith("/") ? decodedPath : `/${decodedPath}`);
1025
+ const absolute = path.resolve(normalizedRoot, `.${normalized}`);
1026
+
1027
+ if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) {
1028
+ return { ok: false, filePath: null };
1029
+ }
1030
+
1031
+ return { ok: true, filePath: absolute };
1032
+ }
1033
+
1034
+ async _resolveExistingStaticFile(filePath) {
1035
+ try {
1036
+ const stat = await fs.promises.stat(filePath);
1037
+ if (stat.isFile()) return filePath;
1038
+ if (stat.isDirectory()) {
1039
+ const indexPath = path.join(filePath, "index.html");
1040
+ const indexStat = await fs.promises.stat(indexPath);
1041
+ if (indexStat.isFile()) return indexPath;
1042
+ }
1043
+ } catch {}
1044
+ return null;
1045
+ }
1046
+
1047
+ _staticCacheControl(mount, filePath) {
1048
+ if (mount.cachePolicy === "none") return "no-store";
1049
+ if (mount.cachePolicy === "immutable") return "public, max-age=31536000, immutable";
1050
+
1051
+ const base = path.basename(filePath);
1052
+ if (/[.-][a-f0-9]{8,}\./i.test(base)) {
1053
+ return "public, max-age=31536000, immutable";
1054
+ }
1055
+ return "public, max-age=300";
1056
+ }
1057
+
1058
+ /**
1059
+ * Wire message/request handlers to both the direct channel manager
1060
+ * AND the supervisor fallback IPC path.
1061
+ */
1062
+ _wireMessageHandlers() {
1063
+ // Direct channel path (primary — used once ports are established)
1064
+ this.channels.onMessage = (from, payload) => {
1065
+ if (this._onMessage) this._onMessage(from, payload);
1066
+ };
1067
+ this.channels.onRequest = (from, payload) => {
1068
+ if (this._onRequest) return this._onRequest(from, payload);
1069
+ return null;
1070
+ };
1071
+ }
1072
+
1073
+ /**
1074
+ * Handle an incoming IPC message from the supervisor.
1075
+ * This is the FALLBACK path — only used during startup before
1076
+ * direct MessagePorts are established, and for supervisor-level
1077
+ * commands (health checks, shutdown, etc.)
1078
+ */
1079
+ _handleIPCMessage(msg) {
1080
+ if (!msg || !msg.type) return;
1081
+
1082
+ // Supervisor-level messages
1083
+ if (msg.type === "forge:health-check") {
1084
+ this._sendIPC({
1085
+ type: "forge:health-response",
1086
+ timestamp: msg.timestamp,
1087
+ uptime: process.uptime(),
1088
+ memory: process.memoryUsage(),
1089
+ pid: process.pid,
1090
+ });
1091
+ return;
1092
+ }
1093
+
1094
+ // Fallback message routing (before direct ports are ready)
1095
+ if (msg.type === "forge:message" && this._onMessage) {
1096
+ this._onMessage(msg.from, msg.payload);
1097
+ }
1098
+
1099
+ if (msg.type === "forge:request" && this._onRequest) {
1100
+ Promise.resolve(this._onRequest(msg.from, msg.payload))
1101
+ .then((result) => {
1102
+ this._sendIPC({
1103
+ type: "forge:response",
1104
+ requestId: msg.requestId,
1105
+ payload: result,
1106
+ error: null,
1107
+ });
1108
+ })
1109
+ .catch((err) => {
1110
+ this._sendIPC({
1111
+ type: "forge:response",
1112
+ requestId: msg.requestId,
1113
+ payload: null,
1114
+ error: err.message,
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ if (msg.type === "forge:response") {
1120
+ const handler = this._messageHandlers.get(msg.requestId);
1121
+ if (handler) handler(msg);
1122
+ }
1123
+ }
1124
+
1125
+ _isMethodAllowed(method) {
1126
+ if (!method || typeof method !== "string") return false;
1127
+ if (method.startsWith("_")) return false;
1128
+ if (BLOCKED_LIFECYCLE_METHODS.has(method)) return false;
1129
+
1130
+ const ServiceClass = this._serviceInstance?.constructor;
1131
+ if (!ServiceClass) return false;
1132
+
1133
+ const contract = getContract(ServiceClass);
1134
+ if (!contract) return false;
1135
+
1136
+ return contract.methods.has(method);
1137
+ }
1138
+
1139
+ /**
1140
+ * Gracefully shut down.
1141
+ */
1142
+ async stop() {
1143
+ // Close direct channels
1144
+ this.channels.destroy();
1145
+
1146
+ // Close all active WebSocket connections and clean up timers
1147
+ for (const ws of this._wsConnections) {
1148
+ if (ws._closeTimer) { clearTimeout(ws._closeTimer); ws._closeTimer = null; }
1149
+ if (ws._pingTimer) { clearInterval(ws._pingTimer); ws._pingTimer = null; }
1150
+ if (!ws._closed) {
1151
+ ws._closed = true;
1152
+ }
1153
+ if (!ws.socket.destroyed) {
1154
+ ws.socket.destroy();
1155
+ }
1156
+ }
1157
+ this._wsConnections.clear();
1158
+
1159
+ if (this._server) {
1160
+ // Deregister from ForgeProxy on shutdown
1161
+ if (this._forgeProxy) {
1162
+ try {
1163
+ const data = JSON.stringify({
1164
+ service: this.serviceName,
1165
+ host: this._getHost(),
1166
+ port: this.port,
1167
+ });
1168
+ const url = new URL(`${this._forgeProxy}/deregister`);
1169
+ await fetch(url, { method: "POST", body: data, headers: { "Content-Type": "application/json" } }).catch(
1170
+ () => {},
1171
+ );
1172
+ } catch {}
1173
+ }
1174
+
1175
+ return new Promise((resolve) => {
1176
+ let drainInterval = null;
1177
+
1178
+ // Stop accepting new connections and terminate idle keep-alive sockets
1179
+ this._server.close(() => {
1180
+ if (drainInterval) clearInterval(drainInterval);
1181
+ resolve();
1182
+ });
1183
+ // Force-close idle keep-alive connections (Node 18.2+)
1184
+ if (typeof this._server.closeAllConnections === 'function') {
1185
+ // Wait briefly for in-flight requests, then force-close
1186
+ const drainStart = Date.now();
1187
+ drainInterval = setInterval(() => {
1188
+ if (this._activeRequests <= 0 || Date.now() - drainStart >= 5000) {
1189
+ clearInterval(drainInterval);
1190
+ drainInterval = null;
1191
+ this._server.closeAllConnections();
1192
+ }
1193
+ }, 50);
1194
+ drainInterval.unref();
1195
+ }
1196
+ });
1197
+ }
1198
+ }
1199
+
1200
+ // ── WebSocket Support ──
1201
+
1202
+ /**
1203
+ * Register a WebSocket handler for a path.
1204
+ *
1205
+ * ctx.ws('/ws', (socket, req) => {
1206
+ * socket.on('message', (data) => {
1207
+ * const msg = JSON.parse(data);
1208
+ * // req.ctx has the RequestContext with auth, correlationId
1209
+ * socket.send(JSON.stringify({ echo: msg }));
1210
+ * });
1211
+ * });
1212
+ */
1213
+ ws(path, handlerOrOptions, maybeHandler) {
1214
+ let handler, options;
1215
+ if (typeof handlerOrOptions === 'function') {
1216
+ handler = handlerOrOptions;
1217
+ options = {};
1218
+ } else {
1219
+ options = handlerOrOptions ?? {};
1220
+ handler = maybeHandler;
1221
+ }
1222
+ // S8: WebSocket endpoints require auth by default
1223
+ if (options.auth === undefined) {
1224
+ options.auth = 'required';
1225
+ }
1226
+ this._wsHandlers.set(path, { handler, options });
1227
+ }
1228
+
1229
+ /**
1230
+ * Write a short HTTP response over a raw upgrade socket and close it.
1231
+ *
1232
+ * @param {import("node:net").Socket} socket
1233
+ * @param {number} statusCode
1234
+ * @param {string} reason
1235
+ * @param {Record<string, string>} [headers]
1236
+ */
1237
+ _writeWsUpgradeError(socket, statusCode, reason, headers = {}) {
1238
+ const statusText = String(reason || "Upgrade Rejected").replace(/[\r\n]/g, " ");
1239
+ let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`;
1240
+ for (const [key, value] of Object.entries(headers)) {
1241
+ const safeKey = String(key).replace(/[\r\n:]/g, "");
1242
+ const safeVal = String(value).replace(/[\r\n]/g, " ");
1243
+ response += `${safeKey}: ${safeVal}\r\n`;
1244
+ }
1245
+ response += "\r\n";
1246
+ socket.write(response);
1247
+ socket.destroy();
1248
+ }
1249
+
1250
+ /**
1251
+ * Run websocket plugin hooks for a lifecycle stage.
1252
+ *
1253
+ * @param {"upgrade" | "connect" | "message" | "close"} stage
1254
+ * @param {object} payload
1255
+ */
1256
+ async _runWsPluginHooks(stage, payload) {
1257
+ const methodByStage = {
1258
+ upgrade: "onWsUpgrade",
1259
+ connect: "onWsConnect",
1260
+ message: "onWsMessage",
1261
+ close: "onWsClose",
1262
+ };
1263
+ const method = methodByStage[stage];
1264
+ if (!method) return [];
1265
+
1266
+ const results = [];
1267
+ for (const hook of this._wsPluginHooks) {
1268
+ const fn = hook?.[method];
1269
+ if (typeof fn !== "function") continue;
1270
+ try {
1271
+ const result = await fn(payload);
1272
+ results.push({ plugin: hook.name, ok: true, result });
1273
+ } catch (err) {
1274
+ this.logger.warn(`WebSocket plugin hook failed`, {
1275
+ plugin: hook.name,
1276
+ stage,
1277
+ error: err.message,
1278
+ });
1279
+ results.push({ plugin: hook.name, ok: false, error: err });
1280
+ }
1281
+ }
1282
+
1283
+ return results;
1284
+ }
1285
+
1286
+ /**
1287
+ * Handle HTTP→WebSocket upgrade.
1288
+ * Implements RFC 6455 handshake without external dependencies.
1289
+ */
1290
+ async _handleWsUpgrade(req, socket, head) {
1291
+ // H-SEC-2: Per-IP WebSocket connection limit to prevent file descriptor exhaustion
1292
+ const wsRemoteAddr = req.socket?.remoteAddress ?? "unknown";
1293
+ const currentCount = this._wsPerIpCounts.get(wsRemoteAddr) ?? 0;
1294
+ if (currentCount >= this._wsMaxPerIp) {
1295
+ socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
1296
+ socket.destroy();
1297
+ return;
1298
+ }
1299
+
1300
+ const url = new URL(req.url, `http://${req.headers.host}`);
1301
+ const entry = this._wsHandlers.get(url.pathname);
1302
+
1303
+ if (!entry) {
1304
+ socket.destroy();
1305
+ return;
1306
+ }
1307
+
1308
+ const handler = typeof entry === 'function' ? entry : entry.handler;
1309
+ const wsOptions = typeof entry === 'function' ? {} : (entry.options ?? {});
1310
+
1311
+ // S3: WebSocket Origin Validation — default deny
1312
+ const allowedOrigins = wsOptions.allowedOrigins;
1313
+ if (allowedOrigins && allowedOrigins.length > 0) {
1314
+ // Explicit allowlist: check if '*' is included (opt-out of security)
1315
+ if (!allowedOrigins.includes('*')) {
1316
+ const origin = req.headers["origin"];
1317
+ if (!origin || !allowedOrigins.includes(origin)) {
1318
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1319
+ socket.destroy();
1320
+ return;
1321
+ }
1322
+ }
1323
+ } else {
1324
+ // No allowedOrigins configured — default deny (reject cross-origin)
1325
+ const origin = req.headers["origin"];
1326
+ if (origin) {
1327
+ // Compare origin host to request host
1328
+ try {
1329
+ const originHost = new URL(origin).host;
1330
+ const reqHost = req.headers.host?.split(':')[0];
1331
+ if (originHost !== reqHost && originHost !== req.headers.host) {
1332
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1333
+ socket.destroy();
1334
+ return;
1335
+ }
1336
+ } catch {
1337
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1338
+ socket.destroy();
1339
+ return;
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ // Strip internal forge headers from external WebSocket upgrades
1345
+ let headers = req.headers;
1346
+ const remoteAddr = req.socket?.remoteAddress ?? "";
1347
+ if (!isPrivateNetwork(remoteAddr)) {
1348
+ headers = { ...req.headers };
1349
+ delete headers["x-forge-auth"];
1350
+ delete headers["x-forge-tenant"];
1351
+ delete headers["x-forge-user"];
1352
+ delete headers["x-forge-deadline"];
1353
+ }
1354
+
1355
+ const rctx = RequestContext.fromPropagation(headers);
1356
+ rctx.service = this.serviceName;
1357
+ rctx.method = `ws:${url.pathname}`;
1358
+ req.ctx = rctx;
1359
+ req.auth = rctx.auth;
1360
+ req.tenantId = rctx.tenantId;
1361
+
1362
+ // S8: Enforce auth by default (auth defaults to 'required' from ws() method)
1363
+ if (wsOptions.auth === 'required' && !rctx.auth) {
1364
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1365
+ socket.destroy();
1366
+ return;
1367
+ }
1368
+
1369
+ const wsMeta = {
1370
+ service: this.serviceName,
1371
+ workerId: this.workerId,
1372
+ path: url.pathname,
1373
+ options: wsOptions,
1374
+ requestContext: rctx,
1375
+ };
1376
+
1377
+ const upgradeResults = await this._runWsPluginHooks("upgrade", {
1378
+ req,
1379
+ socket,
1380
+ meta: wsMeta,
1381
+ });
1382
+ for (const res of upgradeResults) {
1383
+ if (!res.ok) {
1384
+ this._writeWsUpgradeError(socket, 503, "Service Unavailable");
1385
+ return;
1386
+ }
1387
+ const decision = res.result;
1388
+ if (decision === false || decision?.allow === false) {
1389
+ this._writeWsUpgradeError(
1390
+ socket,
1391
+ decision?.statusCode ?? 403,
1392
+ decision?.reason ?? "Forbidden",
1393
+ decision?.headers ?? {},
1394
+ );
1395
+ return;
1396
+ }
1397
+ }
1398
+
1399
+ // M6: Validate Sec-WebSocket-Version (must be 13 per RFC 6455)
1400
+ if (req.headers["sec-websocket-version"] !== "13") {
1401
+ socket.write("HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\n\r\n");
1402
+ socket.destroy();
1403
+ return;
1404
+ }
1405
+
1406
+ // RFC 6455 handshake
1407
+ const key = req.headers["sec-websocket-key"];
1408
+ if (!key) {
1409
+ socket.destroy();
1410
+ return;
1411
+ }
1412
+
1413
+ const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-5AB4085B9976`).digest("base64");
1414
+
1415
+ const safeCorrelationId = (rctx.correlationId || '').replace(/[\r\n]/g, '');
1416
+
1417
+ socket.write(
1418
+ "HTTP/1.1 101 Switching Protocols\r\n" +
1419
+ "Upgrade: websocket\r\n" +
1420
+ "Connection: Upgrade\r\n" +
1421
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
1422
+ `X-Correlation-ID: ${safeCorrelationId}\r\n` +
1423
+ "\r\n",
1424
+ );
1425
+
1426
+ // Create a minimal WebSocket wrapper
1427
+ const ws = new ForgeWebSocket(socket, rctx, wsOptions);
1428
+
1429
+ // H1: If _head buffer has data, prepend it so the first frame isn't lost
1430
+ if (head && head.length > 0) {
1431
+ ws._onData(head);
1432
+ }
1433
+ this._wsConnections.add(ws);
1434
+ this.metrics.wsConnectionOpen();
1435
+ // H-SEC-2: Track per-IP count
1436
+ this._wsPerIpCounts.set(wsRemoteAddr, (this._wsPerIpCounts.get(wsRemoteAddr) ?? 0) + 1);
1437
+
1438
+ ws.on("close", () => {
1439
+ this._wsConnections.delete(ws);
1440
+ this.metrics.wsConnectionClose();
1441
+ // H-SEC-2: Decrement per-IP count
1442
+ const count = (this._wsPerIpCounts.get(wsRemoteAddr) ?? 1) - 1;
1443
+ if (count <= 0) {
1444
+ this._wsPerIpCounts.delete(wsRemoteAddr);
1445
+ } else {
1446
+ this._wsPerIpCounts.set(wsRemoteAddr, count);
1447
+ }
1448
+ });
1449
+
1450
+ ws.on("message", () => {
1451
+ this.metrics.wsMessage("inbound");
1452
+ });
1453
+
1454
+ ws.on("message", (message) => {
1455
+ void this._runWsPluginHooks("message", {
1456
+ ws,
1457
+ req,
1458
+ data: message,
1459
+ meta: wsMeta,
1460
+ });
1461
+ });
1462
+
1463
+ ws.on("close", (code, reason) => {
1464
+ void this._runWsPluginHooks("close", {
1465
+ ws,
1466
+ req,
1467
+ code,
1468
+ reason,
1469
+ meta: wsMeta,
1470
+ });
1471
+ });
1472
+
1473
+ const connectResults = await this._runWsPluginHooks("connect", {
1474
+ ws,
1475
+ req,
1476
+ meta: wsMeta,
1477
+ });
1478
+ for (const res of connectResults) {
1479
+ if (!res.ok) {
1480
+ try {
1481
+ ws.close?.(1011, "Internal plugin error");
1482
+ } catch {}
1483
+ return;
1484
+ }
1485
+ const decision = res.result;
1486
+ if (decision === false || decision?.allow === false) {
1487
+ try {
1488
+ ws.close?.(decision?.closeCode ?? 1008, decision?.closeReason ?? "Policy violation");
1489
+ } catch {}
1490
+ return;
1491
+ }
1492
+ }
1493
+
1494
+ // Run handler within RequestContext
1495
+ RequestContext.run(rctx, () => handler(ws, req));
1496
+ }
1497
+
1498
+ /**
1499
+ * Register this service with ForgeProxy.
1500
+ * ForgeProxy then routes external traffic to us.
1501
+ */
1502
+ async _registerWithForgeProxy() {
1503
+ const contract = this._serviceInstance?.constructor?.contract;
1504
+ const methods = contract?.expose ?? [];
1505
+
1506
+ const registration = {
1507
+ service: this.serviceName,
1508
+ host: this._getHost(),
1509
+ port: this.port,
1510
+ workers: this.threadCount,
1511
+ methods,
1512
+ health_endpoint: "/health",
1513
+ };
1514
+
1515
+ const resp = await fetch(`${this._forgeProxy}/register`, {
1516
+ method: "POST",
1517
+ headers: { "Content-Type": "application/json" },
1518
+ body: JSON.stringify(registration),
1519
+ });
1520
+
1521
+ if (resp.ok) {
1522
+ this.logger.info(`Registered with ForgeProxy at ${this._forgeProxy}`, {
1523
+ methods: methods.length,
1524
+ });
1525
+ } else {
1526
+ throw new Error(`ForgeProxy registration failed: ${resp.status}`);
1527
+ }
1528
+ }
1529
+
1530
+ _getHost() {
1531
+ return process.env.FORGE_HOST ?? process.env.HOSTNAME ?? "127.0.0.1";
1532
+ }
1533
+ }
1534
+
1535
+ /**
1536
+ * Simple pattern-matching HTTP router.
1537
+ * P11: Includes a route matching cache.
1538
+ */
1539
+ class Router {
1540
+ constructor() {
1541
+ /** @type {Map<string, Array>} Routes keyed by HTTP method */
1542
+ this.routes = new Map();
1543
+ this.middleware = [];
1544
+ // P11: Route cache — URL→handler lookups
1545
+ this._cache = new Map();
1546
+ }
1547
+
1548
+ use(fn) {
1549
+ this.middleware.push(fn);
1550
+ }
1551
+
1552
+ get(pattern, handler) {
1553
+ this._add("GET", pattern, handler);
1554
+ }
1555
+ post(pattern, handler) {
1556
+ this._add("POST", pattern, handler);
1557
+ }
1558
+ put(pattern, handler) {
1559
+ this._add("PUT", pattern, handler);
1560
+ }
1561
+ patch(pattern, handler) {
1562
+ this._add("PATCH", pattern, handler);
1563
+ }
1564
+ delete(pattern, handler) {
1565
+ this._add("DELETE", pattern, handler);
1566
+ }
1567
+
1568
+ _add(method, pattern, handler) {
1569
+ const paramNames = [];
1570
+ const isWildcard = pattern.endsWith("/*");
1571
+
1572
+ // Count literal (non-param, non-wildcard) segments for specificity ranking
1573
+ const segments = pattern.replace(/\/\*$/, "").split("/").filter(Boolean);
1574
+ const literalCount = segments.filter((s) => !s.startsWith(":")).length;
1575
+ const totalSegments = segments.length;
1576
+
1577
+ let base = isWildcard ? pattern.slice(0, -2) : pattern;
1578
+ // Escape regex-special characters before replacing :param placeholders
1579
+ const escaped = base.replace(/([.*+?^${}()|[\]\\])/g, "\\$1");
1580
+ const regexStr = escaped.replace(/:(\w+)/g, (_, name) => {
1581
+ paramNames.push(name);
1582
+ return "([^/]+)";
1583
+ });
1584
+
1585
+ if (paramNames.length > 10) {
1586
+ throw new Error(`Route "${pattern}" has ${paramNames.length} params (max 10)`);
1587
+ }
1588
+
1589
+ let regex;
1590
+ if (isWildcard) {
1591
+ regex = new RegExp(`^${regexStr}(?:/(.*))?$`);
1592
+ } else {
1593
+ regex = new RegExp(`^${regexStr}$`);
1594
+ }
1595
+
1596
+ const route = { method, pattern, regex, paramNames, handler, isWildcard, literalCount, totalSegments };
1597
+ if (!this.routes.has(method)) this.routes.set(method, []);
1598
+ this.routes.get(method).push(route);
1599
+
1600
+ // Invalidate route cache when routes change
1601
+ this._cache.clear();
1602
+ }
1603
+
1604
+ _parseQuery(searchParams) {
1605
+ const result = {};
1606
+ for (const key of searchParams.keys()) {
1607
+ if (key in result) continue; // already processed via getAll
1608
+ const values = searchParams.getAll(key);
1609
+ result[key] = values.length > 1 ? values : values[0];
1610
+ }
1611
+ return result;
1612
+ }
1613
+
1614
+ _autoOptions(normalized, query) {
1615
+ const methods = new Set();
1616
+ for (const [method, bucket] of this.routes) {
1617
+ for (const route of bucket) {
1618
+ if (normalized.match(route.regex)) { methods.add(method); break; }
1619
+ }
1620
+ }
1621
+ if (methods.size === 0) return null;
1622
+
1623
+ methods.add("OPTIONS");
1624
+ if (methods.has("GET")) methods.add("HEAD");
1625
+
1626
+ const allow = [...methods].sort().join(", ");
1627
+ return {
1628
+ handler: (_req, res) => {
1629
+ res.writeHead(204, { Allow: allow });
1630
+ res.end();
1631
+ },
1632
+ params: {},
1633
+ query,
1634
+ pattern: "OPTIONS(auto)",
1635
+ };
1636
+ }
1637
+
1638
+ match(method, url) {
1639
+ // H-SEC-1: Cache by method:pathname only (not full URL with query string).
1640
+ // This prevents cache poisoning via unique query parameters while still
1641
+ // providing the performance benefit of caching route matching.
1642
+ // The query string is still passed to _matchUncached for parsing.
1643
+ let pathname;
1644
+ const qIdx = url.indexOf('?');
1645
+ pathname = qIdx === -1 ? url : url.slice(0, qIdx);
1646
+
1647
+ const cacheKey = `${method}:${pathname}`;
1648
+ const cached = this._cache.get(cacheKey);
1649
+ if (cached !== undefined) {
1650
+ // Re-parse query from original url for the cached result
1651
+ if (cached) {
1652
+ const query = qIdx === -1 ? {} : Object.fromEntries(new URL(url, "http://localhost").searchParams);
1653
+ return { ...cached, query };
1654
+ }
1655
+ return cached;
1656
+ }
1657
+
1658
+ const result = this._matchUncached(method, url);
1659
+
1660
+ // P11: Store in cache (evict 20% when full). Don't cache null (unmatched)
1661
+ // to prevent attackers filling the cache with misses.
1662
+ if (result !== null) {
1663
+ if (this._cache.size >= ROUTE_CACHE_MAX) {
1664
+ const deleteCount = Math.ceil(ROUTE_CACHE_MAX * 0.2);
1665
+ const iter = this._cache.keys();
1666
+ for (let i = 0; i < deleteCount; i++) {
1667
+ const key = iter.next().value;
1668
+ if (key !== undefined) this._cache.delete(key);
1669
+ }
1670
+ }
1671
+ this._cache.set(cacheKey, result);
1672
+ }
1673
+ return result;
1674
+ }
1675
+
1676
+ _matchUncached(method, url) {
1677
+ const parsed = new URL(url, "http://localhost");
1678
+ const pathname = parsed.pathname;
1679
+
1680
+ // Reject null bytes — path traversal / request smuggling vector
1681
+ if (url.includes("\0")) return null;
1682
+
1683
+ // Normalize path to collapse encoded traversals (e.g. ..%2F)
1684
+ let normalized = new URL(pathname, "http://localhost").pathname;
1685
+ // Strip trailing slashes (except root "/")
1686
+ if (normalized.length > 1 && normalized.endsWith('/')) {
1687
+ normalized = normalized.slice(0, -1);
1688
+ }
1689
+ const query = this._parseQuery(parsed.searchParams);
1690
+
1691
+ // HEAD requests match GET routes (RFC 7231 § 4.3.2)
1692
+ const isHead = method === "HEAD";
1693
+ const effectiveMethod = isHead ? "GET" : method;
1694
+
1695
+ // Collect route buckets to search — only the relevant method(s)
1696
+ const buckets = [];
1697
+ if (this.routes.has(effectiveMethod)) buckets.push(this.routes.get(effectiveMethod));
1698
+ if (isHead && this.routes.has("HEAD")) buckets.push(this.routes.get("HEAD"));
1699
+
1700
+ // Priority tiers: exact > parameterized > wildcard
1701
+ let bestParam = null;
1702
+ let bestParamScore = -1;
1703
+ let bestWildcard = null;
1704
+ let bestWildcardScore = -1;
1705
+
1706
+ for (const bucket of buckets) {
1707
+ for (const route of bucket) {
1708
+ const match = normalized.match(route.regex);
1709
+ if (!match) continue;
1710
+
1711
+ const params = {};
1712
+ let paramTraversal = false;
1713
+ let decodeError = false;
1714
+ route.paramNames.forEach((name, i) => {
1715
+ let val;
1716
+ try {
1717
+ val = decodeURIComponent(match[i + 1]);
1718
+ } catch {
1719
+ decodeError = true;
1720
+ return;
1721
+ }
1722
+ // Detect double-encoding by attempting further decodes (up to 2 iterations)
1723
+ for (let d = 0; d < 2; d++) {
1724
+ try {
1725
+ const decoded = decodeURIComponent(val);
1726
+ if (decoded === val) break;
1727
+ val = decoded;
1728
+ } catch { break; }
1729
+ }
1730
+ if (val.includes("..") || val.includes("\0") || val.includes("%00")) paramTraversal = true;
1731
+ params[name] = val;
1732
+ });
1733
+ if (decodeError) return null;
1734
+ if (paramTraversal) continue;
1735
+
1736
+ // Wildcard routes — lowest priority tier
1737
+ if (route.isWildcard) {
1738
+ const wildcardIndex = route.paramNames.length + 1;
1739
+ params["*"] = match[wildcardIndex] ?? "";
1740
+ params.wildcard = params["*"];
1741
+
1742
+ const score = route.literalCount * 1000 + route.totalSegments;
1743
+ if (score > bestWildcardScore) {
1744
+ bestWildcardScore = score;
1745
+ bestWildcard = { handler: route.handler, params, query, pattern: route.pattern };
1746
+ }
1747
+ continue;
1748
+ }
1749
+
1750
+ // Exact match (no params) — highest priority, return immediately
1751
+ if (route.paramNames.length === 0) {
1752
+ return { handler: route.handler, params, query, pattern: route.pattern };
1753
+ }
1754
+
1755
+ // Parameterized route — pick the most specific
1756
+ const score = route.literalCount * 1000 + route.totalSegments;
1757
+ if (score > bestParamScore) {
1758
+ bestParamScore = score;
1759
+ bestParam = { handler: route.handler, params, query, pattern: route.pattern };
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ if (bestParam) return bestParam;
1765
+ if (bestWildcard) return bestWildcard;
1766
+
1767
+ // Auto OPTIONS if no explicit handler matched
1768
+ if (method === "OPTIONS") {
1769
+ return this._autoOptions(normalized, query);
1770
+ }
1771
+
1772
+ // 405 Method Not Allowed — path matches but method doesn't
1773
+ const allowedMethods = new Set();
1774
+ for (const [routeMethod, bucket] of this.routes) {
1775
+ for (const route of bucket) {
1776
+ if (normalized.match(route.regex)) { allowedMethods.add(routeMethod); break; }
1777
+ }
1778
+ }
1779
+ if (allowedMethods.size > 0) {
1780
+ allowedMethods.add("OPTIONS");
1781
+ if (allowedMethods.has("GET")) allowedMethods.add("HEAD");
1782
+ const allow = [...allowedMethods].sort().join(", ");
1783
+ return {
1784
+ handler: (_req, res) => {
1785
+ res.writeHead(405, { "Content-Type": "application/json", "Allow": allow });
1786
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
1787
+ },
1788
+ params: {},
1789
+ query,
1790
+ pattern: "405(auto)",
1791
+ };
1792
+ }
1793
+
1794
+ return null;
1795
+ }
1796
+ }
1797
+
1798
+ /**
1799
+ * O5: Structured logger with service context and log level filtering.
1800
+ */
1801
+ class Logger {
1802
+ constructor(serviceName, workerId) {
1803
+ this.serviceName = serviceName;
1804
+ this.workerId = workerId;
1805
+ // O5: Default log level is 'info'
1806
+ this._level = LOG_LEVELS[process.env.FORGE_LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info;
1807
+ }
1808
+
1809
+ /**
1810
+ * O5: Set the log level dynamically.
1811
+ * @param {'error'|'warn'|'info'|'debug'} level
1812
+ */
1813
+ setLogLevel(level) {
1814
+ const numeric = LOG_LEVELS[level];
1815
+ if (numeric === undefined) return;
1816
+ this._level = numeric;
1817
+ }
1818
+
1819
+ _log(level, message, meta = {}) {
1820
+ // O5: Filter messages below current log level
1821
+ if (LOG_LEVELS[level] > this._level) return;
1822
+
1823
+ const entry = {
1824
+ ...meta,
1825
+ timestamp: new Date().toISOString(),
1826
+ level,
1827
+ service: this.serviceName,
1828
+ worker: this.workerId,
1829
+ message,
1830
+ };
1831
+ const output = level === "error" ? process.stderr : process.stdout;
1832
+ output.write(`${JSON.stringify(entry)}\n`);
1833
+ }
1834
+
1835
+ debug(msg, meta) {
1836
+ this._log("debug", msg, meta);
1837
+ }
1838
+ info(msg, meta) {
1839
+ this._log("info", msg, meta);
1840
+ }
1841
+ warn(msg, meta) {
1842
+ this._log("warn", msg, meta);
1843
+ }
1844
+ error(msg, meta) {
1845
+ this._log("error", msg, meta);
1846
+ }
1847
+ }
1848
+
1849
+ /**
1850
+ * Minimal WebSocket implementation (RFC 6455 framing).
1851
+ * No external dependencies. Handles text frames, ping/pong, close.
1852
+ */
1853
+ export class ForgeWebSocket extends EventEmitter {
1854
+ constructor(socket, rctx, options = {}) {
1855
+ super();
1856
+ this.socket = socket;
1857
+ this.ctx = rctx;
1858
+ this._closed = false;
1859
+ this._closeSent = false;
1860
+ this._closeTimer = null;
1861
+ // P7: Use chunk-list pattern instead of Buffer.concat on every chunk
1862
+ this._bufferChunks = [];
1863
+ this._bufferTotalLen = 0;
1864
+ this._fragments = [];
1865
+ this._fragmentOpcode = 0;
1866
+ this._fragmentsTotalSize = 0;
1867
+ this._totalBuffered = 0;
1868
+ this._pingTimer = null;
1869
+ this._lastPong = Date.now();
1870
+
1871
+ socket.on("data", (buf) => this._onData(buf));
1872
+ socket.on("close", () => this._onClose());
1873
+ socket.on("end", () => this._onClose());
1874
+ socket.on("error", () => this._onClose());
1875
+
1876
+ // Start server-side ping keepalive
1877
+ const pingInterval = options.pingInterval ?? 30000;
1878
+ if (pingInterval > 0) {
1879
+ this._startPingInterval(pingInterval);
1880
+ }
1881
+ }
1882
+
1883
+ send(data) {
1884
+ if (this._closed) return;
1885
+ const payload = typeof data === "string" ? Buffer.from(data) : data;
1886
+ const frame = this._encodeFrame(payload, 0x01); // text frame
1887
+ this.socket.write(frame);
1888
+ }
1889
+
1890
+ sendBinary(data) {
1891
+ if (this._closed) return;
1892
+ const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
1893
+ const frame = this._encodeFrame(payload, 0x02); // binary frame
1894
+ this.socket.write(frame);
1895
+ }
1896
+
1897
+ close(code = 1000, reason = "") {
1898
+ if (this._closed) return;
1899
+ this._closed = true;
1900
+ if (this._pingTimer) {
1901
+ clearInterval(this._pingTimer);
1902
+ this._pingTimer = null;
1903
+ }
1904
+ // RFC 6455 §5.5: close frame reason must be <= 123 bytes
1905
+ let reasonBuf = reason ? Buffer.from(reason, "utf8") : Buffer.alloc(0);
1906
+ if (reasonBuf.length > 123) reasonBuf = reasonBuf.subarray(0, 123);
1907
+ const buf = Buffer.alloc(2 + reasonBuf.length);
1908
+ buf.writeUInt16BE(code, 0);
1909
+ if (reasonBuf.length > 0) reasonBuf.copy(buf, 2);
1910
+ this.socket.write(this._encodeFrame(buf, 0x08)); // close frame
1911
+
1912
+ // H2: Close handshake — track _closeSent so we know if we initiated the close.
1913
+ // When the peer's close frame arrives in _onData, the handler checks _closeSent
1914
+ // and ends the socket immediately (completing the handshake).
1915
+ // We always call socket.end() after sending our close frame.
1916
+ if (!this._closeSent) {
1917
+ this._closeSent = true;
1918
+ }
1919
+ this.socket.end();
1920
+ this.emit("close", code, reason);
1921
+ }
1922
+
1923
+ /**
1924
+ * P7: Consolidate buffer chunks into a single Buffer only when needed.
1925
+ */
1926
+ _consolidateBuffer() {
1927
+ if (this._bufferChunks.length === 0) return;
1928
+ if (this._bufferChunks.length === 1) {
1929
+ // No concat needed for single chunk
1930
+ return;
1931
+ }
1932
+ const consolidated = Buffer.concat(this._bufferChunks, this._bufferTotalLen);
1933
+ this._bufferChunks = [consolidated];
1934
+ // _bufferTotalLen stays the same
1935
+ }
1936
+
1937
+ /**
1938
+ * Get the current buffer as a single Buffer (consolidating if needed).
1939
+ */
1940
+ get _buffer() {
1941
+ if (this._bufferChunks.length === 0) return Buffer.alloc(0);
1942
+ if (this._bufferChunks.length === 1) return this._bufferChunks[0];
1943
+ this._consolidateBuffer();
1944
+ return this._bufferChunks[0];
1945
+ }
1946
+
1947
+ /**
1948
+ * Set the buffer (replaces all chunks).
1949
+ */
1950
+ set _buffer(buf) {
1951
+ if (buf.length === 0) {
1952
+ this._bufferChunks = [];
1953
+ this._bufferTotalLen = 0;
1954
+ } else {
1955
+ this._bufferChunks = [buf];
1956
+ this._bufferTotalLen = buf.length;
1957
+ }
1958
+ }
1959
+
1960
+ _onData(buf) {
1961
+ // Track total memory used per connection
1962
+ this._totalBuffered += buf.length;
1963
+
1964
+ if (this._totalBuffered > MAX_WS_BUFFER) {
1965
+ this._closed = true;
1966
+ this._bufferChunks = [];
1967
+ this._bufferTotalLen = 0;
1968
+ this._fragments = [];
1969
+ this._fragmentsTotalSize = 0;
1970
+ this._totalBuffered = 0;
1971
+ this.socket.destroy();
1972
+ this.emit("close", 1009, "Message too big");
1973
+ return;
1974
+ }
1975
+
1976
+ // P7: Append chunk to list instead of Buffer.concat on every call
1977
+ this._bufferChunks.push(buf);
1978
+ this._bufferTotalLen += buf.length;
1979
+
1980
+ while (this._bufferTotalLen >= 2) {
1981
+ // Consolidate only when we need to parse
1982
+ this._consolidateBuffer();
1983
+ const buffer = this._bufferChunks[0];
1984
+
1985
+ const fin = (buffer[0] & 0x80) !== 0;
1986
+ const rsv = buffer[0] & 0x70; // bits 4-6
1987
+ if (rsv !== 0) {
1988
+ // C7: Immediate destroy — don't try to send close frame
1989
+ this._closed = true;
1990
+ this._bufferChunks = [];
1991
+ this._bufferTotalLen = 0;
1992
+ this._fragments = [];
1993
+ this._fragmentsTotalSize = 0;
1994
+ if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
1995
+ this.socket.destroy();
1996
+ this.emit("close", 1002, "RSV bits must be 0");
1997
+ return;
1998
+ }
1999
+ const masked = (buffer[1] & 0x80) !== 0;
2000
+
2001
+ // RFC 6455 requires client-to-server frames to be masked
2002
+ if (!masked) {
2003
+ // H2: Immediate destroy — don't try to send close frame
2004
+ this._closed = true;
2005
+ this._bufferChunks = [];
2006
+ this._bufferTotalLen = 0;
2007
+ this._fragments = [];
2008
+ this._fragmentsTotalSize = 0;
2009
+ if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
2010
+ this.socket.destroy();
2011
+ this.emit("close", 1002, "Unmasked client frame");
2012
+ return;
2013
+ }
2014
+
2015
+ let payloadLen = buffer[1] & 0x7f;
2016
+ let offset = 2;
2017
+
2018
+ if (payloadLen === 126) {
2019
+ if (this._bufferTotalLen < 4) return; // need more data for length
2020
+ payloadLen = buffer.readUInt16BE(2);
2021
+ offset = 4;
2022
+ } else if (payloadLen === 127) {
2023
+ if (this._bufferTotalLen < 10) return; // need more data for length
2024
+ payloadLen = Number(buffer.readBigUInt64BE(2));
2025
+ offset = 10;
2026
+ }
2027
+
2028
+ // Max payload size check to prevent memory exhaustion (1MB)
2029
+ if (payloadLen > MAX_WS_BUFFER) {
2030
+ this.close(1009); // 1009 = message too big
2031
+ this._bufferChunks = [];
2032
+ this._bufferTotalLen = 0;
2033
+ this._fragments = [];
2034
+ this._fragmentsTotalSize = 0;
2035
+ return;
2036
+ }
2037
+
2038
+ if (masked) offset += 4;
2039
+
2040
+ const totalFrameSize = offset + payloadLen;
2041
+ if (this._bufferTotalLen < totalFrameSize) return; // incomplete frame
2042
+
2043
+ // We have a complete frame — parse it
2044
+ const opcode = buffer[0] & 0x0f;
2045
+ const maskOffset = offset - 4;
2046
+ let mask = null;
2047
+ if (masked) {
2048
+ mask = buffer.slice(maskOffset, maskOffset + 4);
2049
+ }
2050
+
2051
+ let payload = buffer.slice(offset, offset + payloadLen);
2052
+ // P8: In-place XOR unmasking — no intermediate array allocation
2053
+ if (mask) {
2054
+ payload = Buffer.from(payload); // copy so we can modify in place
2055
+ for (let i = 0; i < payload.length; i++) {
2056
+ payload[i] ^= mask[i & 3];
2057
+ }
2058
+ }
2059
+
2060
+ // Consume the frame from the buffer
2061
+ const remaining = buffer.slice(totalFrameSize);
2062
+ if (remaining.length === 0) {
2063
+ this._bufferChunks = [];
2064
+ this._bufferTotalLen = 0;
2065
+ } else {
2066
+ this._bufferChunks = [remaining];
2067
+ this._bufferTotalLen = remaining.length;
2068
+ }
2069
+ // Recompute total buffered from actual buffer + already-tracked fragment size
2070
+ this._totalBuffered = this._bufferTotalLen + this._fragmentsTotalSize;
2071
+
2072
+ // Handle control frames (ping/pong/close) — always processed immediately
2073
+ if (opcode === 0x08) {
2074
+ // H2: If we already sent a close frame, this is the peer's response —
2075
+ // complete the handshake by ending the socket immediately
2076
+ if (this._closeSent) {
2077
+ if (this._closeTimer) { clearTimeout(this._closeTimer); this._closeTimer = null; }
2078
+ this.socket.end();
2079
+ return;
2080
+ }
2081
+
2082
+ // close — parse status code and reason from payload (RFC 6455 § 5.5.1)
2083
+ // Set _closeSent so the responding close() call knows to end immediately
2084
+ this._closeSent = true;
2085
+ if (payload.length === 0) {
2086
+ this.close(1000, '');
2087
+ } else if (payload.length === 1) {
2088
+ // Malformed: close payload must be 0 or >= 2 bytes
2089
+ this.close(1002, 'Protocol error');
2090
+ } else {
2091
+ const code = payload.readUInt16BE(0);
2092
+ // H10: Validate close code per RFC 6455 §7.4
2093
+ if (code < 1000 || (code >= 1004 && code <= 1006) || (code >= 1012 && code <= 2999) || code >= 5000) {
2094
+ this.close(1002, 'Protocol error');
2095
+ return;
2096
+ }
2097
+ const reason = payload.length > 2 ? payload.slice(2).toString('utf8') : '';
2098
+ this.close(code, reason);
2099
+ }
2100
+ return;
2101
+ }
2102
+ if (opcode === 0x09) {
2103
+ // ping
2104
+ this.socket.write(this._encodeFrame(payload, 0x0a)); // pong
2105
+ this._lastPong = Date.now();
2106
+ continue;
2107
+ }
2108
+ if (opcode === 0x0a) {
2109
+ // pong
2110
+ this._lastPong = Date.now();
2111
+ continue;
2112
+ }
2113
+
2114
+ // Handle data frames with fragmentation support
2115
+ if (opcode !== 0x00) {
2116
+ // Non-continuation frame — start of a new message (or single-frame message)
2117
+ this._fragmentOpcode = opcode;
2118
+ this._fragments = [payload];
2119
+ this._fragmentsTotalSize = payload.length;
2120
+ } else {
2121
+ // Continuation frame (opcode 0x00)
2122
+ if (this._fragments.length === 0) {
2123
+ // Orphan continuation frame with no initial frame — protocol error
2124
+ this.close(1002);
2125
+ return;
2126
+ }
2127
+ const MAX_WS_FRAGMENTS = 1024;
2128
+ if (this._fragments.length >= MAX_WS_FRAGMENTS) {
2129
+ this.close(1009, "Too many fragments");
2130
+ return;
2131
+ }
2132
+ this._fragmentsTotalSize += payload.length;
2133
+ if (this._fragmentsTotalSize > MAX_WS_BUFFER) {
2134
+ this.close(1009, "Reassembled message too large");
2135
+ return;
2136
+ }
2137
+ this._fragments.push(payload);
2138
+ }
2139
+
2140
+ if (fin) {
2141
+ // Message is complete — reassemble fragments
2142
+ const complete = this._fragments.length === 1 ? this._fragments[0] : Buffer.concat(this._fragments);
2143
+ const messageOpcode = this._fragmentOpcode;
2144
+ this._fragments = [];
2145
+ this._fragmentOpcode = 0;
2146
+ this._fragmentsTotalSize = 0;
2147
+
2148
+ // Check total reassembled message size
2149
+ if (complete.length > MAX_WS_BUFFER) {
2150
+ this.close(1009);
2151
+ this._bufferChunks = [];
2152
+ this._bufferTotalLen = 0;
2153
+ return;
2154
+ }
2155
+
2156
+ if (messageOpcode === 0x01) {
2157
+ // text
2158
+ this.emit("message", complete.toString());
2159
+ } else if (messageOpcode === 0x02) {
2160
+ // binary
2161
+ this.emit("message", complete);
2162
+ }
2163
+ }
2164
+ }
2165
+
2166
+ // Always recalculate at end to prevent drift from partial frames
2167
+ this._totalBuffered = this._bufferTotalLen + this._fragmentsTotalSize;
2168
+ }
2169
+
2170
+ _encodeFrame(payload, opcode) {
2171
+ const len = payload.length;
2172
+ let header;
2173
+ if (len < 126) {
2174
+ header = Buffer.alloc(2);
2175
+ header[0] = 0x80 | opcode; // FIN + opcode
2176
+ header[1] = len;
2177
+ } else if (len < 65536) {
2178
+ header = Buffer.alloc(4);
2179
+ header[0] = 0x80 | opcode;
2180
+ header[1] = 126;
2181
+ header.writeUInt16BE(len, 2);
2182
+ } else {
2183
+ header = Buffer.alloc(10);
2184
+ header[0] = 0x80 | opcode;
2185
+ header[1] = 127;
2186
+ header.writeBigUInt64BE(BigInt(len), 2);
2187
+ }
2188
+ return Buffer.concat([header, payload]);
2189
+ }
2190
+
2191
+ _onClose() {
2192
+ // Always clean up timers regardless of _closed state
2193
+ if (this._pingTimer) {
2194
+ clearInterval(this._pingTimer);
2195
+ this._pingTimer = null;
2196
+ }
2197
+ if (this._closeTimer) {
2198
+ clearTimeout(this._closeTimer);
2199
+ this._closeTimer = null;
2200
+ }
2201
+ if (!this._closed) {
2202
+ this._closed = true;
2203
+ this.emit("close");
2204
+ }
2205
+ }
2206
+
2207
+ _startPingInterval(intervalMs) {
2208
+ // M-WS-1: Reset pong timestamp to avoid false timeout from gap between
2209
+ // socket creation and first ping check
2210
+ this._lastPong = Date.now();
2211
+ this._pingTimer = setInterval(() => {
2212
+ if (this._closed) {
2213
+ clearInterval(this._pingTimer);
2214
+ this._pingTimer = null;
2215
+ return;
2216
+ }
2217
+ // If no pong received within 2 ping intervals, close as unresponsive
2218
+ if (Date.now() - this._lastPong > intervalMs * 2) {
2219
+ this.close(1001, 'Ping timeout');
2220
+ return;
2221
+ }
2222
+ // Send ping frame with empty payload
2223
+ this.socket.write(this._encodeFrame(Buffer.alloc(0), 0x09));
2224
+ }, intervalMs);
2225
+ this._pingTimer.unref();
2226
+ }
2227
+ }