threadforge 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -20
- 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 +78 -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
|
@@ -1,797 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Service Registry
|
|
3
|
-
*
|
|
4
|
-
* The registry is the brain of horizontal scaling. It answers one
|
|
5
|
-
* question for every proxy call: WHERE is this service?
|
|
6
|
-
*
|
|
7
|
-
* ═══════════════════════════════════════════════════════════════
|
|
8
|
-
* GROWTH STORY
|
|
9
|
-
* ═══════════════════════════════════════════════════════════════
|
|
10
|
-
*
|
|
11
|
-
* PHASE 1: Single machine ($20/mo VPS)
|
|
12
|
-
* Registry mode: 'embedded'
|
|
13
|
-
* - Runs in-process inside the supervisor
|
|
14
|
-
* - All services are local (UDS or colocated)
|
|
15
|
-
* - Registry is just a Map in memory
|
|
16
|
-
* - Zero operational overhead
|
|
17
|
-
*
|
|
18
|
-
* PHASE 2: 2-3 machines ($100/mo)
|
|
19
|
-
* Registry mode: 'multicast'
|
|
20
|
-
* - Each supervisor announces its services via UDP multicast
|
|
21
|
-
* on the local network (or via a simple HTTP gossip protocol)
|
|
22
|
-
* - Nodes discover each other automatically
|
|
23
|
-
* - No external dependencies (no etcd, no consul)
|
|
24
|
-
* - Services that move to another machine are auto-discovered
|
|
25
|
-
*
|
|
26
|
-
* PHASE 3: 10+ machines (serious scale)
|
|
27
|
-
* Registry mode: 'external'
|
|
28
|
-
* - Pluggable backend: Redis, etcd, Consul, or managed service
|
|
29
|
-
* - Full service mesh capabilities
|
|
30
|
-
* - Health checks, circuit breakers, canary deployments
|
|
31
|
-
*
|
|
32
|
-
* The proxy layer doesn't know or care which phase you're in.
|
|
33
|
-
* this.users.getUser('123') works identically in all three.
|
|
34
|
-
*
|
|
35
|
-
* ═══════════════════════════════════════════════════════════════
|
|
36
|
-
* REGISTRATION PROTOCOL
|
|
37
|
-
* ═══════════════════════════════════════════════════════════════
|
|
38
|
-
*
|
|
39
|
-
* When a service starts, it registers:
|
|
40
|
-
*
|
|
41
|
-
* {
|
|
42
|
-
* name: 'users',
|
|
43
|
-
* nodeId: 'node-abc123',
|
|
44
|
-
* host: '10.0.1.5',
|
|
45
|
-
* ports: {
|
|
46
|
-
* http: 4001, // HTTP port (for remote calls via /__forge/invoke)
|
|
47
|
-
* },
|
|
48
|
-
* udsPath: '/tmp/forge-1234/users-1.sock', // local only
|
|
49
|
-
* workers: 4,
|
|
50
|
-
* contract: { // what methods are available
|
|
51
|
-
* methods: ['getUser', 'createUser', 'listUsers'],
|
|
52
|
-
* events: ['user.created'],
|
|
53
|
-
* },
|
|
54
|
-
* health: {
|
|
55
|
-
* status: 'healthy',
|
|
56
|
-
* cpu: 23, // percent
|
|
57
|
-
* memory: 156, // MB
|
|
58
|
-
* rpcLatencyP50: 2, // ms
|
|
59
|
-
* rpcLatencyP99: 18, // ms
|
|
60
|
-
* pendingRequests: 12,
|
|
61
|
-
* lastHeartbeat: 1707500000000,
|
|
62
|
-
* },
|
|
63
|
-
* metadata: {
|
|
64
|
-
* version: '1.2.3',
|
|
65
|
-
* region: 'us-east-1',
|
|
66
|
-
* startedAt: 1707500000000,
|
|
67
|
-
* },
|
|
68
|
-
* }
|
|
69
|
-
*
|
|
70
|
-
* ═══════════════════════════════════════════════════════════════
|
|
71
|
-
* TOPOLOGY RESOLUTION
|
|
72
|
-
* ═══════════════════════════════════════════════════════════════
|
|
73
|
-
*
|
|
74
|
-
* When a proxy call is made, the registry resolves the transport:
|
|
75
|
-
*
|
|
76
|
-
* registry.resolve('users')
|
|
77
|
-
* → [
|
|
78
|
-
* { transport: 'local', instance: ... }, // colocated
|
|
79
|
-
* { transport: 'uds', path: '/tmp/...' }, // same machine
|
|
80
|
-
* { transport: 'http', host: '10.0.1.5:4001' }, // remote
|
|
81
|
-
* ]
|
|
82
|
-
*
|
|
83
|
-
* The proxy picks the BEST option:
|
|
84
|
-
* 1. Colocated (same process) → always preferred
|
|
85
|
-
* 2. UDS (same machine) → preferred over network
|
|
86
|
-
* 3. HTTP (closest region) → fallback
|
|
87
|
-
*/
|
|
88
|
-
|
|
89
|
-
import crypto from "node:crypto";
|
|
90
|
-
import { EventEmitter } from "node:events";
|
|
91
|
-
import os from "node:os";
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* @typedef {Object} ServiceRegistration
|
|
95
|
-
* @property {string} name
|
|
96
|
-
* @property {string} nodeId
|
|
97
|
-
* @property {string} host
|
|
98
|
-
* @property {{http?: number}} ports
|
|
99
|
-
* @property {string|null} udsPath
|
|
100
|
-
* @property {number} workers
|
|
101
|
-
* @property {{methods: string[], events: string[]}} contract
|
|
102
|
-
* @property {ServiceHealth} health
|
|
103
|
-
* @property {Object} metadata
|
|
104
|
-
*/
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* @typedef {Object} ServiceHealth
|
|
108
|
-
* @property {'healthy'|'degraded'|'unhealthy'|'draining'} status
|
|
109
|
-
* @property {number} cpu
|
|
110
|
-
* @property {number} memory
|
|
111
|
-
* @property {number} rpcLatencyP50
|
|
112
|
-
* @property {number} rpcLatencyP99
|
|
113
|
-
* @property {number} pendingRequests
|
|
114
|
-
* @property {number} lastHeartbeat
|
|
115
|
-
*/
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* @typedef {Object} ResolvedEndpoint
|
|
119
|
-
* @property {'local'|'uds'|'http'} transport
|
|
120
|
-
* @property {string} nodeId
|
|
121
|
-
* @property {object} [instance] - for 'local'
|
|
122
|
-
* @property {string} [path] - for 'uds'
|
|
123
|
-
* @property {string} [address] - for 'http'
|
|
124
|
-
* @property {ServiceHealth} health
|
|
125
|
-
* @property {number} priority - lower = preferred
|
|
126
|
-
*/
|
|
127
|
-
|
|
128
|
-
// ─── Base Registry ──────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
export class ServiceRegistry extends EventEmitter {
|
|
131
|
-
/**
|
|
132
|
-
* @param {Object} options
|
|
133
|
-
* @param {string} [options.mode='embedded'] - 'embedded' | 'multicast' | 'external'
|
|
134
|
-
* @param {string} [options.nodeId] - Unique node identifier
|
|
135
|
-
* @param {string} [options.host] - This node's reachable address
|
|
136
|
-
* @param {number} [options.heartbeatIntervalMs=5000]
|
|
137
|
-
* @param {number} [options.healthTimeoutMs=15000] - Mark unhealthy after this
|
|
138
|
-
* @param {number} [options.httpBasePort=4000] - Base port for auto HTTP binding
|
|
139
|
-
*/
|
|
140
|
-
constructor(options = {}) {
|
|
141
|
-
super();
|
|
142
|
-
|
|
143
|
-
this.mode = options.mode ?? "embedded";
|
|
144
|
-
this.nodeId = options.nodeId ?? `node-${crypto.randomBytes(4).toString("hex")}`;
|
|
145
|
-
this.host = options.host ?? getLocalIP();
|
|
146
|
-
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 5000;
|
|
147
|
-
this.healthTimeoutMs = options.healthTimeoutMs ?? 15000;
|
|
148
|
-
this.httpBasePort = options.httpBasePort ?? 4000;
|
|
149
|
-
this._clusterSecret = options.clusterSecret ?? process.env.FORGE_CLUSTER_SECRET ?? null;
|
|
150
|
-
this.staleReapMultiplier = options.staleReapMultiplier ?? 3;
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* All known service registrations across all nodes.
|
|
154
|
-
* Key: `${serviceName}@${nodeId}`
|
|
155
|
-
* @type {Map<string, ServiceRegistration>}
|
|
156
|
-
*/
|
|
157
|
-
this.registrations = new Map();
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Services running on THIS node.
|
|
161
|
-
* @type {Map<string, ServiceRegistration>}
|
|
162
|
-
*/
|
|
163
|
-
this.localRegistrations = new Map();
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Colocated service instances (same process).
|
|
167
|
-
* @type {Map<string, {service: object, ctx: object}>}
|
|
168
|
-
*/
|
|
169
|
-
this.localInstances = new Map();
|
|
170
|
-
|
|
171
|
-
/** @type {NodeJS.Timeout|null} */
|
|
172
|
-
this._heartbeatTimer = null;
|
|
173
|
-
|
|
174
|
-
/** @type {NodeJS.Timeout|null} */
|
|
175
|
-
this._reapTimer = null;
|
|
176
|
-
|
|
177
|
-
/** @type {Object|null} - multicast/external backend */
|
|
178
|
-
this._backend = null;
|
|
179
|
-
|
|
180
|
-
/** @type {Map<string, NodeJS.Timeout>} pending drain timers from deregister() */
|
|
181
|
-
this._drainTimers = new Map();
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Split-brain protection: track known peer nodes for quorum checks.
|
|
185
|
-
* @type {Map<string, number>} nodeId → last seen timestamp
|
|
186
|
-
*/
|
|
187
|
-
this._knownPeers = new Map();
|
|
188
|
-
|
|
189
|
-
/** Expected cluster size for quorum calculation (0 = disabled) */
|
|
190
|
-
this._expectedClusterSize = parseInt(process.env.FORGE_EXPECTED_CLUSTER_SIZE || '0', 10);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Start the registry. In embedded mode this is a no-op.
|
|
195
|
-
* In multicast/external mode, connects to peers.
|
|
196
|
-
*/
|
|
197
|
-
async start() {
|
|
198
|
-
// Start heartbeat with jitter to prevent thundering herd across nodes
|
|
199
|
-
const jitter = Math.floor(Math.random() * this.heartbeatIntervalMs * 0.2);
|
|
200
|
-
this._heartbeatTimer = setInterval(() => {
|
|
201
|
-
this._sendHeartbeats();
|
|
202
|
-
}, this.heartbeatIntervalMs + jitter);
|
|
203
|
-
this._heartbeatTimer.unref();
|
|
204
|
-
|
|
205
|
-
// Start reaper (remove stale registrations)
|
|
206
|
-
this._reapTimer = setInterval(() => {
|
|
207
|
-
this._reapStale();
|
|
208
|
-
}, this.healthTimeoutMs);
|
|
209
|
-
this._reapTimer.unref();
|
|
210
|
-
|
|
211
|
-
if (this.mode === "multicast") {
|
|
212
|
-
await this._startMulticast();
|
|
213
|
-
} else if (this.mode === "external") {
|
|
214
|
-
await this._startExternal();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return this;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Register a local service.
|
|
222
|
-
* Called by the Supervisor when a service worker starts.
|
|
223
|
-
*/
|
|
224
|
-
register(registration) {
|
|
225
|
-
const key = `${registration.name}@${this.nodeId}`;
|
|
226
|
-
|
|
227
|
-
const reg = {
|
|
228
|
-
...registration,
|
|
229
|
-
nodeId: this.nodeId,
|
|
230
|
-
host: this.host,
|
|
231
|
-
health: {
|
|
232
|
-
status: "healthy",
|
|
233
|
-
cpu: 0,
|
|
234
|
-
memory: 0,
|
|
235
|
-
rpcLatencyP50: 0,
|
|
236
|
-
rpcLatencyP99: 0,
|
|
237
|
-
pendingRequests: 0,
|
|
238
|
-
lastHeartbeat: Date.now(),
|
|
239
|
-
...registration.health,
|
|
240
|
-
},
|
|
241
|
-
metadata: {
|
|
242
|
-
version: process.env.npm_package_version ?? "0.0.0",
|
|
243
|
-
region: process.env.FORGE_REGION ?? "local",
|
|
244
|
-
startedAt: Date.now(),
|
|
245
|
-
...registration.metadata,
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
this.registrations.set(key, reg);
|
|
250
|
-
this.localRegistrations.set(registration.name, reg);
|
|
251
|
-
|
|
252
|
-
this.emit("registered", reg);
|
|
253
|
-
this._announceToNetwork(reg);
|
|
254
|
-
|
|
255
|
-
return reg;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Register a colocated service instance (same process).
|
|
260
|
-
*/
|
|
261
|
-
registerLocal(name, instance) {
|
|
262
|
-
this.localInstances.set(name, instance);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Deregister a service (shutting down).
|
|
267
|
-
*/
|
|
268
|
-
deregister(serviceName) {
|
|
269
|
-
const key = `${serviceName}@${this.nodeId}`;
|
|
270
|
-
const reg = this.registrations.get(key);
|
|
271
|
-
|
|
272
|
-
if (reg) {
|
|
273
|
-
reg.health.status = "draining";
|
|
274
|
-
this.emit("deregistering", reg);
|
|
275
|
-
|
|
276
|
-
// Announce to network so other nodes stop routing to us
|
|
277
|
-
this._announceToNetwork(reg);
|
|
278
|
-
|
|
279
|
-
// Remove after drain period
|
|
280
|
-
const drainTimer = setTimeout(() => {
|
|
281
|
-
this._drainTimers.delete(key);
|
|
282
|
-
this.registrations.delete(key);
|
|
283
|
-
this.localRegistrations.delete(serviceName);
|
|
284
|
-
this.localInstances.delete(serviceName);
|
|
285
|
-
this.emit("deregistered", { name: serviceName, nodeId: this.nodeId });
|
|
286
|
-
}, 5000);
|
|
287
|
-
drainTimer.unref();
|
|
288
|
-
this._drainTimers.set(key, drainTimer);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Receive a registration from another node.
|
|
294
|
-
*/
|
|
295
|
-
receiveRemoteRegistration(reg) {
|
|
296
|
-
if (!reg || typeof reg.name !== 'string' || typeof reg.nodeId !== 'string') return;
|
|
297
|
-
if (!reg.host || typeof reg.host !== 'string') return;
|
|
298
|
-
if (!reg.health || typeof reg.health.status !== 'string') return;
|
|
299
|
-
|
|
300
|
-
if (reg.nodeId === this.nodeId) return; // ignore self
|
|
301
|
-
|
|
302
|
-
const key = `${reg.name}@${reg.nodeId}`;
|
|
303
|
-
const existing = this.registrations.get(key);
|
|
304
|
-
|
|
305
|
-
// Set lastSeen to local clock to avoid clock skew issues with remote timestamps
|
|
306
|
-
reg.lastSeen = Date.now();
|
|
307
|
-
|
|
308
|
-
// Track peer nodes for split-brain detection
|
|
309
|
-
this._knownPeers.set(reg.nodeId, Date.now());
|
|
310
|
-
|
|
311
|
-
this.registrations.set(key, reg);
|
|
312
|
-
|
|
313
|
-
if (!existing) {
|
|
314
|
-
this.emit("discovered", reg);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Check if we can see a quorum of the expected cluster.
|
|
320
|
-
* Returns true if quorum is met or split-brain detection is disabled.
|
|
321
|
-
* A node should avoid serving traffic if it's partitioned from the majority.
|
|
322
|
-
*/
|
|
323
|
-
hasQuorum() {
|
|
324
|
-
if (this._expectedClusterSize <= 0) return true;
|
|
325
|
-
|
|
326
|
-
// Count recently-seen peers (within 2x health timeout)
|
|
327
|
-
const cutoff = Date.now() - this.healthTimeoutMs * 2;
|
|
328
|
-
let activePeers = 0;
|
|
329
|
-
for (const [, lastSeen] of this._knownPeers) {
|
|
330
|
-
if (lastSeen > cutoff) activePeers++;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// +1 for self
|
|
334
|
-
const visibleNodes = activePeers + 1;
|
|
335
|
-
const quorum = Math.floor(this._expectedClusterSize / 2) + 1;
|
|
336
|
-
return visibleNodes >= quorum;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Update health for a local service.
|
|
341
|
-
*/
|
|
342
|
-
updateHealth(serviceName, health) {
|
|
343
|
-
const reg = this.localRegistrations.get(serviceName);
|
|
344
|
-
if (reg) {
|
|
345
|
-
Object.assign(reg.health, health, { lastHeartbeat: Date.now() });
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ─── Resolution ─────────────────────────────────────────
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Resolve endpoints for a service, ordered by preference.
|
|
353
|
-
*
|
|
354
|
-
* Returns all known instances with their transport type and
|
|
355
|
-
* health status, sorted by priority:
|
|
356
|
-
* 1. Local (colocated, same process)
|
|
357
|
-
* 2. UDS (same machine, different process)
|
|
358
|
-
* 3. HTTP (remote, same region)
|
|
359
|
-
* 4. HTTP (remote, different region)
|
|
360
|
-
*
|
|
361
|
-
* @param {string} serviceName
|
|
362
|
-
* @returns {ResolvedEndpoint[]}
|
|
363
|
-
*/
|
|
364
|
-
resolve(serviceName) {
|
|
365
|
-
const endpoints = [];
|
|
366
|
-
|
|
367
|
-
const isColocated = this.localInstances.has(serviceName);
|
|
368
|
-
let localAdded = false;
|
|
369
|
-
|
|
370
|
-
for (const [_key, reg] of this.registrations) {
|
|
371
|
-
if (reg.name !== serviceName) continue;
|
|
372
|
-
if (reg.health.status === "unhealthy") continue;
|
|
373
|
-
|
|
374
|
-
const isLocal = reg.nodeId === this.nodeId;
|
|
375
|
-
const isDraining = reg.health.status === "draining";
|
|
376
|
-
|
|
377
|
-
if (isDraining) continue;
|
|
378
|
-
|
|
379
|
-
if (isColocated && isLocal) {
|
|
380
|
-
if (!localAdded) {
|
|
381
|
-
endpoints.push({
|
|
382
|
-
transport: "local",
|
|
383
|
-
nodeId: reg.nodeId,
|
|
384
|
-
instance: this.localInstances.get(serviceName),
|
|
385
|
-
health: reg.health,
|
|
386
|
-
priority: 0, // highest priority
|
|
387
|
-
});
|
|
388
|
-
localAdded = true;
|
|
389
|
-
}
|
|
390
|
-
} else if (isLocal && reg.udsPath) {
|
|
391
|
-
endpoints.push({
|
|
392
|
-
transport: "uds",
|
|
393
|
-
nodeId: reg.nodeId,
|
|
394
|
-
path: reg.udsPath,
|
|
395
|
-
health: reg.health,
|
|
396
|
-
priority: 1,
|
|
397
|
-
});
|
|
398
|
-
} else if (reg.ports?.http) {
|
|
399
|
-
const sameRegion = reg.metadata?.region === (process.env.FORGE_REGION ?? "local");
|
|
400
|
-
endpoints.push({
|
|
401
|
-
transport: "http",
|
|
402
|
-
nodeId: reg.nodeId,
|
|
403
|
-
address: `${reg.host}:${reg.ports.http}`,
|
|
404
|
-
health: reg.health,
|
|
405
|
-
priority: sameRegion ? 2 : 3,
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Sort by priority, then by health
|
|
411
|
-
endpoints.sort((a, b) => {
|
|
412
|
-
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
413
|
-
// Prefer lower latency
|
|
414
|
-
return (a.health.rpcLatencyP50 ?? 0) - (b.health.rpcLatencyP50 ?? 0);
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
return endpoints;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Resolve the BEST single endpoint for a service.
|
|
422
|
-
* Used by the proxy when making a call.
|
|
423
|
-
*
|
|
424
|
-
* @param {string} serviceName
|
|
425
|
-
* @returns {ResolvedEndpoint|null}
|
|
426
|
-
*/
|
|
427
|
-
resolveBest(serviceName) {
|
|
428
|
-
const endpoints = this.resolve(serviceName);
|
|
429
|
-
return endpoints[0] ?? null;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Get all known services and their locations.
|
|
434
|
-
*/
|
|
435
|
-
topology() {
|
|
436
|
-
const services = new Map();
|
|
437
|
-
|
|
438
|
-
for (const [, reg] of this.registrations) {
|
|
439
|
-
if (!services.has(reg.name)) {
|
|
440
|
-
services.set(reg.name, []);
|
|
441
|
-
}
|
|
442
|
-
services.get(reg.name).push({
|
|
443
|
-
nodeId: reg.nodeId,
|
|
444
|
-
host: reg.host,
|
|
445
|
-
transport: this.localInstances?.has(reg.name) ? "colocated"
|
|
446
|
-
: reg.nodeId === this.nodeId ? "local"
|
|
447
|
-
: "http",
|
|
448
|
-
status: reg.health.status,
|
|
449
|
-
cpu: reg.health.cpu,
|
|
450
|
-
workers: reg.workers,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
return Object.fromEntries(services);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ─── Multicast Discovery ────────────────────────────────
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Multicast mode: UDP broadcast on the local network.
|
|
461
|
-
* Each node announces its services every heartbeat interval.
|
|
462
|
-
* New nodes are discovered automatically.
|
|
463
|
-
*/
|
|
464
|
-
async _startMulticast() {
|
|
465
|
-
if (!this._clusterSecret) {
|
|
466
|
-
if (process.env.NODE_ENV === 'production') {
|
|
467
|
-
throw new Error('[Registry] FORGE_CLUSTER_SECRET is required for multicast discovery in production');
|
|
468
|
-
}
|
|
469
|
-
console.warn(
|
|
470
|
-
"[Registry] FORGE_CLUSTER_SECRET not set — multicast messages are unauthenticated. Set a shared secret for production use.",
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const dgram = await import("node:dgram");
|
|
475
|
-
const MULTICAST_ADDR = process.env.FORGE_MULTICAST_ADDRESS || "239.255.42.42";
|
|
476
|
-
const MULTICAST_PORT = parseInt(process.env.FORGE_MULTICAST_PORT || "42042", 10);
|
|
477
|
-
|
|
478
|
-
// Create UDP socket for sending/receiving announcements
|
|
479
|
-
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
480
|
-
|
|
481
|
-
// Rate limiting: track message counts per source IP
|
|
482
|
-
const _multicastRateCounts = new Map();
|
|
483
|
-
const _multicastRateTimer = setInterval(() => { _multicastRateCounts.clear(); }, 1000);
|
|
484
|
-
_multicastRateTimer.unref();
|
|
485
|
-
this._multicastRateTimer = _multicastRateTimer;
|
|
486
|
-
|
|
487
|
-
socket.on("message", (buf, rinfo) => {
|
|
488
|
-
// 1. Size check FIRST
|
|
489
|
-
if (buf.length > 8192) return;
|
|
490
|
-
|
|
491
|
-
// 2. Rate limit per source IP: reject if > 10/sec
|
|
492
|
-
const srcAddr = rinfo.address;
|
|
493
|
-
const rateCount = (_multicastRateCounts.get(srcAddr) ?? 0) + 1;
|
|
494
|
-
_multicastRateCounts.set(srcAddr, rateCount);
|
|
495
|
-
if (rateCount > 10) return;
|
|
496
|
-
|
|
497
|
-
// 3. JSON.parse
|
|
498
|
-
let msg;
|
|
499
|
-
try {
|
|
500
|
-
msg = JSON.parse(buf.toString());
|
|
501
|
-
} catch (err) {
|
|
502
|
-
console.warn("[ServiceRegistry] Received malformed multicast message");
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// 4. Signature verification BEFORE checking message structure
|
|
507
|
-
if (this._clusterSecret) {
|
|
508
|
-
const sig = msg.sig ?? "";
|
|
509
|
-
const { sig: _, ...rest } = msg;
|
|
510
|
-
const canonical = JSON.stringify(sortKeys(rest));
|
|
511
|
-
const expected = crypto.createHmac("sha256", this._clusterSecret)
|
|
512
|
-
.update(canonical)
|
|
513
|
-
.digest("hex");
|
|
514
|
-
try {
|
|
515
|
-
const sigBuf = Buffer.from(sig, "hex");
|
|
516
|
-
const expBuf = Buffer.from(expected, "hex");
|
|
517
|
-
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
} catch {
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// 5. THEN check message type, nodeId, etc.
|
|
526
|
-
if (!msg || typeof msg !== 'object' || !msg.type || msg.type !== 'forge:announce') {
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
if (msg.nodeId === this.nodeId) return; // Ignore own announcements
|
|
530
|
-
|
|
531
|
-
// Replay protection: reject stale messages
|
|
532
|
-
if (typeof msg.timestamp === 'number') {
|
|
533
|
-
const MAX_MESSAGE_AGE = (this.heartbeatIntervalMs ?? 30000) * 2;
|
|
534
|
-
if (Math.abs(Date.now() - msg.timestamp) > MAX_MESSAGE_AGE) {
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
for (const reg of msg.services) {
|
|
540
|
-
this.receiveRemoteRegistration(reg);
|
|
541
|
-
}
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
await new Promise((resolve) => {
|
|
545
|
-
socket.bind(MULTICAST_PORT, () => {
|
|
546
|
-
socket.addMembership(MULTICAST_ADDR);
|
|
547
|
-
resolve();
|
|
548
|
-
});
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
this._multicastSocket = socket;
|
|
552
|
-
this._multicastAddr = MULTICAST_ADDR;
|
|
553
|
-
this._multicastPort = MULTICAST_PORT;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Announce services to the network.
|
|
558
|
-
*/
|
|
559
|
-
_announceToNetwork(reg) {
|
|
560
|
-
if (this.mode === "multicast" && this._multicastSocket) {
|
|
561
|
-
const allServices = [...this.localRegistrations.values()];
|
|
562
|
-
const testPayload = JSON.stringify({
|
|
563
|
-
type: "forge:announce",
|
|
564
|
-
nodeId: this.nodeId,
|
|
565
|
-
services: allServices,
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
if (Buffer.byteLength(testPayload) > 1400) {
|
|
569
|
-
// Split services into chunks that fit in a UDP packet
|
|
570
|
-
const chunks = [];
|
|
571
|
-
let current = [];
|
|
572
|
-
let currentSize = 0;
|
|
573
|
-
for (const svc of allServices) {
|
|
574
|
-
const svcSize = Buffer.byteLength(JSON.stringify(svc));
|
|
575
|
-
if (currentSize + svcSize > 1200 && current.length > 0) {
|
|
576
|
-
chunks.push(current);
|
|
577
|
-
current = [];
|
|
578
|
-
currentSize = 0;
|
|
579
|
-
}
|
|
580
|
-
current.push(svc);
|
|
581
|
-
currentSize += svcSize;
|
|
582
|
-
}
|
|
583
|
-
if (current.length > 0) chunks.push(current);
|
|
584
|
-
|
|
585
|
-
for (const chunk of chunks) {
|
|
586
|
-
this._sendMulticastPayload(chunk);
|
|
587
|
-
}
|
|
588
|
-
} else {
|
|
589
|
-
this._sendMulticastPayload(allServices);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (this.mode === "external" && this._backend) {
|
|
594
|
-
this._backend.set(`forge/services/${reg.name}/${this.nodeId}`, JSON.stringify(reg), {
|
|
595
|
-
ttl: Math.ceil(this.healthTimeoutMs / 1000),
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
_sendMulticastPayload(services) {
|
|
601
|
-
const payload = {
|
|
602
|
-
type: "forge:announce",
|
|
603
|
-
nodeId: this.nodeId,
|
|
604
|
-
services,
|
|
605
|
-
timestamp: Date.now(),
|
|
606
|
-
};
|
|
607
|
-
|
|
608
|
-
// Sign with HMAC if cluster secret is configured
|
|
609
|
-
// Use canonical JSON (recursive sorted keys) to ensure consistent signatures across environments
|
|
610
|
-
if (this._clusterSecret) {
|
|
611
|
-
const canonical = JSON.stringify(sortKeys(payload));
|
|
612
|
-
const sig = crypto.createHmac("sha256", this._clusterSecret).update(canonical).digest("hex");
|
|
613
|
-
payload.sig = sig;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const buf = Buffer.from(JSON.stringify(payload));
|
|
617
|
-
this._multicastSocket.send(buf, this._multicastPort, this._multicastAddr);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// ─── External Backend ───────────────────────────────────
|
|
621
|
-
|
|
622
|
-
async _startExternal() {
|
|
623
|
-
throw new Error(
|
|
624
|
-
'External registry backend not yet implemented. Planned backends: Redis, etcd, Consul. ' +
|
|
625
|
-
'Use "embedded" or "multicast" mode for now.'
|
|
626
|
-
);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* Set an external backend (Redis, etcd, etc.)
|
|
631
|
-
*
|
|
632
|
-
* The backend must implement:
|
|
633
|
-
* get(key) → string
|
|
634
|
-
* set(key, value, options) → void
|
|
635
|
-
* watch(prefix, callback) → void
|
|
636
|
-
* delete(key) → void
|
|
637
|
-
*/
|
|
638
|
-
setBackend(backend) {
|
|
639
|
-
this._backend = backend;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// ─── Health Management ──────────────────────────────────
|
|
643
|
-
|
|
644
|
-
_sendHeartbeats() {
|
|
645
|
-
for (const [, reg] of this.localRegistrations) {
|
|
646
|
-
reg.health.lastHeartbeat = Date.now();
|
|
647
|
-
reg.health.cpu = getCpuUsage();
|
|
648
|
-
reg.health.memory = Math.round(process.memoryUsage().rss / 1024 / 1024);
|
|
649
|
-
this._announceToNetwork(reg);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
_reapStale() {
|
|
654
|
-
const now = Date.now();
|
|
655
|
-
|
|
656
|
-
for (const [key, reg] of this.registrations) {
|
|
657
|
-
if (reg.nodeId === this.nodeId) continue; // don't reap self
|
|
658
|
-
|
|
659
|
-
// Use lastSeen (local receive time) instead of remote lastHeartbeat to avoid clock skew
|
|
660
|
-
const age = now - (reg.lastSeen ?? reg.health?.lastHeartbeat ?? 0);
|
|
661
|
-
if (age > this.healthTimeoutMs) {
|
|
662
|
-
if (reg.health.status !== "unhealthy") {
|
|
663
|
-
reg.health.status = "unhealthy";
|
|
664
|
-
this.emit("unhealthy", reg);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Remove after multiplier * timeout
|
|
668
|
-
if (age > this.healthTimeoutMs * this.staleReapMultiplier) {
|
|
669
|
-
this.registrations.delete(key);
|
|
670
|
-
this.emit("removed", reg);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// Reap stale peers from split-brain tracking
|
|
676
|
-
const peerCutoff = now - this.healthTimeoutMs * this.staleReapMultiplier;
|
|
677
|
-
for (const [nodeId, lastSeen] of this._knownPeers) {
|
|
678
|
-
if (lastSeen < peerCutoff) {
|
|
679
|
-
this._knownPeers.delete(nodeId);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// Emit warning if quorum is lost
|
|
684
|
-
if (!this.hasQuorum()) {
|
|
685
|
-
this.emit("quorum-lost", {
|
|
686
|
-
expectedSize: this._expectedClusterSize,
|
|
687
|
-
activePeers: this._knownPeers.size,
|
|
688
|
-
nodeId: this.nodeId,
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/**
|
|
694
|
-
* Expose the topology via HTTP for gossip / dashboards.
|
|
695
|
-
* The supervisor can mount this on its metrics server.
|
|
696
|
-
*/
|
|
697
|
-
httpHandler(req, res) {
|
|
698
|
-
if (req.url === "/_forge/topology") {
|
|
699
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
700
|
-
res.end(
|
|
701
|
-
JSON.stringify(
|
|
702
|
-
{
|
|
703
|
-
nodeId: this.nodeId,
|
|
704
|
-
host: this.host,
|
|
705
|
-
mode: this.mode,
|
|
706
|
-
registrations: [...this.registrations.values()],
|
|
707
|
-
topology: this.topology(),
|
|
708
|
-
},
|
|
709
|
-
null,
|
|
710
|
-
2,
|
|
711
|
-
),
|
|
712
|
-
);
|
|
713
|
-
return true;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const parsedUrl = new URL(req.url, "http://localhost");
|
|
717
|
-
if (parsedUrl.pathname === "/_forge/resolve" && req.method === "GET") {
|
|
718
|
-
const service = parsedUrl.searchParams.get("service");
|
|
719
|
-
if (service) {
|
|
720
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
721
|
-
res.end(JSON.stringify(this.resolve(service), null, 2));
|
|
722
|
-
} else {
|
|
723
|
-
res.writeHead(400);
|
|
724
|
-
res.end("Missing ?service= parameter");
|
|
725
|
-
}
|
|
726
|
-
return true;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
return false;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
async stop() {
|
|
733
|
-
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
734
|
-
if (this._reapTimer) clearInterval(this._reapTimer);
|
|
735
|
-
|
|
736
|
-
// Clear all pending drain timers from deregister()
|
|
737
|
-
for (const timer of this._drainTimers.values()) {
|
|
738
|
-
clearTimeout(timer);
|
|
739
|
-
}
|
|
740
|
-
this._drainTimers.clear();
|
|
741
|
-
|
|
742
|
-
// Immediately remove all local services (skip drain period during stop)
|
|
743
|
-
for (const name of [...this.localRegistrations.keys()]) {
|
|
744
|
-
const key = `${name}@${this.nodeId}`;
|
|
745
|
-
this.registrations.delete(key);
|
|
746
|
-
this.localRegistrations.delete(name);
|
|
747
|
-
this.localInstances.delete(name);
|
|
748
|
-
this.emit("deregistered", { name, nodeId: this.nodeId });
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
if (this._multicastRateTimer) {
|
|
752
|
-
clearInterval(this._multicastRateTimer);
|
|
753
|
-
}
|
|
754
|
-
if (this._multicastSocket) {
|
|
755
|
-
this._multicastSocket.close();
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// ─── Utilities ──────────────────────────────────────────────
|
|
761
|
-
|
|
762
|
-
function sortKeys(obj) {
|
|
763
|
-
if (Array.isArray(obj)) return obj.map(sortKeys);
|
|
764
|
-
if (obj && typeof obj === 'object') {
|
|
765
|
-
return Object.keys(obj).sort().reduce((acc, k) => { acc[k] = sortKeys(obj[k]); return acc; }, {});
|
|
766
|
-
}
|
|
767
|
-
return obj;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function getLocalIP() {
|
|
771
|
-
const interfaces = os.networkInterfaces();
|
|
772
|
-
for (const name of Object.keys(interfaces)) {
|
|
773
|
-
for (const iface of interfaces[name]) {
|
|
774
|
-
if (iface.family === "IPv4" && !iface.internal) {
|
|
775
|
-
return iface.address;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
return "127.0.0.1";
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
let lastCpuUsage = process.cpuUsage();
|
|
783
|
-
let lastCpuTime = Date.now();
|
|
784
|
-
|
|
785
|
-
function getCpuUsage() {
|
|
786
|
-
const now = Date.now();
|
|
787
|
-
const elapsed = now - lastCpuTime;
|
|
788
|
-
if (elapsed === 0) return 0;
|
|
789
|
-
|
|
790
|
-
const usage = process.cpuUsage(lastCpuUsage);
|
|
791
|
-
lastCpuUsage = process.cpuUsage();
|
|
792
|
-
lastCpuTime = now;
|
|
793
|
-
|
|
794
|
-
// user + system time in microseconds / elapsed wall time in microseconds
|
|
795
|
-
const cpuPercent = ((usage.user + usage.system) / 1000 / elapsed) * 100;
|
|
796
|
-
return Math.round(cpuPercent);
|
|
797
|
-
}
|