threadforge 0.1.1 → 0.2.2
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/README.md +69 -42
- package/bin/forge.js +2 -1058
- package/bin/host-commands.d.ts +2 -0
- package/bin/host-commands.d.ts.map +1 -0
- package/bin/host-commands.js +7 -8
- package/bin/platform-commands.d.ts +2 -0
- package/bin/platform-commands.d.ts.map +1 -0
- package/bin/platform-commands.js +118 -36
- package/dist/cli/base-command.d.ts +12 -0
- package/dist/cli/base-command.d.ts.map +1 -0
- package/dist/cli/base-command.js +25 -0
- package/dist/cli/base-command.js.map +1 -0
- package/dist/cli/commands/build.d.ts +10 -0
- package/dist/cli/commands/build.d.ts.map +1 -0
- package/dist/cli/commands/build.js +110 -0
- package/dist/cli/commands/build.js.map +1 -0
- package/dist/cli/commands/deploy.d.ts +12 -0
- package/dist/cli/commands/deploy.d.ts.map +1 -0
- package/dist/cli/commands/deploy.js +143 -0
- package/dist/cli/commands/deploy.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +10 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +138 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/generate.d.ts +10 -0
- package/dist/cli/commands/generate.d.ts.map +1 -0
- package/dist/cli/commands/generate.js +76 -0
- package/dist/cli/commands/generate.js.map +1 -0
- package/dist/cli/commands/host.d.ts +8 -0
- package/dist/cli/commands/host.d.ts.map +1 -0
- package/dist/cli/commands/host.js +20 -0
- package/dist/cli/commands/host.js.map +1 -0
- package/dist/cli/commands/init.d.ts +16 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +246 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/platform.d.ts +8 -0
- package/dist/cli/commands/platform.d.ts.map +1 -0
- package/dist/cli/commands/platform.js +20 -0
- package/dist/cli/commands/platform.js.map +1 -0
- package/dist/cli/commands/restart.d.ts +8 -0
- package/dist/cli/commands/restart.d.ts.map +1 -0
- package/dist/cli/commands/restart.js +13 -0
- package/dist/cli/commands/restart.js.map +1 -0
- package/dist/cli/commands/scaffold/frontend.d.ts +10 -0
- package/dist/cli/commands/scaffold/frontend.d.ts.map +1 -0
- package/dist/cli/commands/scaffold/frontend.js +130 -0
- package/dist/cli/commands/scaffold/frontend.js.map +1 -0
- package/dist/cli/commands/scaffold/react.d.ts +7 -0
- package/dist/cli/commands/scaffold/react.d.ts.map +1 -0
- package/dist/cli/commands/scaffold/react.js +12 -0
- package/dist/cli/commands/scaffold/react.js.map +1 -0
- package/dist/cli/commands/scale.d.ts +8 -0
- package/dist/cli/commands/scale.d.ts.map +1 -0
- package/dist/cli/commands/scale.js +13 -0
- package/dist/cli/commands/scale.js.map +1 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.d.ts.map +1 -0
- package/dist/cli/commands/start.js +71 -0
- package/dist/cli/commands/start.js.map +1 -0
- package/dist/cli/commands/status.d.ts +11 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +60 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/stop.d.ts +10 -0
- package/dist/cli/commands/stop.d.ts.map +1 -0
- package/dist/cli/commands/stop.js +89 -0
- package/dist/cli/commands/stop.js.map +1 -0
- package/dist/cli/util/config-discovery.d.ts +8 -0
- package/dist/cli/util/config-discovery.d.ts.map +1 -0
- package/dist/cli/util/config-discovery.js +70 -0
- package/dist/cli/util/config-discovery.js.map +1 -0
- package/dist/cli/util/config-patcher.d.ts +17 -0
- package/dist/cli/util/config-patcher.d.ts.map +1 -0
- package/dist/cli/util/config-patcher.js +439 -0
- package/dist/cli/util/config-patcher.js.map +1 -0
- package/dist/cli/util/frontend-dev.d.ts +8 -0
- package/dist/cli/util/frontend-dev.d.ts.map +1 -0
- package/dist/cli/util/frontend-dev.js +117 -0
- package/dist/cli/util/frontend-dev.js.map +1 -0
- package/dist/cli/util/process.d.ts +5 -0
- package/dist/cli/util/process.d.ts.map +1 -0
- package/dist/cli/util/process.js +17 -0
- package/dist/cli/util/process.js.map +1 -0
- package/dist/cli/util/templates.d.ts +10 -0
- package/dist/cli/util/templates.d.ts.map +1 -0
- package/dist/cli/util/templates.js +157 -0
- package/dist/cli/util/templates.js.map +1 -0
- package/dist/core/AlertSink.d.ts +83 -0
- package/dist/core/AlertSink.d.ts.map +1 -0
- package/dist/core/AlertSink.js +126 -0
- package/dist/core/AlertSink.js.map +1 -0
- package/dist/core/DirectMessageBus.d.ts +88 -0
- package/dist/core/DirectMessageBus.d.ts.map +1 -0
- package/dist/core/DirectMessageBus.js +352 -0
- package/dist/core/DirectMessageBus.js.map +1 -0
- package/dist/core/EndpointResolver.d.ts +111 -0
- package/dist/core/EndpointResolver.d.ts.map +1 -0
- package/dist/core/EndpointResolver.js +336 -0
- package/dist/core/EndpointResolver.js.map +1 -0
- package/dist/core/ForgeContext.d.ts +221 -0
- package/dist/core/ForgeContext.d.ts.map +1 -0
- package/dist/core/ForgeContext.js +1169 -0
- package/dist/core/ForgeContext.js.map +1 -0
- package/dist/core/ForgeEndpoints.d.ts +71 -0
- package/dist/core/ForgeEndpoints.d.ts.map +1 -0
- package/dist/core/ForgeEndpoints.js +442 -0
- package/dist/core/ForgeEndpoints.js.map +1 -0
- package/dist/core/ForgeHost.d.ts +82 -0
- package/dist/core/ForgeHost.d.ts.map +1 -0
- package/dist/core/ForgeHost.js +107 -0
- package/dist/core/ForgeHost.js.map +1 -0
- package/dist/core/ForgePlatform.d.ts +96 -0
- package/dist/core/ForgePlatform.d.ts.map +1 -0
- package/dist/core/ForgePlatform.js +136 -0
- package/dist/core/ForgePlatform.js.map +1 -0
- package/dist/core/ForgeWebSocket.d.ts +56 -0
- package/dist/core/ForgeWebSocket.d.ts.map +1 -0
- package/dist/core/ForgeWebSocket.js +415 -0
- package/dist/core/ForgeWebSocket.js.map +1 -0
- package/dist/core/Ingress.d.ts +329 -0
- package/dist/core/Ingress.d.ts.map +1 -0
- package/dist/core/Ingress.js +694 -0
- package/dist/core/Ingress.js.map +1 -0
- package/dist/core/Interceptors.d.ts +134 -0
- package/dist/core/Interceptors.d.ts.map +1 -0
- package/dist/core/Interceptors.js +416 -0
- package/dist/core/Interceptors.js.map +1 -0
- package/dist/core/Logger.d.ts +20 -0
- package/dist/core/Logger.d.ts.map +1 -0
- package/dist/core/Logger.js +77 -0
- package/dist/core/Logger.js.map +1 -0
- package/dist/core/MessageBus.d.ts +15 -0
- package/dist/core/MessageBus.d.ts.map +1 -0
- package/dist/core/MessageBus.js +18 -0
- package/dist/core/MessageBus.js.map +1 -0
- package/dist/core/Prometheus.d.ts +80 -0
- package/dist/core/Prometheus.d.ts.map +1 -0
- package/dist/core/Prometheus.js +332 -0
- package/dist/core/Prometheus.js.map +1 -0
- package/dist/core/RequestContext.d.ts +214 -0
- package/dist/core/RequestContext.d.ts.map +1 -0
- package/dist/core/RequestContext.js +556 -0
- package/dist/core/RequestContext.js.map +1 -0
- package/dist/core/Router.d.ts +45 -0
- package/dist/core/Router.d.ts.map +1 -0
- package/dist/core/Router.js +285 -0
- package/dist/core/Router.js.map +1 -0
- package/dist/core/RoutingStrategy.d.ts +116 -0
- package/dist/core/RoutingStrategy.d.ts.map +1 -0
- package/dist/core/RoutingStrategy.js +306 -0
- package/dist/core/RoutingStrategy.js.map +1 -0
- package/dist/core/RpcConfig.d.ts +72 -0
- package/dist/core/RpcConfig.d.ts.map +1 -0
- package/dist/core/RpcConfig.js +127 -0
- package/dist/core/RpcConfig.js.map +1 -0
- package/dist/core/SignatureCache.d.ts +81 -0
- package/dist/core/SignatureCache.d.ts.map +1 -0
- package/dist/core/SignatureCache.js +172 -0
- package/dist/core/SignatureCache.js.map +1 -0
- package/dist/core/StaticFileServer.d.ts +34 -0
- package/dist/core/StaticFileServer.d.ts.map +1 -0
- package/dist/core/StaticFileServer.js +497 -0
- package/dist/core/StaticFileServer.js.map +1 -0
- package/dist/core/Supervisor.d.ts +198 -0
- package/dist/core/Supervisor.d.ts.map +1 -0
- package/dist/core/Supervisor.js +1418 -0
- package/dist/core/Supervisor.js.map +1 -0
- package/dist/core/ThreadAllocator.d.ts +52 -0
- package/dist/core/ThreadAllocator.d.ts.map +1 -0
- package/dist/core/ThreadAllocator.js +174 -0
- package/dist/core/ThreadAllocator.js.map +1 -0
- package/dist/core/WorkerChannelManager.d.ts +130 -0
- package/dist/core/WorkerChannelManager.d.ts.map +1 -0
- package/dist/core/WorkerChannelManager.js +956 -0
- package/dist/core/WorkerChannelManager.js.map +1 -0
- package/dist/core/config-enums.d.ts +41 -0
- package/dist/core/config-enums.d.ts.map +1 -0
- package/dist/core/config-enums.js +59 -0
- package/dist/core/config-enums.js.map +1 -0
- package/dist/core/config.d.ts +159 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +694 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/host-config.d.ts +146 -0
- package/dist/core/host-config.d.ts.map +1 -0
- package/dist/core/host-config.js +312 -0
- package/dist/core/host-config.js.map +1 -0
- package/dist/core/ipc-errors.d.ts +27 -0
- package/dist/core/ipc-errors.d.ts.map +1 -0
- package/dist/core/ipc-errors.js +36 -0
- package/dist/core/ipc-errors.js.map +1 -0
- package/dist/core/network-utils.d.ts +35 -0
- package/dist/core/network-utils.d.ts.map +1 -0
- package/dist/core/network-utils.js +145 -0
- package/dist/core/network-utils.js.map +1 -0
- package/dist/core/platform-config.d.ts +142 -0
- package/dist/core/platform-config.d.ts.map +1 -0
- package/dist/core/platform-config.js +299 -0
- package/dist/core/platform-config.js.map +1 -0
- package/dist/decorators/ServiceProxy.d.ts +175 -0
- package/dist/decorators/ServiceProxy.d.ts.map +1 -0
- package/dist/decorators/ServiceProxy.js +969 -0
- package/dist/decorators/ServiceProxy.js.map +1 -0
- package/dist/decorators/index.d.ts +146 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/index.js +545 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/deploy/NginxGenerator.d.ts +165 -0
- package/dist/deploy/NginxGenerator.d.ts.map +1 -0
- package/dist/deploy/NginxGenerator.js +781 -0
- package/dist/deploy/NginxGenerator.js.map +1 -0
- package/dist/deploy/PlatformManifestGenerator.d.ts +43 -0
- package/dist/deploy/PlatformManifestGenerator.d.ts.map +1 -0
- package/dist/deploy/PlatformManifestGenerator.js +80 -0
- package/dist/deploy/PlatformManifestGenerator.js.map +1 -0
- package/dist/deploy/RouteManifestGenerator.d.ts +42 -0
- package/dist/deploy/RouteManifestGenerator.d.ts.map +1 -0
- package/dist/deploy/RouteManifestGenerator.js +105 -0
- package/dist/deploy/RouteManifestGenerator.js.map +1 -0
- package/dist/deploy/index.d.ts +210 -0
- package/dist/deploy/index.d.ts.map +1 -0
- package/dist/deploy/index.js +918 -0
- package/dist/deploy/index.js.map +1 -0
- package/dist/frontend/FrontendDevLifecycle.d.ts +26 -0
- package/dist/frontend/FrontendDevLifecycle.d.ts.map +1 -0
- package/dist/frontend/FrontendDevLifecycle.js +60 -0
- package/dist/frontend/FrontendDevLifecycle.js.map +1 -0
- package/dist/frontend/FrontendPluginOrchestrator.d.ts +64 -0
- package/dist/frontend/FrontendPluginOrchestrator.d.ts.map +1 -0
- package/dist/frontend/FrontendPluginOrchestrator.js +167 -0
- package/dist/frontend/FrontendPluginOrchestrator.js.map +1 -0
- package/dist/frontend/SiteResolver.d.ts +33 -0
- package/dist/frontend/SiteResolver.d.ts.map +1 -0
- package/dist/frontend/SiteResolver.js +53 -0
- package/dist/frontend/SiteResolver.js.map +1 -0
- package/dist/frontend/StaticMountRegistry.d.ts +36 -0
- package/dist/frontend/StaticMountRegistry.d.ts.map +1 -0
- package/dist/frontend/StaticMountRegistry.js +94 -0
- package/dist/frontend/StaticMountRegistry.js.map +1 -0
- package/dist/frontend/index.d.ts +7 -0
- package/dist/frontend/index.d.ts.map +1 -0
- package/{src → dist}/frontend/index.js +4 -2
- package/dist/frontend/index.js.map +1 -0
- package/dist/frontend/pathUtils.d.ts +8 -0
- package/dist/frontend/pathUtils.d.ts.map +1 -0
- package/dist/frontend/pathUtils.js +17 -0
- package/dist/frontend/pathUtils.js.map +1 -0
- package/dist/frontend/plugins/index.d.ts +2 -0
- package/dist/frontend/plugins/index.d.ts.map +1 -0
- package/{src → dist}/frontend/plugins/index.js +1 -1
- package/dist/frontend/plugins/index.js.map +1 -0
- package/dist/frontend/plugins/viteFrontend.d.ts +51 -0
- package/dist/frontend/plugins/viteFrontend.d.ts.map +1 -0
- package/dist/frontend/plugins/viteFrontend.js +134 -0
- package/dist/frontend/plugins/viteFrontend.js.map +1 -0
- package/dist/frontend/types.d.ts +25 -0
- package/dist/frontend/types.d.ts.map +1 -0
- package/dist/frontend/types.js +2 -0
- package/dist/frontend/types.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/internals.d.ts +21 -0
- package/dist/internals.d.ts.map +1 -0
- package/{src → dist}/internals.js +12 -14
- package/dist/internals.js.map +1 -0
- package/dist/plugins/PluginManager.d.ts +209 -0
- package/dist/plugins/PluginManager.d.ts.map +1 -0
- package/dist/plugins/PluginManager.js +365 -0
- package/dist/plugins/PluginManager.js.map +1 -0
- package/dist/plugins/ScopedPostgres.d.ts +78 -0
- package/dist/plugins/ScopedPostgres.d.ts.map +1 -0
- package/dist/plugins/ScopedPostgres.js +190 -0
- package/dist/plugins/ScopedPostgres.js.map +1 -0
- package/dist/plugins/ScopedRedis.d.ts +88 -0
- package/dist/plugins/ScopedRedis.d.ts.map +1 -0
- package/dist/plugins/ScopedRedis.js +169 -0
- package/dist/plugins/ScopedRedis.js.map +1 -0
- package/dist/plugins/index.d.ts +289 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +1942 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/types.d.ts +59 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +2 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/registry/ServiceRegistry.d.ts +305 -0
- package/dist/registry/ServiceRegistry.d.ts.map +1 -0
- package/dist/registry/ServiceRegistry.js +735 -0
- package/dist/registry/ServiceRegistry.js.map +1 -0
- package/dist/scaling/ScaleAdvisor.d.ts +214 -0
- package/dist/scaling/ScaleAdvisor.d.ts.map +1 -0
- package/dist/scaling/ScaleAdvisor.js +526 -0
- package/dist/scaling/ScaleAdvisor.js.map +1 -0
- package/dist/services/Service.d.ts +164 -0
- package/dist/services/Service.d.ts.map +1 -0
- package/dist/services/Service.js +106 -0
- package/dist/services/Service.js.map +1 -0
- package/dist/services/worker-bootstrap.d.ts +15 -0
- package/dist/services/worker-bootstrap.d.ts.map +1 -0
- package/dist/services/worker-bootstrap.js +744 -0
- package/dist/services/worker-bootstrap.js.map +1 -0
- package/dist/templates/auth-service.d.ts +42 -0
- package/dist/templates/auth-service.d.ts.map +1 -0
- package/dist/templates/auth-service.js +54 -0
- package/dist/templates/auth-service.js.map +1 -0
- package/dist/templates/identity-service.d.ts +50 -0
- package/dist/templates/identity-service.d.ts.map +1 -0
- package/dist/templates/identity-service.js +62 -0
- package/dist/templates/identity-service.js.map +1 -0
- package/dist/types/contract.d.ts +120 -0
- package/dist/types/contract.d.ts.map +1 -0
- package/dist/types/contract.js +69 -0
- package/dist/types/contract.js.map +1 -0
- package/package.json +79 -20
- package/src/core/DirectMessageBus.js +0 -364
- package/src/core/EndpointResolver.js +0 -259
- package/src/core/ForgeContext.js +0 -2236
- package/src/core/ForgeHost.js +0 -122
- package/src/core/ForgePlatform.js +0 -145
- package/src/core/Ingress.js +0 -768
- package/src/core/Interceptors.js +0 -420
- package/src/core/MessageBus.js +0 -321
- package/src/core/Prometheus.js +0 -305
- package/src/core/RequestContext.js +0 -413
- package/src/core/RoutingStrategy.js +0 -330
- package/src/core/Supervisor.js +0 -1349
- package/src/core/ThreadAllocator.js +0 -196
- package/src/core/WorkerChannelManager.js +0 -879
- package/src/core/config.js +0 -637
- package/src/core/host-config.js +0 -311
- package/src/core/network-utils.js +0 -166
- package/src/core/platform-config.js +0 -308
- package/src/decorators/ServiceProxy.js +0 -904
- package/src/decorators/index.js +0 -571
- package/src/deploy/NginxGenerator.js +0 -865
- package/src/deploy/PlatformManifestGenerator.js +0 -96
- package/src/deploy/RouteManifestGenerator.js +0 -112
- package/src/deploy/index.js +0 -984
- package/src/frontend/FrontendDevLifecycle.js +0 -65
- package/src/frontend/FrontendPluginOrchestrator.js +0 -187
- package/src/frontend/SiteResolver.js +0 -63
- package/src/frontend/StaticMountRegistry.js +0 -90
- package/src/frontend/plugins/viteFrontend.js +0 -79
- package/src/frontend/types.js +0 -35
- package/src/index.js +0 -58
- package/src/plugins/PluginManager.js +0 -537
- package/src/plugins/ScopedPostgres.js +0 -192
- package/src/plugins/ScopedRedis.js +0 -142
- package/src/plugins/index.js +0 -1756
- package/src/registry/ServiceRegistry.js +0 -797
- package/src/scaling/ScaleAdvisor.js +0 -442
- package/src/services/Service.js +0 -195
- package/src/services/worker-bootstrap.js +0 -679
- package/src/templates/auth-service.js +0 -65
- package/src/templates/identity-service.js +0 -75
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import tls from "node:tls";
|
|
5
|
+
import { StaticMountRegistry } from "../frontend/StaticMountRegistry.js";
|
|
6
|
+
import { ForgeEndpoints, verifyJwt } from "./ForgeEndpoints.js";
|
|
7
|
+
import { ForgeWebSocket } from "./ForgeWebSocket.js";
|
|
8
|
+
import { IngressProtection } from "./Ingress.js";
|
|
9
|
+
import { Logger } from "./Logger.js";
|
|
10
|
+
// A10: Network utilities extracted to network-utils.js
|
|
11
|
+
import { isPrivateNetwork, isTrustedProxy } from "./network-utils.js";
|
|
12
|
+
import { PrometheusMetrics } from "./Prometheus.js";
|
|
13
|
+
import { RequestContext } from "./RequestContext.js";
|
|
14
|
+
import { Router } from "./Router.js";
|
|
15
|
+
import { StaticFileServer } from "./StaticFileServer.js";
|
|
16
|
+
import { WorkerChannelManager } from "./WorkerChannelManager.js";
|
|
17
|
+
import { RegistryMode, ServiceType, } from "./config-enums.js";
|
|
18
|
+
// Re-export for backward compatibility (A10)
|
|
19
|
+
export { isPrivateNetwork, isTrustedProxy };
|
|
20
|
+
const MAX_BODY_SIZE = 1_048_576; // 1MB
|
|
21
|
+
const VALID_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
|
|
22
|
+
// S11: Body timeout reduced from 30s to 10s
|
|
23
|
+
const BODY_TIMEOUT_MS = 10_000;
|
|
24
|
+
export const NOT_HANDLED = Symbol("NOT_HANDLED");
|
|
25
|
+
// Error messages for fatal bind errors
|
|
26
|
+
const BIND_ERROR_MESSAGES = {
|
|
27
|
+
EPERM: (port) => `Permission denied binding to port ${port}. Run with elevated privileges or use a port >= 1024.`,
|
|
28
|
+
EACCES: (port) => `Access denied binding to port ${port}. Run with elevated privileges or use a port >= 1024.`,
|
|
29
|
+
EADDRNOTAVAIL: (port) => `Cannot bind to port ${port}. Address not available (restricted environment or invalid configuration).`,
|
|
30
|
+
EADDRINUSE: (port) => `Port ${port} is already in use. Stop the conflicting process or choose a different port.`,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* ForgeContext
|
|
34
|
+
*
|
|
35
|
+
* Injected into every Service instance. Provides:
|
|
36
|
+
* - HTTP router
|
|
37
|
+
* - IPC messaging helpers (direct worker-to-worker when available)
|
|
38
|
+
* - Metrics collection
|
|
39
|
+
* - Structured logger
|
|
40
|
+
* - Runtime metadata (service name, thread count, worker id, etc.)
|
|
41
|
+
*/
|
|
42
|
+
export class ForgeContext {
|
|
43
|
+
serviceName;
|
|
44
|
+
port;
|
|
45
|
+
workerId;
|
|
46
|
+
threadCount;
|
|
47
|
+
mode;
|
|
48
|
+
serviceType;
|
|
49
|
+
router;
|
|
50
|
+
logger;
|
|
51
|
+
metrics;
|
|
52
|
+
channels;
|
|
53
|
+
ingress;
|
|
54
|
+
_sendIPC;
|
|
55
|
+
_localSend;
|
|
56
|
+
_localRequest;
|
|
57
|
+
_ingressConfig;
|
|
58
|
+
_ingressApplied;
|
|
59
|
+
_forgeProxy;
|
|
60
|
+
_serviceInstance;
|
|
61
|
+
_servicePorts;
|
|
62
|
+
_endpointResolver;
|
|
63
|
+
_staticMounts;
|
|
64
|
+
_staticRegistry;
|
|
65
|
+
_staticFileServer;
|
|
66
|
+
_forgeEndpoints;
|
|
67
|
+
_wsConnections;
|
|
68
|
+
_wsPerIpCounts;
|
|
69
|
+
_wsMaxPerIp;
|
|
70
|
+
_wsPerIpCleanupTimer;
|
|
71
|
+
_wsHandlers;
|
|
72
|
+
_wsPluginHooks;
|
|
73
|
+
_server;
|
|
74
|
+
_needsHttpServer;
|
|
75
|
+
_activeRequests;
|
|
76
|
+
_messageHandlers;
|
|
77
|
+
_onMessage;
|
|
78
|
+
_onRequest;
|
|
79
|
+
_projectId;
|
|
80
|
+
_projectSchema;
|
|
81
|
+
_projectKeyPrefix;
|
|
82
|
+
_emitEvent;
|
|
83
|
+
constructor(options) {
|
|
84
|
+
this.serviceName = options.serviceName;
|
|
85
|
+
this.port = options.port;
|
|
86
|
+
this.workerId = options.workerId;
|
|
87
|
+
this.threadCount = options.threadCount;
|
|
88
|
+
this.mode = options.mode;
|
|
89
|
+
this.serviceType = options.serviceType ?? ServiceType.INTERNAL;
|
|
90
|
+
this._sendIPC = options.sendIPC;
|
|
91
|
+
this._localSend = options.localSend ?? null;
|
|
92
|
+
this._localRequest = options.localRequest ?? null;
|
|
93
|
+
this._ingressConfig = options.ingress ?? {};
|
|
94
|
+
this._ingressApplied = false;
|
|
95
|
+
this._forgeProxy = options.forgeProxy ?? process.env.FORGE_PROXY_URL ?? null;
|
|
96
|
+
this._serviceInstance = null; // set by worker-bootstrap after service.onStart
|
|
97
|
+
// Port map for HTTP-based service calls: { serviceName: port }
|
|
98
|
+
try {
|
|
99
|
+
this._servicePorts = JSON.parse(process.env.FORGE_SERVICE_PORTS || "{}");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this._servicePorts = {};
|
|
103
|
+
}
|
|
104
|
+
this._endpointResolver = null;
|
|
105
|
+
this.router = new Router();
|
|
106
|
+
this.logger = new Logger(this.serviceName, this.workerId);
|
|
107
|
+
this.metrics = new PrometheusMetrics(this.serviceName, this.workerId);
|
|
108
|
+
this._staticMounts = options.staticMounts ?? [];
|
|
109
|
+
this._staticRegistry = new StaticMountRegistry(this._staticMounts);
|
|
110
|
+
this._staticFileServer = new StaticFileServer({
|
|
111
|
+
staticRegistry: this._staticRegistry,
|
|
112
|
+
logger: this.logger,
|
|
113
|
+
metrics: this.metrics,
|
|
114
|
+
});
|
|
115
|
+
this._forgeEndpoints = new ForgeEndpoints({
|
|
116
|
+
serviceName: this.serviceName,
|
|
117
|
+
logger: this.logger,
|
|
118
|
+
getServiceInstance: () => this._serviceInstance,
|
|
119
|
+
});
|
|
120
|
+
this._wsConnections = new Set();
|
|
121
|
+
this._wsPerIpCounts = new Map();
|
|
122
|
+
this._wsMaxPerIp = parseInt(process.env.FORGE_WS_MAX_PER_IP || "100", 10) || 100;
|
|
123
|
+
this._wsPerIpCleanupTimer = setInterval(() => {
|
|
124
|
+
if (this._wsPerIpCounts.size > 50_000) {
|
|
125
|
+
// Nuclear option: map exceeded 50K entries — clear entirely
|
|
126
|
+
this._wsPerIpCounts.clear();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const [ip, count] of this._wsPerIpCounts) {
|
|
130
|
+
if (count <= 0)
|
|
131
|
+
this._wsPerIpCounts.delete(ip);
|
|
132
|
+
}
|
|
133
|
+
}, 60_000);
|
|
134
|
+
this._wsPerIpCleanupTimer.unref();
|
|
135
|
+
this._wsHandlers = new Map();
|
|
136
|
+
this._wsPluginHooks = [];
|
|
137
|
+
/**
|
|
138
|
+
* Direct channel manager — handles MessagePort connections
|
|
139
|
+
* to other services, bypassing the supervisor for all
|
|
140
|
+
* inter-service communication.
|
|
141
|
+
*/
|
|
142
|
+
this.channels = new WorkerChannelManager(this.serviceName, this.workerId);
|
|
143
|
+
this.channels.init(this._sendIPC);
|
|
144
|
+
this._server = null;
|
|
145
|
+
// Compute once whether this service needs an HTTP server
|
|
146
|
+
const isEdge = this.serviceType === ServiceType.EDGE && this.port > 0;
|
|
147
|
+
const isMultiMachine = process.env.FORGE_REGISTRY_MODE && process.env.FORGE_REGISTRY_MODE !== RegistryMode.EMBEDDED;
|
|
148
|
+
let hasRemoteEndpoints = false;
|
|
149
|
+
try {
|
|
150
|
+
const eps = JSON.parse(process.env.FORGE_SERVICE_ENDPOINTS || "{}");
|
|
151
|
+
hasRemoteEndpoints = Object.values(eps).some((ep) => Array.isArray(ep) ? ep.some((e) => e.remote) : ep?.remote);
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
this._needsHttpServer = isEdge || ((isMultiMachine || hasRemoteEndpoints) && this.port > 0);
|
|
155
|
+
this._activeRequests = 0;
|
|
156
|
+
this._messageHandlers = new Map();
|
|
157
|
+
}
|
|
158
|
+
setStaticMounts(mounts = []) {
|
|
159
|
+
this._staticMounts = mounts;
|
|
160
|
+
this._staticRegistry.setMounts(mounts);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Send a message to another service.
|
|
164
|
+
*
|
|
165
|
+
* Resolution order:
|
|
166
|
+
* 1. Local dispatch (colocated service in same process) — zero overhead
|
|
167
|
+
* 2. Direct UDS connection — bypasses supervisor
|
|
168
|
+
* 3. Supervisor IPC fallback — only during startup
|
|
169
|
+
*/
|
|
170
|
+
async send(target, payload) {
|
|
171
|
+
// Try colocated first (direct function call, no serialization)
|
|
172
|
+
if (this._localSend?.(target, payload)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Fall through to UDS / supervisor IPC
|
|
176
|
+
this.channels.send(target, payload);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Broadcast to all workers of a target service.
|
|
180
|
+
* Note: channels.broadcast delivers to all workers including local via UDS.
|
|
181
|
+
* We only use _localSend for colocated services that share this process
|
|
182
|
+
* (no UDS needed), then broadcast to remote workers via channels.
|
|
183
|
+
*/
|
|
184
|
+
async broadcast(target, payload) {
|
|
185
|
+
// Local colocated dispatch (same process, direct call)
|
|
186
|
+
this._localSend?.(target, payload);
|
|
187
|
+
// UDS broadcast to all OTHER workers (channels excludes self)
|
|
188
|
+
this.channels.broadcast(target, payload);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Send a request to another service and await a response.
|
|
192
|
+
*
|
|
193
|
+
* If the target is colocated, this is a direct async function call
|
|
194
|
+
* with zero serialization overhead.
|
|
195
|
+
*/
|
|
196
|
+
async request(target, payload, timeoutMs = 5000) {
|
|
197
|
+
// Try colocated first
|
|
198
|
+
if (this._localRequest) {
|
|
199
|
+
const result = await this._localRequest(target, payload);
|
|
200
|
+
if (result !== NOT_HANDLED)
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
// Fall through to UDS / supervisor IPC
|
|
204
|
+
return this.channels.request(target, payload, timeoutMs);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Start the HTTP server for this service.
|
|
208
|
+
*/
|
|
209
|
+
async startServer() {
|
|
210
|
+
// Auto-apply ingress protection for edge services (when no ForgeProxy)
|
|
211
|
+
if (this.serviceType === ServiceType.EDGE && !this._ingressApplied && !this._forgeProxy) {
|
|
212
|
+
this.ingress = new IngressProtection(this._ingressConfig ?? {});
|
|
213
|
+
this.router.use(this.ingress.middleware());
|
|
214
|
+
this._ingressApplied = true;
|
|
215
|
+
}
|
|
216
|
+
// ── S7: Prometheus metrics endpoint with optional auth ──
|
|
217
|
+
this.router.get("/metrics", (_req, res) => {
|
|
218
|
+
const metricsToken = process.env.FORGE_METRICS_TOKEN;
|
|
219
|
+
if (metricsToken) {
|
|
220
|
+
const auth = _req.headers.authorization;
|
|
221
|
+
const expected = `Bearer ${metricsToken}`;
|
|
222
|
+
if (!auth || auth.length !== expected.length || !timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {
|
|
223
|
+
res.json({ error: "Unauthorized" }, 401);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
228
|
+
res.end(this.metrics.expose());
|
|
229
|
+
});
|
|
230
|
+
// ── Internal forge endpoints (/__forge/*) ──
|
|
231
|
+
this._forgeEndpoints.registerRoutes(this.router);
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
this._server = createServer((req, res) => {
|
|
234
|
+
const start = performance.now();
|
|
235
|
+
const forgeReq = req;
|
|
236
|
+
const forgeRes = res;
|
|
237
|
+
// H8: Validate HTTP method early
|
|
238
|
+
if (!VALID_METHODS.has(req.method)) {
|
|
239
|
+
res.writeHead(405, {
|
|
240
|
+
"Content-Type": "application/json",
|
|
241
|
+
Allow: "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS",
|
|
242
|
+
});
|
|
243
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// H11: Strip internal forge headers from external requests BEFORE body parsing
|
|
247
|
+
// L-SEC-1: Also strip internal signature/timestamp headers for defense-in-depth
|
|
248
|
+
const remoteAddrEarly = req.socket?.remoteAddress ?? "";
|
|
249
|
+
if (!isPrivateNetwork(remoteAddrEarly)) {
|
|
250
|
+
delete req.headers["x-forge-auth"];
|
|
251
|
+
delete req.headers["x-forge-tenant"];
|
|
252
|
+
delete req.headers["x-forge-user"];
|
|
253
|
+
delete req.headers["x-forge-deadline"];
|
|
254
|
+
delete req.headers["x-forge-internal-sig"];
|
|
255
|
+
delete req.headers["x-forge-internal-ts"];
|
|
256
|
+
}
|
|
257
|
+
if (["POST", "PUT", "PATCH"].includes(req.method)) {
|
|
258
|
+
const ct = (req.headers["content-type"] ?? "").toLowerCase();
|
|
259
|
+
if (ct &&
|
|
260
|
+
!ct.includes("application/json") &&
|
|
261
|
+
!ct.includes("text/") &&
|
|
262
|
+
!ct.includes("multipart/form-data") &&
|
|
263
|
+
!ct.includes("application/x-www-form-urlencoded")) {
|
|
264
|
+
res.writeHead(415, { "Content-Type": "application/json" });
|
|
265
|
+
res.end(JSON.stringify({ error: "Unsupported media type" }));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Early Content-Length check — reject oversized requests without reading body
|
|
269
|
+
const declaredLength = req.headers["content-length"];
|
|
270
|
+
if (declaredLength != null) {
|
|
271
|
+
const cl = parseInt(declaredLength, 10);
|
|
272
|
+
if (!Number.isNaN(cl) && cl > MAX_BODY_SIZE) {
|
|
273
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
274
|
+
res.end(JSON.stringify({ error: "Request body too large" }));
|
|
275
|
+
req.destroy();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// P7: Collect chunks in array, single Buffer.concat at end
|
|
280
|
+
const chunks = [];
|
|
281
|
+
let bodySize = 0;
|
|
282
|
+
let rejected = false;
|
|
283
|
+
// S11: Reduced body timeout from 30s to 10s
|
|
284
|
+
const bodyTimeout = setTimeout(() => {
|
|
285
|
+
if (rejected)
|
|
286
|
+
return;
|
|
287
|
+
rejected = true;
|
|
288
|
+
if (!res.headersSent) {
|
|
289
|
+
res.writeHead(408, { "Content-Type": "application/json" });
|
|
290
|
+
res.end(JSON.stringify({ error: "Request Timeout" }));
|
|
291
|
+
}
|
|
292
|
+
req.destroy();
|
|
293
|
+
}, BODY_TIMEOUT_MS);
|
|
294
|
+
req.on("data", (chunk) => {
|
|
295
|
+
if (rejected)
|
|
296
|
+
return;
|
|
297
|
+
bodySize += chunk.length;
|
|
298
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
299
|
+
rejected = true;
|
|
300
|
+
clearTimeout(bodyTimeout);
|
|
301
|
+
if (!res.headersSent) {
|
|
302
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
303
|
+
res.end(JSON.stringify({ error: "Request body too large" }));
|
|
304
|
+
}
|
|
305
|
+
req.destroy();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
chunks.push(chunk);
|
|
309
|
+
});
|
|
310
|
+
req.on("end", () => {
|
|
311
|
+
clearTimeout(bodyTimeout);
|
|
312
|
+
if (rejected)
|
|
313
|
+
return;
|
|
314
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
315
|
+
// Only JSON.parse when content-type is application/json or absent (backward compat)
|
|
316
|
+
if (!ct || ct.includes("application/json")) {
|
|
317
|
+
try {
|
|
318
|
+
forgeReq.body = body
|
|
319
|
+
? JSON.parse(body, (key, value) => {
|
|
320
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype")
|
|
321
|
+
return undefined;
|
|
322
|
+
return value;
|
|
323
|
+
})
|
|
324
|
+
: {};
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// For internal forge endpoints, return 400 on malformed JSON
|
|
328
|
+
const urlPath = new URL(req.url, "http://localhost").pathname;
|
|
329
|
+
if (urlPath.startsWith("/__forge/")) {
|
|
330
|
+
if (!res.headersSent) {
|
|
331
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
332
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Fall back to raw string for user routes
|
|
337
|
+
forgeReq.body = body;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
forgeReq.body = body;
|
|
342
|
+
}
|
|
343
|
+
this._handleRequest(forgeReq, forgeRes, start);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
forgeReq.body = {};
|
|
348
|
+
this._handleRequest(forgeReq, forgeRes, start);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
// ── Client error handling ──
|
|
352
|
+
this._server.on("clientError", (err, socket) => {
|
|
353
|
+
this.logger.error("Client error", { error: err.message, code: err.code });
|
|
354
|
+
if (socket && !socket.destroyed) {
|
|
355
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
356
|
+
socket.destroy();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
// ── WebSocket upgrade handling ──
|
|
360
|
+
this._server.on("upgrade", (req, socket, head) => {
|
|
361
|
+
this._handleWsUpgrade(req, socket, head).catch((err) => {
|
|
362
|
+
this.logger.error("WebSocket upgrade failed", { error: err.message, path: req.url });
|
|
363
|
+
if (!socket.destroyed) {
|
|
364
|
+
try {
|
|
365
|
+
socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
|
|
366
|
+
}
|
|
367
|
+
catch { }
|
|
368
|
+
socket.destroy();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
// Set request timeouts to prevent slowloris attacks
|
|
373
|
+
this._server.timeout = 30000;
|
|
374
|
+
this._server.requestTimeout = 30000;
|
|
375
|
+
this._server.headersTimeout = 10000;
|
|
376
|
+
this._server.listen(this.port, () => {
|
|
377
|
+
// Reduce startup noise in multi-worker mode.
|
|
378
|
+
if (this.workerId === 0) {
|
|
379
|
+
this.logger.info(`Listening on port ${this.port}`);
|
|
380
|
+
}
|
|
381
|
+
// Auto-register with ForgeProxy if configured
|
|
382
|
+
if (this._forgeProxy) {
|
|
383
|
+
this._registerWithForgeProxy().catch((err) => {
|
|
384
|
+
this.logger.warn(`ForgeProxy registration failed: ${err.message}`);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
resolve();
|
|
388
|
+
});
|
|
389
|
+
this._server.on("error", (err) => {
|
|
390
|
+
// Detect fatal bind errors that should not trigger restart loops
|
|
391
|
+
const isFatalBindError = err.code === "EPERM" || err.code === "EACCES" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE";
|
|
392
|
+
if (isFatalBindError) {
|
|
393
|
+
// Notify supervisor this is a fatal error — don't restart
|
|
394
|
+
if (this._sendIPC) {
|
|
395
|
+
this._sendIPC({
|
|
396
|
+
type: "forge:fatal-error",
|
|
397
|
+
error: err.code,
|
|
398
|
+
message: err.message,
|
|
399
|
+
port: this.port,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
// Augment error with clear message using constant map
|
|
403
|
+
const messageGenerator = BIND_ERROR_MESSAGES[err.code];
|
|
404
|
+
err.fatalBindError = true;
|
|
405
|
+
err.userMessage = messageGenerator
|
|
406
|
+
? messageGenerator(this.port)
|
|
407
|
+
: `Failed to bind to port ${this.port}: ${err.message}`;
|
|
408
|
+
}
|
|
409
|
+
reject(err);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Route an incoming HTTP request.
|
|
415
|
+
* Creates a RequestContext that flows through the entire call chain.
|
|
416
|
+
*/
|
|
417
|
+
_handleRequest(req, res, start) {
|
|
418
|
+
this._activeRequests++;
|
|
419
|
+
res._forgeTracked = true;
|
|
420
|
+
res.once("close", () => {
|
|
421
|
+
if (res._forgeTracked) {
|
|
422
|
+
this._activeRequests--;
|
|
423
|
+
res._forgeTracked = false;
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
// Convenience methods on response
|
|
427
|
+
res.json = (data, statusCode = 200) => {
|
|
428
|
+
if (res.headersSent)
|
|
429
|
+
return;
|
|
430
|
+
let body;
|
|
431
|
+
try {
|
|
432
|
+
body = JSON.stringify(data ?? null);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
body = JSON.stringify({ error: "Response serialization failed" });
|
|
436
|
+
statusCode = 500;
|
|
437
|
+
}
|
|
438
|
+
const len = Buffer.byteLength(body);
|
|
439
|
+
res.writeHead(statusCode, { "Content-Type": "application/json", "Content-Length": len });
|
|
440
|
+
res.end(body);
|
|
441
|
+
};
|
|
442
|
+
res.status = (code) => {
|
|
443
|
+
res.statusCode = code;
|
|
444
|
+
return res;
|
|
445
|
+
};
|
|
446
|
+
// HTTP-M5: Streaming response support — sets Transfer-Encoding: chunked
|
|
447
|
+
// Handlers can call res.stream() then use res.write() / res.end() for large payloads.
|
|
448
|
+
res.stream = (statusCode = 200, headers = {}) => {
|
|
449
|
+
if (!res.headersSent) {
|
|
450
|
+
res.writeHead(statusCode, {
|
|
451
|
+
"Transfer-Encoding": "chunked",
|
|
452
|
+
...headers,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return res;
|
|
456
|
+
};
|
|
457
|
+
// ── Build RequestContext from incoming headers ──
|
|
458
|
+
// Note: internal forge headers already stripped in createServer callback (H11)
|
|
459
|
+
const remoteAddr = req.socket?.remoteAddress ?? "";
|
|
460
|
+
const rctx = RequestContext.fromPropagation(req.headers);
|
|
461
|
+
rctx.service = this.serviceName;
|
|
462
|
+
// Security headers
|
|
463
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
464
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
465
|
+
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
|
466
|
+
// HSTS should only be sent over HTTPS (RFC 6797 § 7.2)
|
|
467
|
+
// Only trust x-forwarded-proto from trusted proxies (FORGE_TRUSTED_PROXIES), not just any private network
|
|
468
|
+
if (req.socket?.encrypted ||
|
|
469
|
+
(isTrustedProxy(remoteAddr) && req.headers["x-forwarded-proto"] === "https")) {
|
|
470
|
+
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
471
|
+
}
|
|
472
|
+
// Always set the correlation ID response header
|
|
473
|
+
res.setHeader("x-correlation-id", rctx.correlationId);
|
|
474
|
+
res.setHeader("x-trace-id", rctx.traceId);
|
|
475
|
+
// In host mode, propagate project ID to the request context
|
|
476
|
+
rctx._projectId ??= this._projectId;
|
|
477
|
+
// Attach context to request for handlers to use
|
|
478
|
+
req.ctx = rctx;
|
|
479
|
+
req.auth = rctx.auth;
|
|
480
|
+
req.tenantId = rctx.tenantId;
|
|
481
|
+
req.projectId = rctx.projectId;
|
|
482
|
+
req.userId = rctx.userId;
|
|
483
|
+
req.correlationId = rctx.correlationId;
|
|
484
|
+
this.metrics.httpRequestStart();
|
|
485
|
+
const matched = this.router.match(req.method, req.url);
|
|
486
|
+
if (!matched) {
|
|
487
|
+
this._staticFileServer
|
|
488
|
+
.tryServeStatic(req, res, start)
|
|
489
|
+
.then((served) => {
|
|
490
|
+
if (served)
|
|
491
|
+
return;
|
|
492
|
+
this.metrics.httpRequestEnd((performance.now() - start) / 1000, {
|
|
493
|
+
method: req.method,
|
|
494
|
+
route: "unmatched",
|
|
495
|
+
status: 404,
|
|
496
|
+
});
|
|
497
|
+
res.json({ error: "Not Found" }, 404);
|
|
498
|
+
})
|
|
499
|
+
.catch((err) => {
|
|
500
|
+
this.logger.error("Static serve failure", {
|
|
501
|
+
error: err.message,
|
|
502
|
+
url: req.url,
|
|
503
|
+
});
|
|
504
|
+
if (!res.headersSent) {
|
|
505
|
+
res.json({ error: "Internal server error" }, 500);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
req.params = matched.params;
|
|
511
|
+
req.query = matched.query;
|
|
512
|
+
// HEAD responses MUST NOT include a message body (RFC 7231 § 4.3.2)
|
|
513
|
+
if (req.method === "HEAD") {
|
|
514
|
+
let headContentLength = 0;
|
|
515
|
+
const origEnd = res.end.bind(res);
|
|
516
|
+
res.end = (_chunk, encoding, callback) => {
|
|
517
|
+
// Account for any final chunk passed to end()
|
|
518
|
+
if (_chunk != null) {
|
|
519
|
+
headContentLength += Buffer.byteLength(_chunk, typeof encoding === "string" ? encoding : undefined);
|
|
520
|
+
}
|
|
521
|
+
if (!res.headersSent && headContentLength > 0 && !res.getHeader("content-length")) {
|
|
522
|
+
res.setHeader("Content-Length", headContentLength);
|
|
523
|
+
}
|
|
524
|
+
return origEnd(null, encoding, callback);
|
|
525
|
+
};
|
|
526
|
+
res.write = (chunk, encodingOrCb, cb) => {
|
|
527
|
+
if (chunk != null) {
|
|
528
|
+
headContentLength += Buffer.byteLength(chunk, typeof encodingOrCb === "string" ? encodingOrCb : undefined);
|
|
529
|
+
}
|
|
530
|
+
if (typeof encodingOrCb === "function")
|
|
531
|
+
encodingOrCb(null);
|
|
532
|
+
else if (typeof cb === "function")
|
|
533
|
+
cb(null);
|
|
534
|
+
return true;
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
// Execute middleware chain + handler INSIDE RequestContext
|
|
538
|
+
const handlers = [...this.router.middleware, matched.handler];
|
|
539
|
+
let i = 0;
|
|
540
|
+
const next = (err) => {
|
|
541
|
+
if (err) {
|
|
542
|
+
this.logger.error("Request error", {
|
|
543
|
+
error: err.message,
|
|
544
|
+
url: req.url,
|
|
545
|
+
...rctx.toLogFields(),
|
|
546
|
+
});
|
|
547
|
+
if (!res.headersSent) {
|
|
548
|
+
const status = err.statusCode || 500;
|
|
549
|
+
const message = status >= 500 ? "Internal server error" : err.message || "Internal error";
|
|
550
|
+
res.json({ error: message }, status);
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const handler = handlers[i++];
|
|
555
|
+
if (!handler)
|
|
556
|
+
return;
|
|
557
|
+
let stepCalled = false;
|
|
558
|
+
const stepNext = (stepErr) => {
|
|
559
|
+
if (stepCalled) {
|
|
560
|
+
if (process.env.NODE_ENV !== "production") {
|
|
561
|
+
process.stderr.write(`${JSON.stringify({
|
|
562
|
+
timestamp: new Date().toISOString(),
|
|
563
|
+
level: "warn",
|
|
564
|
+
message: `Middleware called next() more than once (handler index ${i - 1}). This is likely a bug.`,
|
|
565
|
+
})}\n`);
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
stepCalled = true;
|
|
570
|
+
next(stepErr);
|
|
571
|
+
};
|
|
572
|
+
try {
|
|
573
|
+
const result = handler(req, res, stepNext);
|
|
574
|
+
if (result && typeof result.catch === "function") {
|
|
575
|
+
result.catch(stepNext);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
stepNext(e);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
// Run entire handler chain within the RequestContext.
|
|
583
|
+
// AsyncLocalStorage.run() properly propagates through the entire async chain,
|
|
584
|
+
// including middleware that does `await next()` followed by post-next work.
|
|
585
|
+
RequestContext.run(rctx, () => next());
|
|
586
|
+
// Track metrics on response finish
|
|
587
|
+
const onFinish = () => {
|
|
588
|
+
const duration = (performance.now() - start) / 1000;
|
|
589
|
+
const labels = {
|
|
590
|
+
method: req.method,
|
|
591
|
+
route_pattern: matched.pattern,
|
|
592
|
+
status: res.statusCode,
|
|
593
|
+
};
|
|
594
|
+
this.metrics.httpRequestEnd(duration, labels);
|
|
595
|
+
res.removeListener("finish", onFinish);
|
|
596
|
+
};
|
|
597
|
+
res.on("finish", onFinish);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Wire message/request handlers to both the direct channel manager
|
|
601
|
+
* AND the supervisor fallback IPC path.
|
|
602
|
+
*/
|
|
603
|
+
_wireMessageHandlers() {
|
|
604
|
+
// Direct channel path (primary — used once ports are established)
|
|
605
|
+
this.channels.onMessage = (from, payload) => {
|
|
606
|
+
if (this._onMessage)
|
|
607
|
+
this._onMessage(from, payload);
|
|
608
|
+
};
|
|
609
|
+
this.channels.onRequest = (from, payload) => {
|
|
610
|
+
if (this._onRequest)
|
|
611
|
+
return this._onRequest(from, payload);
|
|
612
|
+
return null;
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Handle an incoming IPC message from the supervisor.
|
|
617
|
+
* This is the FALLBACK path — only used during startup before
|
|
618
|
+
* direct MessagePorts are established, and for supervisor-level
|
|
619
|
+
* commands (health checks, shutdown, etc.)
|
|
620
|
+
*/
|
|
621
|
+
_handleIPCMessage(msg) {
|
|
622
|
+
if (!msg || !msg.type)
|
|
623
|
+
return;
|
|
624
|
+
// Supervisor-level messages
|
|
625
|
+
if (msg.type === "forge:health-check") {
|
|
626
|
+
this._sendIPC({
|
|
627
|
+
type: "forge:health-response",
|
|
628
|
+
timestamp: msg.timestamp,
|
|
629
|
+
uptime: process.uptime(),
|
|
630
|
+
memory: process.memoryUsage(),
|
|
631
|
+
pid: process.pid,
|
|
632
|
+
});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Fallback message routing (before direct ports are ready)
|
|
636
|
+
if (msg.type === "forge:message" && this._onMessage) {
|
|
637
|
+
this._onMessage(msg.from, msg.payload);
|
|
638
|
+
}
|
|
639
|
+
if (msg.type === "forge:request" && this._onRequest) {
|
|
640
|
+
Promise.resolve(this._onRequest(msg.from, msg.payload))
|
|
641
|
+
.then((result) => {
|
|
642
|
+
this._sendIPC({
|
|
643
|
+
type: "forge:response",
|
|
644
|
+
requestId: msg.requestId,
|
|
645
|
+
payload: result,
|
|
646
|
+
error: null,
|
|
647
|
+
});
|
|
648
|
+
})
|
|
649
|
+
.catch((err) => {
|
|
650
|
+
this._sendIPC({
|
|
651
|
+
type: "forge:response",
|
|
652
|
+
requestId: msg.requestId,
|
|
653
|
+
payload: null,
|
|
654
|
+
error: err.message,
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (msg.type === "forge:response") {
|
|
659
|
+
const handler = this._messageHandlers.get(msg.requestId);
|
|
660
|
+
if (handler)
|
|
661
|
+
handler(msg);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
_isMethodAllowed(method) {
|
|
665
|
+
return this._forgeEndpoints.isMethodAllowed(method);
|
|
666
|
+
}
|
|
667
|
+
async _executeForgeEndpoint(res, rctx, handler, method, { args, logPrefix }) {
|
|
668
|
+
return this._forgeEndpoints.execute(res, rctx, handler, method, { args, logPrefix });
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Gracefully shut down.
|
|
672
|
+
*/
|
|
673
|
+
async stop() {
|
|
674
|
+
// M-13: Stop IngressProtection timers if active
|
|
675
|
+
if (this.ingress) {
|
|
676
|
+
this.ingress.stop();
|
|
677
|
+
}
|
|
678
|
+
// Close direct channels
|
|
679
|
+
this.channels.destroy();
|
|
680
|
+
// Close all active WebSocket connections and clean up timers
|
|
681
|
+
for (const ws of this._wsConnections) {
|
|
682
|
+
if (ws._closeTimer) {
|
|
683
|
+
clearTimeout(ws._closeTimer);
|
|
684
|
+
ws._closeTimer = null;
|
|
685
|
+
}
|
|
686
|
+
if (ws._pingTimer) {
|
|
687
|
+
clearInterval(ws._pingTimer);
|
|
688
|
+
ws._pingTimer = null;
|
|
689
|
+
}
|
|
690
|
+
if (!ws._closed) {
|
|
691
|
+
ws._closed = true;
|
|
692
|
+
}
|
|
693
|
+
if (!ws.socket.destroyed) {
|
|
694
|
+
ws.socket.destroy();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
this._wsConnections.clear();
|
|
698
|
+
if (this._server) {
|
|
699
|
+
// Deregister from ForgeProxy on shutdown
|
|
700
|
+
if (this._forgeProxy) {
|
|
701
|
+
try {
|
|
702
|
+
const data = JSON.stringify({
|
|
703
|
+
service: this.serviceName,
|
|
704
|
+
host: this._getHost(),
|
|
705
|
+
port: this.port,
|
|
706
|
+
});
|
|
707
|
+
const url = new URL(`${this._forgeProxy}/deregister`);
|
|
708
|
+
await fetch(url, { method: "POST", body: data, headers: { "Content-Type": "application/json" } }).catch(() => { });
|
|
709
|
+
}
|
|
710
|
+
catch { }
|
|
711
|
+
}
|
|
712
|
+
return new Promise((resolve) => {
|
|
713
|
+
let drainInterval = null;
|
|
714
|
+
// Stop accepting new connections and terminate idle keep-alive sockets
|
|
715
|
+
this._server.close(() => {
|
|
716
|
+
if (drainInterval)
|
|
717
|
+
clearInterval(drainInterval);
|
|
718
|
+
resolve();
|
|
719
|
+
});
|
|
720
|
+
// Force-close idle keep-alive connections (Node 18.2+)
|
|
721
|
+
if (typeof this._server.closeAllConnections === "function") {
|
|
722
|
+
// Wait briefly for in-flight requests, then force-close
|
|
723
|
+
const drainStart = Date.now();
|
|
724
|
+
drainInterval = setInterval(() => {
|
|
725
|
+
if (this._activeRequests <= 0 || Date.now() - drainStart >= 5000) {
|
|
726
|
+
clearInterval(drainInterval);
|
|
727
|
+
drainInterval = null;
|
|
728
|
+
this._server.closeAllConnections();
|
|
729
|
+
}
|
|
730
|
+
}, 50);
|
|
731
|
+
drainInterval.unref();
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// ── WebSocket Support ──
|
|
737
|
+
/**
|
|
738
|
+
* Register a WebSocket handler for a path.
|
|
739
|
+
*
|
|
740
|
+
* ctx.ws('/ws', (socket, req) => {
|
|
741
|
+
* socket.on('message', (data) => {
|
|
742
|
+
* const msg = JSON.parse(data);
|
|
743
|
+
* // req.ctx has the RequestContext with auth, correlationId
|
|
744
|
+
* socket.send(JSON.stringify({ echo: msg }));
|
|
745
|
+
* });
|
|
746
|
+
* });
|
|
747
|
+
*/
|
|
748
|
+
ws(path, handlerOrOptions, maybeHandler) {
|
|
749
|
+
let handler;
|
|
750
|
+
let options;
|
|
751
|
+
if (typeof handlerOrOptions === "function") {
|
|
752
|
+
handler = handlerOrOptions;
|
|
753
|
+
options = {};
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
options = handlerOrOptions ?? {};
|
|
757
|
+
handler = maybeHandler;
|
|
758
|
+
}
|
|
759
|
+
// S8: WebSocket endpoints require auth by default
|
|
760
|
+
if (options.auth === undefined) {
|
|
761
|
+
options.auth = "required";
|
|
762
|
+
}
|
|
763
|
+
this._wsHandlers.set(path, { handler, options });
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Write a short HTTP response over a raw upgrade socket and close it.
|
|
767
|
+
*/
|
|
768
|
+
_writeWsUpgradeError(socket, statusCode, reason, headers = {}) {
|
|
769
|
+
const statusText = String(reason || "Upgrade Rejected").replace(/[\r\n]/g, " ");
|
|
770
|
+
let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`;
|
|
771
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
772
|
+
const safeKey = String(key).replace(/[\r\n:]/g, "");
|
|
773
|
+
const safeVal = String(value).replace(/[\r\n]/g, " ");
|
|
774
|
+
response += `${safeKey}: ${safeVal}\r\n`;
|
|
775
|
+
}
|
|
776
|
+
response += "\r\n";
|
|
777
|
+
socket.write(response);
|
|
778
|
+
socket.destroy();
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Run websocket plugin hooks for a lifecycle stage.
|
|
782
|
+
*/
|
|
783
|
+
async _runWsPluginHooks(stage, payload) {
|
|
784
|
+
const methodByStage = {
|
|
785
|
+
upgrade: "onWsUpgrade",
|
|
786
|
+
connect: "onWsConnect",
|
|
787
|
+
message: "onWsMessage",
|
|
788
|
+
close: "onWsClose",
|
|
789
|
+
};
|
|
790
|
+
const method = methodByStage[stage];
|
|
791
|
+
if (!method)
|
|
792
|
+
return [];
|
|
793
|
+
const results = [];
|
|
794
|
+
for (const hook of this._wsPluginHooks) {
|
|
795
|
+
const fn = hook?.[method];
|
|
796
|
+
if (typeof fn !== "function")
|
|
797
|
+
continue;
|
|
798
|
+
try {
|
|
799
|
+
const result = await fn(payload);
|
|
800
|
+
results.push({ plugin: hook.name, ok: true, result });
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
this.logger.warn(`WebSocket plugin hook failed`, {
|
|
804
|
+
plugin: hook.name,
|
|
805
|
+
stage,
|
|
806
|
+
error: err.message,
|
|
807
|
+
});
|
|
808
|
+
results.push({ plugin: hook.name, ok: false, error: err });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return results;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Handle HTTP→WebSocket upgrade.
|
|
815
|
+
* Implements RFC 6455 handshake without external dependencies.
|
|
816
|
+
*/
|
|
817
|
+
async _handleWsUpgrade(req, socket, head) {
|
|
818
|
+
// H-SEC-2: Per-IP WebSocket connection limit to prevent file descriptor exhaustion
|
|
819
|
+
// HTTP-M4: Use X-Forwarded-For from trusted proxies for accurate per-IP tracking
|
|
820
|
+
let wsRemoteAddr = req.socket?.remoteAddress ?? "unknown";
|
|
821
|
+
const rawAddr = req.socket?.remoteAddress;
|
|
822
|
+
if (rawAddr && (process.env.FORGE_TRUSTED_PROXIES ? isTrustedProxy(rawAddr) : isPrivateNetwork(rawAddr))) {
|
|
823
|
+
const xff = req.headers["x-forwarded-for"];
|
|
824
|
+
if (xff) {
|
|
825
|
+
const clientIp = (Array.isArray(xff) ? xff[0] : xff).split(",")[0]?.trim();
|
|
826
|
+
if (clientIp)
|
|
827
|
+
wsRemoteAddr = clientIp;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const currentCount = this._wsPerIpCounts.get(wsRemoteAddr) ?? 0;
|
|
831
|
+
if (currentCount >= this._wsMaxPerIp) {
|
|
832
|
+
socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
|
|
833
|
+
socket.destroy();
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
837
|
+
const entry = this._wsHandlers.get(url.pathname);
|
|
838
|
+
if (!entry) {
|
|
839
|
+
const proxied = await this._proxyWsUpgradeToDevServer(req, socket, head, url);
|
|
840
|
+
if (proxied)
|
|
841
|
+
return;
|
|
842
|
+
socket.destroy();
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const handler = typeof entry === "function" ? entry : entry.handler;
|
|
846
|
+
const wsOptions = typeof entry === "function" ? {} : (entry.options ?? {});
|
|
847
|
+
// S3: WebSocket Origin Validation — default deny
|
|
848
|
+
const allowedOrigins = wsOptions.allowedOrigins;
|
|
849
|
+
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
850
|
+
// Explicit allowlist: check if '*' is included (opt-out of security)
|
|
851
|
+
if (!allowedOrigins.includes("*")) {
|
|
852
|
+
const origin = req.headers.origin;
|
|
853
|
+
if (!origin || !allowedOrigins.includes(origin)) {
|
|
854
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
855
|
+
socket.destroy();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
// No allowedOrigins configured — default deny (reject cross-origin)
|
|
862
|
+
const origin = req.headers.origin;
|
|
863
|
+
if (origin) {
|
|
864
|
+
// Compare origin host to request host
|
|
865
|
+
try {
|
|
866
|
+
const originHost = new URL(origin).host;
|
|
867
|
+
const reqHost = req.headers.host?.split(":")[0];
|
|
868
|
+
if (originHost !== reqHost && originHost !== req.headers.host) {
|
|
869
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
870
|
+
socket.destroy();
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
876
|
+
socket.destroy();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
// Strip internal forge headers from external WebSocket upgrades
|
|
882
|
+
let headers = req.headers;
|
|
883
|
+
const remoteAddr = req.socket?.remoteAddress ?? "";
|
|
884
|
+
if (!isPrivateNetwork(remoteAddr)) {
|
|
885
|
+
headers = { ...req.headers };
|
|
886
|
+
delete headers["x-forge-auth"];
|
|
887
|
+
delete headers["x-forge-tenant"];
|
|
888
|
+
delete headers["x-forge-user"];
|
|
889
|
+
delete headers["x-forge-deadline"];
|
|
890
|
+
}
|
|
891
|
+
const rctx = RequestContext.fromPropagation(headers);
|
|
892
|
+
rctx.service = this.serviceName;
|
|
893
|
+
rctx.method = `ws:${url.pathname}`;
|
|
894
|
+
req.ctx = rctx;
|
|
895
|
+
req.auth = rctx.auth;
|
|
896
|
+
req.tenantId = rctx.tenantId;
|
|
897
|
+
// S8: Enforce auth by default (auth defaults to 'required' from ws() method)
|
|
898
|
+
if (wsOptions.auth === "required") {
|
|
899
|
+
if (!rctx.auth) {
|
|
900
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
901
|
+
socket.destroy();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// M-3 Security: When JWT_SECRET is set, verify the JWT signature — not just auth presence
|
|
905
|
+
if (process.env.JWT_SECRET) {
|
|
906
|
+
const rawToken = headers["x-forge-auth"] ??
|
|
907
|
+
headers.authorization ??
|
|
908
|
+
url.searchParams.get("token");
|
|
909
|
+
const verified = rawToken ? verifyJwt(rawToken) : null;
|
|
910
|
+
if (!verified) {
|
|
911
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
912
|
+
socket.destroy();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const wsMeta = {
|
|
918
|
+
service: this.serviceName,
|
|
919
|
+
workerId: this.workerId,
|
|
920
|
+
path: url.pathname,
|
|
921
|
+
options: wsOptions,
|
|
922
|
+
requestContext: rctx,
|
|
923
|
+
};
|
|
924
|
+
const upgradeResults = await this._runWsPluginHooks("upgrade", {
|
|
925
|
+
req,
|
|
926
|
+
socket,
|
|
927
|
+
meta: wsMeta,
|
|
928
|
+
});
|
|
929
|
+
for (const res of upgradeResults) {
|
|
930
|
+
if (!res.ok) {
|
|
931
|
+
this._writeWsUpgradeError(socket, 503, "Service Unavailable");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const decision = res.result;
|
|
935
|
+
if (decision === false || (decision && decision.allow === false)) {
|
|
936
|
+
this._writeWsUpgradeError(socket, decision && typeof decision === "object" ? (decision.statusCode ?? 403) : 403, decision && typeof decision === "object" ? (decision.reason ?? "Forbidden") : "Forbidden", decision && typeof decision === "object" ? (decision.headers ?? {}) : {});
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// M6: Validate Sec-WebSocket-Version (must be 13 per RFC 6455)
|
|
941
|
+
if (req.headers["sec-websocket-version"] !== "13") {
|
|
942
|
+
socket.write("HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\n\r\n");
|
|
943
|
+
socket.destroy();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
// RFC 6455 handshake
|
|
947
|
+
const key = req.headers["sec-websocket-key"];
|
|
948
|
+
if (!key) {
|
|
949
|
+
socket.destroy();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-5AB4085B9976`).digest("base64");
|
|
953
|
+
const safeCorrelationId = (rctx.correlationId || "").replace(/[\r\n]/g, "");
|
|
954
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
955
|
+
"Upgrade: websocket\r\n" +
|
|
956
|
+
"Connection: Upgrade\r\n" +
|
|
957
|
+
`Sec-WebSocket-Accept: ${accept}\r\n` +
|
|
958
|
+
`X-Correlation-ID: ${safeCorrelationId}\r\n` +
|
|
959
|
+
"\r\n");
|
|
960
|
+
// Create a minimal WebSocket wrapper
|
|
961
|
+
const ws = new ForgeWebSocket(socket, rctx, wsOptions);
|
|
962
|
+
// H1: If _head buffer has data, prepend it so the first frame isn't lost
|
|
963
|
+
if (head && head.length > 0) {
|
|
964
|
+
ws._onData(head);
|
|
965
|
+
}
|
|
966
|
+
this._wsConnections.add(ws);
|
|
967
|
+
this.metrics.wsConnectionOpen();
|
|
968
|
+
// H-SEC-2: Track per-IP count
|
|
969
|
+
this._wsPerIpCounts.set(wsRemoteAddr, (this._wsPerIpCounts.get(wsRemoteAddr) ?? 0) + 1);
|
|
970
|
+
ws.on("close", () => {
|
|
971
|
+
this._wsConnections.delete(ws);
|
|
972
|
+
this.metrics.wsConnectionClose();
|
|
973
|
+
// H-SEC-2: Decrement per-IP count
|
|
974
|
+
const count = (this._wsPerIpCounts.get(wsRemoteAddr) ?? 1) - 1;
|
|
975
|
+
if (count <= 0) {
|
|
976
|
+
this._wsPerIpCounts.delete(wsRemoteAddr);
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
this._wsPerIpCounts.set(wsRemoteAddr, count);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
ws.on("message", () => {
|
|
983
|
+
this.metrics.wsMessage("inbound");
|
|
984
|
+
});
|
|
985
|
+
ws.on("message", (message) => {
|
|
986
|
+
void this._runWsPluginHooks("message", {
|
|
987
|
+
ws,
|
|
988
|
+
req,
|
|
989
|
+
data: message,
|
|
990
|
+
meta: wsMeta,
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
ws.on("close", (code, reason) => {
|
|
994
|
+
void this._runWsPluginHooks("close", {
|
|
995
|
+
ws,
|
|
996
|
+
req,
|
|
997
|
+
code,
|
|
998
|
+
reason,
|
|
999
|
+
meta: wsMeta,
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
const connectResults = await this._runWsPluginHooks("connect", {
|
|
1003
|
+
ws,
|
|
1004
|
+
req,
|
|
1005
|
+
meta: wsMeta,
|
|
1006
|
+
});
|
|
1007
|
+
for (const res of connectResults) {
|
|
1008
|
+
if (!res.ok) {
|
|
1009
|
+
try {
|
|
1010
|
+
ws.close?.(1011, "Internal plugin error");
|
|
1011
|
+
}
|
|
1012
|
+
catch { }
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const decision = res.result;
|
|
1016
|
+
if (decision === false || (decision && decision.allow === false)) {
|
|
1017
|
+
try {
|
|
1018
|
+
ws.close?.(decision && typeof decision === "object" ? (decision.closeCode ?? 1008) : 1008, decision && typeof decision === "object"
|
|
1019
|
+
? (decision.closeReason ?? "Policy violation")
|
|
1020
|
+
: "Policy violation");
|
|
1021
|
+
}
|
|
1022
|
+
catch { }
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Run handler within RequestContext
|
|
1027
|
+
RequestContext.run(rctx, () => handler(ws, req));
|
|
1028
|
+
}
|
|
1029
|
+
async _proxyWsUpgradeToDevServer(req, socket, head, url) {
|
|
1030
|
+
const mount = this._staticFileServer.resolveMountForRequest(req, url.pathname);
|
|
1031
|
+
if (!mount?.devProxyTarget)
|
|
1032
|
+
return false;
|
|
1033
|
+
let target;
|
|
1034
|
+
try {
|
|
1035
|
+
target = new URL(mount.devProxyTarget);
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
this.logger.warn("Invalid frontend dev proxy target for websocket upgrade", {
|
|
1039
|
+
siteId: mount.siteId,
|
|
1040
|
+
proxyTarget: mount.devProxyTarget,
|
|
1041
|
+
error: err.message,
|
|
1042
|
+
});
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
const isTls = target.protocol === "https:" || target.protocol === "wss:";
|
|
1046
|
+
const isTcp = target.protocol === "http:" || target.protocol === "ws:";
|
|
1047
|
+
if (!isTls && !isTcp) {
|
|
1048
|
+
this.logger.warn("Unsupported websocket dev proxy protocol", {
|
|
1049
|
+
siteId: mount.siteId,
|
|
1050
|
+
proxyTarget: mount.devProxyTarget,
|
|
1051
|
+
protocol: target.protocol,
|
|
1052
|
+
});
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
const targetPort = target.port ? Number.parseInt(target.port, 10) : isTls ? 443 : 80;
|
|
1056
|
+
const upstream = isTls
|
|
1057
|
+
? tls.connect({ host: target.hostname, port: targetPort })
|
|
1058
|
+
: net.connect({ host: target.hostname, port: targetPort });
|
|
1059
|
+
const forwardedHeaders = [];
|
|
1060
|
+
const rawHeaders = Array.isArray(req.rawHeaders) ? req.rawHeaders : [];
|
|
1061
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
1062
|
+
const key = rawHeaders[i];
|
|
1063
|
+
const value = rawHeaders[i + 1];
|
|
1064
|
+
const keyLower = String(key ?? "").toLowerCase();
|
|
1065
|
+
if (!keyLower)
|
|
1066
|
+
continue;
|
|
1067
|
+
if (keyLower === "host")
|
|
1068
|
+
continue;
|
|
1069
|
+
if (keyLower.startsWith("x-forge-"))
|
|
1070
|
+
continue;
|
|
1071
|
+
forwardedHeaders.push([String(key), String(value ?? "")]);
|
|
1072
|
+
}
|
|
1073
|
+
forwardedHeaders.push(["Host", target.host]);
|
|
1074
|
+
if (req.headers.host) {
|
|
1075
|
+
forwardedHeaders.push(["X-Forwarded-Host", String(req.headers.host)]);
|
|
1076
|
+
}
|
|
1077
|
+
forwardedHeaders.push(["X-Forwarded-Proto", "http"]);
|
|
1078
|
+
const requestLine = `${req.method} ${url.pathname}${url.search} HTTP/${req.httpVersion}\r\n`;
|
|
1079
|
+
const headerLines = forwardedHeaders.map(([k, v]) => `${k}: ${v}`).join("\r\n");
|
|
1080
|
+
const upgradePayload = `${requestLine}${headerLines}\r\n\r\n`;
|
|
1081
|
+
const connectEvent = isTls ? "secureConnect" : "connect";
|
|
1082
|
+
return await new Promise((resolve) => {
|
|
1083
|
+
let settled = false;
|
|
1084
|
+
const finish = (value) => {
|
|
1085
|
+
if (settled)
|
|
1086
|
+
return;
|
|
1087
|
+
settled = true;
|
|
1088
|
+
resolve(value);
|
|
1089
|
+
};
|
|
1090
|
+
const fail = (statusCode, statusText, err = null) => {
|
|
1091
|
+
if (err) {
|
|
1092
|
+
this.logger.warn("Frontend websocket dev proxy failed", {
|
|
1093
|
+
siteId: mount.siteId,
|
|
1094
|
+
proxyTarget: mount.devProxyTarget,
|
|
1095
|
+
error: err.message,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (!socket.destroyed) {
|
|
1099
|
+
try {
|
|
1100
|
+
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\n\r\n`);
|
|
1101
|
+
}
|
|
1102
|
+
catch { }
|
|
1103
|
+
socket.destroy();
|
|
1104
|
+
}
|
|
1105
|
+
if (!upstream.destroyed)
|
|
1106
|
+
upstream.destroy();
|
|
1107
|
+
finish(true);
|
|
1108
|
+
};
|
|
1109
|
+
socket.once("error", (err) => fail(502, "Bad Gateway", err));
|
|
1110
|
+
socket.once("close", () => {
|
|
1111
|
+
if (!upstream.destroyed)
|
|
1112
|
+
upstream.destroy();
|
|
1113
|
+
});
|
|
1114
|
+
upstream.once("error", (err) => fail(502, "Bad Gateway", err));
|
|
1115
|
+
upstream.once("close", () => {
|
|
1116
|
+
if (!socket.destroyed)
|
|
1117
|
+
socket.destroy();
|
|
1118
|
+
});
|
|
1119
|
+
upstream.once(connectEvent, () => {
|
|
1120
|
+
try {
|
|
1121
|
+
upstream.write(upgradePayload);
|
|
1122
|
+
if (head && head.length > 0) {
|
|
1123
|
+
upstream.write(head);
|
|
1124
|
+
}
|
|
1125
|
+
socket.pipe(upstream);
|
|
1126
|
+
upstream.pipe(socket);
|
|
1127
|
+
finish(true);
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
fail(502, "Bad Gateway", err);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Register this service with ForgeProxy.
|
|
1137
|
+
* ForgeProxy then routes external traffic to us.
|
|
1138
|
+
*/
|
|
1139
|
+
async _registerWithForgeProxy() {
|
|
1140
|
+
const contract = this._serviceInstance?.constructor?.contract;
|
|
1141
|
+
const methods = contract?.expose ?? [];
|
|
1142
|
+
const registration = {
|
|
1143
|
+
service: this.serviceName,
|
|
1144
|
+
host: this._getHost(),
|
|
1145
|
+
port: this.port,
|
|
1146
|
+
workers: this.threadCount,
|
|
1147
|
+
methods,
|
|
1148
|
+
health_endpoint: "/health",
|
|
1149
|
+
};
|
|
1150
|
+
const resp = await fetch(`${this._forgeProxy}/register`, {
|
|
1151
|
+
method: "POST",
|
|
1152
|
+
headers: { "Content-Type": "application/json" },
|
|
1153
|
+
body: JSON.stringify(registration),
|
|
1154
|
+
});
|
|
1155
|
+
if (resp.ok) {
|
|
1156
|
+
this.logger.info(`Registered with ForgeProxy at ${this._forgeProxy}`, {
|
|
1157
|
+
methods: methods.length,
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
throw new Error(`ForgeProxy registration failed: ${resp.status}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
_getHost() {
|
|
1165
|
+
return process.env.FORGE_HOST ?? process.env.HOSTNAME ?? "127.0.0.1";
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
export { ForgeWebSocket };
|
|
1169
|
+
//# sourceMappingURL=ForgeContext.js.map
|