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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/forge.js +1050 -0
- package/bin/host-commands.js +344 -0
- package/bin/platform-commands.js +570 -0
- package/package.json +71 -0
- package/shared/auth.js +475 -0
- package/src/core/DirectMessageBus.js +364 -0
- package/src/core/EndpointResolver.js +247 -0
- package/src/core/ForgeContext.js +2227 -0
- package/src/core/ForgeHost.js +122 -0
- package/src/core/ForgePlatform.js +145 -0
- package/src/core/Ingress.js +768 -0
- package/src/core/Interceptors.js +420 -0
- package/src/core/MessageBus.js +310 -0
- package/src/core/Prometheus.js +305 -0
- package/src/core/RequestContext.js +413 -0
- package/src/core/RoutingStrategy.js +316 -0
- package/src/core/Supervisor.js +1306 -0
- package/src/core/ThreadAllocator.js +196 -0
- package/src/core/WorkerChannelManager.js +879 -0
- package/src/core/config.js +624 -0
- package/src/core/host-config.js +311 -0
- package/src/core/network-utils.js +166 -0
- package/src/core/platform-config.js +308 -0
- package/src/decorators/ServiceProxy.js +899 -0
- package/src/decorators/index.js +571 -0
- package/src/deploy/NginxGenerator.js +865 -0
- package/src/deploy/PlatformManifestGenerator.js +96 -0
- package/src/deploy/RouteManifestGenerator.js +112 -0
- package/src/deploy/index.js +984 -0
- package/src/frontend/FrontendDevLifecycle.js +65 -0
- package/src/frontend/FrontendPluginOrchestrator.js +187 -0
- package/src/frontend/SiteResolver.js +63 -0
- package/src/frontend/StaticMountRegistry.js +90 -0
- package/src/frontend/index.js +5 -0
- package/src/frontend/plugins/index.js +2 -0
- package/src/frontend/plugins/viteFrontend.js +79 -0
- package/src/frontend/types.js +35 -0
- package/src/index.js +56 -0
- package/src/internals.js +31 -0
- package/src/plugins/PluginManager.js +537 -0
- package/src/plugins/ScopedPostgres.js +192 -0
- package/src/plugins/ScopedRedis.js +142 -0
- package/src/plugins/index.js +1729 -0
- package/src/registry/ServiceRegistry.js +796 -0
- package/src/scaling/ScaleAdvisor.js +442 -0
- package/src/services/Service.js +195 -0
- package/src/services/worker-bootstrap.js +676 -0
- package/src/templates/auth-service.js +65 -0
- 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
|
+
}
|