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.
Files changed (358) hide show
  1. package/README.md +52 -20
  2. package/bin/forge.js +2 -1058
  3. package/bin/host-commands.d.ts +2 -0
  4. package/bin/host-commands.d.ts.map +1 -0
  5. package/bin/host-commands.js +7 -8
  6. package/bin/platform-commands.d.ts +2 -0
  7. package/bin/platform-commands.d.ts.map +1 -0
  8. package/bin/platform-commands.js +118 -36
  9. package/dist/cli/base-command.d.ts +12 -0
  10. package/dist/cli/base-command.d.ts.map +1 -0
  11. package/dist/cli/base-command.js +25 -0
  12. package/dist/cli/base-command.js.map +1 -0
  13. package/dist/cli/commands/build.d.ts +10 -0
  14. package/dist/cli/commands/build.d.ts.map +1 -0
  15. package/dist/cli/commands/build.js +110 -0
  16. package/dist/cli/commands/build.js.map +1 -0
  17. package/dist/cli/commands/deploy.d.ts +12 -0
  18. package/dist/cli/commands/deploy.d.ts.map +1 -0
  19. package/dist/cli/commands/deploy.js +143 -0
  20. package/dist/cli/commands/deploy.js.map +1 -0
  21. package/dist/cli/commands/dev.d.ts +10 -0
  22. package/dist/cli/commands/dev.d.ts.map +1 -0
  23. package/dist/cli/commands/dev.js +138 -0
  24. package/dist/cli/commands/dev.js.map +1 -0
  25. package/dist/cli/commands/generate.d.ts +10 -0
  26. package/dist/cli/commands/generate.d.ts.map +1 -0
  27. package/dist/cli/commands/generate.js +76 -0
  28. package/dist/cli/commands/generate.js.map +1 -0
  29. package/dist/cli/commands/host.d.ts +8 -0
  30. package/dist/cli/commands/host.d.ts.map +1 -0
  31. package/dist/cli/commands/host.js +20 -0
  32. package/dist/cli/commands/host.js.map +1 -0
  33. package/dist/cli/commands/init.d.ts +16 -0
  34. package/dist/cli/commands/init.d.ts.map +1 -0
  35. package/dist/cli/commands/init.js +246 -0
  36. package/dist/cli/commands/init.js.map +1 -0
  37. package/dist/cli/commands/platform.d.ts +8 -0
  38. package/dist/cli/commands/platform.d.ts.map +1 -0
  39. package/dist/cli/commands/platform.js +20 -0
  40. package/dist/cli/commands/platform.js.map +1 -0
  41. package/dist/cli/commands/restart.d.ts +8 -0
  42. package/dist/cli/commands/restart.d.ts.map +1 -0
  43. package/dist/cli/commands/restart.js +13 -0
  44. package/dist/cli/commands/restart.js.map +1 -0
  45. package/dist/cli/commands/scaffold/frontend.d.ts +10 -0
  46. package/dist/cli/commands/scaffold/frontend.d.ts.map +1 -0
  47. package/dist/cli/commands/scaffold/frontend.js +130 -0
  48. package/dist/cli/commands/scaffold/frontend.js.map +1 -0
  49. package/dist/cli/commands/scaffold/react.d.ts +7 -0
  50. package/dist/cli/commands/scaffold/react.d.ts.map +1 -0
  51. package/dist/cli/commands/scaffold/react.js +12 -0
  52. package/dist/cli/commands/scaffold/react.js.map +1 -0
  53. package/dist/cli/commands/scale.d.ts +8 -0
  54. package/dist/cli/commands/scale.d.ts.map +1 -0
  55. package/dist/cli/commands/scale.js +13 -0
  56. package/dist/cli/commands/scale.js.map +1 -0
  57. package/dist/cli/commands/start.d.ts +10 -0
  58. package/dist/cli/commands/start.d.ts.map +1 -0
  59. package/dist/cli/commands/start.js +71 -0
  60. package/dist/cli/commands/start.js.map +1 -0
  61. package/dist/cli/commands/status.d.ts +11 -0
  62. package/dist/cli/commands/status.d.ts.map +1 -0
  63. package/dist/cli/commands/status.js +60 -0
  64. package/dist/cli/commands/status.js.map +1 -0
  65. package/dist/cli/commands/stop.d.ts +10 -0
  66. package/dist/cli/commands/stop.d.ts.map +1 -0
  67. package/dist/cli/commands/stop.js +89 -0
  68. package/dist/cli/commands/stop.js.map +1 -0
  69. package/dist/cli/util/config-discovery.d.ts +8 -0
  70. package/dist/cli/util/config-discovery.d.ts.map +1 -0
  71. package/dist/cli/util/config-discovery.js +70 -0
  72. package/dist/cli/util/config-discovery.js.map +1 -0
  73. package/dist/cli/util/config-patcher.d.ts +17 -0
  74. package/dist/cli/util/config-patcher.d.ts.map +1 -0
  75. package/dist/cli/util/config-patcher.js +439 -0
  76. package/dist/cli/util/config-patcher.js.map +1 -0
  77. package/dist/cli/util/frontend-dev.d.ts +8 -0
  78. package/dist/cli/util/frontend-dev.d.ts.map +1 -0
  79. package/dist/cli/util/frontend-dev.js +117 -0
  80. package/dist/cli/util/frontend-dev.js.map +1 -0
  81. package/dist/cli/util/process.d.ts +5 -0
  82. package/dist/cli/util/process.d.ts.map +1 -0
  83. package/dist/cli/util/process.js +17 -0
  84. package/dist/cli/util/process.js.map +1 -0
  85. package/dist/cli/util/templates.d.ts +10 -0
  86. package/dist/cli/util/templates.d.ts.map +1 -0
  87. package/dist/cli/util/templates.js +157 -0
  88. package/dist/cli/util/templates.js.map +1 -0
  89. package/dist/core/AlertSink.d.ts +83 -0
  90. package/dist/core/AlertSink.d.ts.map +1 -0
  91. package/dist/core/AlertSink.js +126 -0
  92. package/dist/core/AlertSink.js.map +1 -0
  93. package/dist/core/DirectMessageBus.d.ts +88 -0
  94. package/dist/core/DirectMessageBus.d.ts.map +1 -0
  95. package/dist/core/DirectMessageBus.js +352 -0
  96. package/dist/core/DirectMessageBus.js.map +1 -0
  97. package/dist/core/EndpointResolver.d.ts +111 -0
  98. package/dist/core/EndpointResolver.d.ts.map +1 -0
  99. package/dist/core/EndpointResolver.js +336 -0
  100. package/dist/core/EndpointResolver.js.map +1 -0
  101. package/dist/core/ForgeContext.d.ts +221 -0
  102. package/dist/core/ForgeContext.d.ts.map +1 -0
  103. package/dist/core/ForgeContext.js +1169 -0
  104. package/dist/core/ForgeContext.js.map +1 -0
  105. package/dist/core/ForgeEndpoints.d.ts +71 -0
  106. package/dist/core/ForgeEndpoints.d.ts.map +1 -0
  107. package/dist/core/ForgeEndpoints.js +442 -0
  108. package/dist/core/ForgeEndpoints.js.map +1 -0
  109. package/dist/core/ForgeHost.d.ts +82 -0
  110. package/dist/core/ForgeHost.d.ts.map +1 -0
  111. package/dist/core/ForgeHost.js +107 -0
  112. package/dist/core/ForgeHost.js.map +1 -0
  113. package/dist/core/ForgePlatform.d.ts +96 -0
  114. package/dist/core/ForgePlatform.d.ts.map +1 -0
  115. package/dist/core/ForgePlatform.js +136 -0
  116. package/dist/core/ForgePlatform.js.map +1 -0
  117. package/dist/core/ForgeWebSocket.d.ts +56 -0
  118. package/dist/core/ForgeWebSocket.d.ts.map +1 -0
  119. package/dist/core/ForgeWebSocket.js +415 -0
  120. package/dist/core/ForgeWebSocket.js.map +1 -0
  121. package/dist/core/Ingress.d.ts +329 -0
  122. package/dist/core/Ingress.d.ts.map +1 -0
  123. package/dist/core/Ingress.js +694 -0
  124. package/dist/core/Ingress.js.map +1 -0
  125. package/dist/core/Interceptors.d.ts +134 -0
  126. package/dist/core/Interceptors.d.ts.map +1 -0
  127. package/dist/core/Interceptors.js +416 -0
  128. package/dist/core/Interceptors.js.map +1 -0
  129. package/dist/core/Logger.d.ts +20 -0
  130. package/dist/core/Logger.d.ts.map +1 -0
  131. package/dist/core/Logger.js +77 -0
  132. package/dist/core/Logger.js.map +1 -0
  133. package/dist/core/MessageBus.d.ts +15 -0
  134. package/dist/core/MessageBus.d.ts.map +1 -0
  135. package/dist/core/MessageBus.js +18 -0
  136. package/dist/core/MessageBus.js.map +1 -0
  137. package/dist/core/Prometheus.d.ts +80 -0
  138. package/dist/core/Prometheus.d.ts.map +1 -0
  139. package/dist/core/Prometheus.js +332 -0
  140. package/dist/core/Prometheus.js.map +1 -0
  141. package/dist/core/RequestContext.d.ts +214 -0
  142. package/dist/core/RequestContext.d.ts.map +1 -0
  143. package/dist/core/RequestContext.js +556 -0
  144. package/dist/core/RequestContext.js.map +1 -0
  145. package/dist/core/Router.d.ts +45 -0
  146. package/dist/core/Router.d.ts.map +1 -0
  147. package/dist/core/Router.js +285 -0
  148. package/dist/core/Router.js.map +1 -0
  149. package/dist/core/RoutingStrategy.d.ts +116 -0
  150. package/dist/core/RoutingStrategy.d.ts.map +1 -0
  151. package/dist/core/RoutingStrategy.js +306 -0
  152. package/dist/core/RoutingStrategy.js.map +1 -0
  153. package/dist/core/RpcConfig.d.ts +72 -0
  154. package/dist/core/RpcConfig.d.ts.map +1 -0
  155. package/dist/core/RpcConfig.js +127 -0
  156. package/dist/core/RpcConfig.js.map +1 -0
  157. package/dist/core/SignatureCache.d.ts +81 -0
  158. package/dist/core/SignatureCache.d.ts.map +1 -0
  159. package/dist/core/SignatureCache.js +172 -0
  160. package/dist/core/SignatureCache.js.map +1 -0
  161. package/dist/core/StaticFileServer.d.ts +34 -0
  162. package/dist/core/StaticFileServer.d.ts.map +1 -0
  163. package/dist/core/StaticFileServer.js +497 -0
  164. package/dist/core/StaticFileServer.js.map +1 -0
  165. package/dist/core/Supervisor.d.ts +198 -0
  166. package/dist/core/Supervisor.d.ts.map +1 -0
  167. package/dist/core/Supervisor.js +1418 -0
  168. package/dist/core/Supervisor.js.map +1 -0
  169. package/dist/core/ThreadAllocator.d.ts +52 -0
  170. package/dist/core/ThreadAllocator.d.ts.map +1 -0
  171. package/dist/core/ThreadAllocator.js +174 -0
  172. package/dist/core/ThreadAllocator.js.map +1 -0
  173. package/dist/core/WorkerChannelManager.d.ts +130 -0
  174. package/dist/core/WorkerChannelManager.d.ts.map +1 -0
  175. package/dist/core/WorkerChannelManager.js +956 -0
  176. package/dist/core/WorkerChannelManager.js.map +1 -0
  177. package/dist/core/config-enums.d.ts +41 -0
  178. package/dist/core/config-enums.d.ts.map +1 -0
  179. package/dist/core/config-enums.js +59 -0
  180. package/dist/core/config-enums.js.map +1 -0
  181. package/dist/core/config.d.ts +159 -0
  182. package/dist/core/config.d.ts.map +1 -0
  183. package/dist/core/config.js +694 -0
  184. package/dist/core/config.js.map +1 -0
  185. package/dist/core/host-config.d.ts +146 -0
  186. package/dist/core/host-config.d.ts.map +1 -0
  187. package/dist/core/host-config.js +312 -0
  188. package/dist/core/host-config.js.map +1 -0
  189. package/dist/core/ipc-errors.d.ts +27 -0
  190. package/dist/core/ipc-errors.d.ts.map +1 -0
  191. package/dist/core/ipc-errors.js +36 -0
  192. package/dist/core/ipc-errors.js.map +1 -0
  193. package/dist/core/network-utils.d.ts +35 -0
  194. package/dist/core/network-utils.d.ts.map +1 -0
  195. package/dist/core/network-utils.js +145 -0
  196. package/dist/core/network-utils.js.map +1 -0
  197. package/dist/core/platform-config.d.ts +142 -0
  198. package/dist/core/platform-config.d.ts.map +1 -0
  199. package/dist/core/platform-config.js +299 -0
  200. package/dist/core/platform-config.js.map +1 -0
  201. package/dist/decorators/ServiceProxy.d.ts +175 -0
  202. package/dist/decorators/ServiceProxy.d.ts.map +1 -0
  203. package/dist/decorators/ServiceProxy.js +969 -0
  204. package/dist/decorators/ServiceProxy.js.map +1 -0
  205. package/dist/decorators/index.d.ts +146 -0
  206. package/dist/decorators/index.d.ts.map +1 -0
  207. package/dist/decorators/index.js +545 -0
  208. package/dist/decorators/index.js.map +1 -0
  209. package/dist/deploy/NginxGenerator.d.ts +165 -0
  210. package/dist/deploy/NginxGenerator.d.ts.map +1 -0
  211. package/dist/deploy/NginxGenerator.js +781 -0
  212. package/dist/deploy/NginxGenerator.js.map +1 -0
  213. package/dist/deploy/PlatformManifestGenerator.d.ts +43 -0
  214. package/dist/deploy/PlatformManifestGenerator.d.ts.map +1 -0
  215. package/dist/deploy/PlatformManifestGenerator.js +80 -0
  216. package/dist/deploy/PlatformManifestGenerator.js.map +1 -0
  217. package/dist/deploy/RouteManifestGenerator.d.ts +42 -0
  218. package/dist/deploy/RouteManifestGenerator.d.ts.map +1 -0
  219. package/dist/deploy/RouteManifestGenerator.js +105 -0
  220. package/dist/deploy/RouteManifestGenerator.js.map +1 -0
  221. package/dist/deploy/index.d.ts +210 -0
  222. package/dist/deploy/index.d.ts.map +1 -0
  223. package/dist/deploy/index.js +918 -0
  224. package/dist/deploy/index.js.map +1 -0
  225. package/dist/frontend/FrontendDevLifecycle.d.ts +26 -0
  226. package/dist/frontend/FrontendDevLifecycle.d.ts.map +1 -0
  227. package/dist/frontend/FrontendDevLifecycle.js +60 -0
  228. package/dist/frontend/FrontendDevLifecycle.js.map +1 -0
  229. package/dist/frontend/FrontendPluginOrchestrator.d.ts +64 -0
  230. package/dist/frontend/FrontendPluginOrchestrator.d.ts.map +1 -0
  231. package/dist/frontend/FrontendPluginOrchestrator.js +167 -0
  232. package/dist/frontend/FrontendPluginOrchestrator.js.map +1 -0
  233. package/dist/frontend/SiteResolver.d.ts +33 -0
  234. package/dist/frontend/SiteResolver.d.ts.map +1 -0
  235. package/dist/frontend/SiteResolver.js +53 -0
  236. package/dist/frontend/SiteResolver.js.map +1 -0
  237. package/dist/frontend/StaticMountRegistry.d.ts +36 -0
  238. package/dist/frontend/StaticMountRegistry.d.ts.map +1 -0
  239. package/dist/frontend/StaticMountRegistry.js +94 -0
  240. package/dist/frontend/StaticMountRegistry.js.map +1 -0
  241. package/dist/frontend/index.d.ts +7 -0
  242. package/dist/frontend/index.d.ts.map +1 -0
  243. package/{src → dist}/frontend/index.js +4 -2
  244. package/dist/frontend/index.js.map +1 -0
  245. package/dist/frontend/pathUtils.d.ts +8 -0
  246. package/dist/frontend/pathUtils.d.ts.map +1 -0
  247. package/dist/frontend/pathUtils.js +17 -0
  248. package/dist/frontend/pathUtils.js.map +1 -0
  249. package/dist/frontend/plugins/index.d.ts +2 -0
  250. package/dist/frontend/plugins/index.d.ts.map +1 -0
  251. package/{src → dist}/frontend/plugins/index.js +1 -1
  252. package/dist/frontend/plugins/index.js.map +1 -0
  253. package/dist/frontend/plugins/viteFrontend.d.ts +51 -0
  254. package/dist/frontend/plugins/viteFrontend.d.ts.map +1 -0
  255. package/dist/frontend/plugins/viteFrontend.js +134 -0
  256. package/dist/frontend/plugins/viteFrontend.js.map +1 -0
  257. package/dist/frontend/types.d.ts +25 -0
  258. package/dist/frontend/types.d.ts.map +1 -0
  259. package/dist/frontend/types.js +2 -0
  260. package/dist/frontend/types.js.map +1 -0
  261. package/dist/index.d.ts +17 -0
  262. package/dist/index.d.ts.map +1 -0
  263. package/dist/index.js +32 -0
  264. package/dist/index.js.map +1 -0
  265. package/dist/internals.d.ts +21 -0
  266. package/dist/internals.d.ts.map +1 -0
  267. package/{src → dist}/internals.js +12 -14
  268. package/dist/internals.js.map +1 -0
  269. package/dist/plugins/PluginManager.d.ts +209 -0
  270. package/dist/plugins/PluginManager.d.ts.map +1 -0
  271. package/dist/plugins/PluginManager.js +365 -0
  272. package/dist/plugins/PluginManager.js.map +1 -0
  273. package/dist/plugins/ScopedPostgres.d.ts +78 -0
  274. package/dist/plugins/ScopedPostgres.d.ts.map +1 -0
  275. package/dist/plugins/ScopedPostgres.js +190 -0
  276. package/dist/plugins/ScopedPostgres.js.map +1 -0
  277. package/dist/plugins/ScopedRedis.d.ts +88 -0
  278. package/dist/plugins/ScopedRedis.d.ts.map +1 -0
  279. package/dist/plugins/ScopedRedis.js +169 -0
  280. package/dist/plugins/ScopedRedis.js.map +1 -0
  281. package/dist/plugins/index.d.ts +289 -0
  282. package/dist/plugins/index.d.ts.map +1 -0
  283. package/dist/plugins/index.js +1942 -0
  284. package/dist/plugins/index.js.map +1 -0
  285. package/dist/plugins/types.d.ts +59 -0
  286. package/dist/plugins/types.d.ts.map +1 -0
  287. package/dist/plugins/types.js +2 -0
  288. package/dist/plugins/types.js.map +1 -0
  289. package/dist/registry/ServiceRegistry.d.ts +305 -0
  290. package/dist/registry/ServiceRegistry.d.ts.map +1 -0
  291. package/dist/registry/ServiceRegistry.js +735 -0
  292. package/dist/registry/ServiceRegistry.js.map +1 -0
  293. package/dist/scaling/ScaleAdvisor.d.ts +214 -0
  294. package/dist/scaling/ScaleAdvisor.d.ts.map +1 -0
  295. package/dist/scaling/ScaleAdvisor.js +526 -0
  296. package/dist/scaling/ScaleAdvisor.js.map +1 -0
  297. package/dist/services/Service.d.ts +164 -0
  298. package/dist/services/Service.d.ts.map +1 -0
  299. package/dist/services/Service.js +106 -0
  300. package/dist/services/Service.js.map +1 -0
  301. package/dist/services/worker-bootstrap.d.ts +15 -0
  302. package/dist/services/worker-bootstrap.d.ts.map +1 -0
  303. package/dist/services/worker-bootstrap.js +744 -0
  304. package/dist/services/worker-bootstrap.js.map +1 -0
  305. package/dist/templates/auth-service.d.ts +42 -0
  306. package/dist/templates/auth-service.d.ts.map +1 -0
  307. package/dist/templates/auth-service.js +54 -0
  308. package/dist/templates/auth-service.js.map +1 -0
  309. package/dist/templates/identity-service.d.ts +50 -0
  310. package/dist/templates/identity-service.d.ts.map +1 -0
  311. package/dist/templates/identity-service.js +62 -0
  312. package/dist/templates/identity-service.js.map +1 -0
  313. package/dist/types/contract.d.ts +120 -0
  314. package/dist/types/contract.d.ts.map +1 -0
  315. package/dist/types/contract.js +69 -0
  316. package/dist/types/contract.js.map +1 -0
  317. package/package.json +78 -20
  318. package/src/core/DirectMessageBus.js +0 -364
  319. package/src/core/EndpointResolver.js +0 -259
  320. package/src/core/ForgeContext.js +0 -2236
  321. package/src/core/ForgeHost.js +0 -122
  322. package/src/core/ForgePlatform.js +0 -145
  323. package/src/core/Ingress.js +0 -768
  324. package/src/core/Interceptors.js +0 -420
  325. package/src/core/MessageBus.js +0 -321
  326. package/src/core/Prometheus.js +0 -305
  327. package/src/core/RequestContext.js +0 -413
  328. package/src/core/RoutingStrategy.js +0 -330
  329. package/src/core/Supervisor.js +0 -1349
  330. package/src/core/ThreadAllocator.js +0 -196
  331. package/src/core/WorkerChannelManager.js +0 -879
  332. package/src/core/config.js +0 -637
  333. package/src/core/host-config.js +0 -311
  334. package/src/core/network-utils.js +0 -166
  335. package/src/core/platform-config.js +0 -308
  336. package/src/decorators/ServiceProxy.js +0 -904
  337. package/src/decorators/index.js +0 -571
  338. package/src/deploy/NginxGenerator.js +0 -865
  339. package/src/deploy/PlatformManifestGenerator.js +0 -96
  340. package/src/deploy/RouteManifestGenerator.js +0 -112
  341. package/src/deploy/index.js +0 -984
  342. package/src/frontend/FrontendDevLifecycle.js +0 -65
  343. package/src/frontend/FrontendPluginOrchestrator.js +0 -187
  344. package/src/frontend/SiteResolver.js +0 -63
  345. package/src/frontend/StaticMountRegistry.js +0 -90
  346. package/src/frontend/plugins/viteFrontend.js +0 -79
  347. package/src/frontend/types.js +0 -35
  348. package/src/index.js +0 -58
  349. package/src/plugins/PluginManager.js +0 -537
  350. package/src/plugins/ScopedPostgres.js +0 -192
  351. package/src/plugins/ScopedRedis.js +0 -142
  352. package/src/plugins/index.js +0 -1756
  353. package/src/registry/ServiceRegistry.js +0 -797
  354. package/src/scaling/ScaleAdvisor.js +0 -442
  355. package/src/services/Service.js +0 -195
  356. package/src/services/worker-bootstrap.js +0 -679
  357. package/src/templates/auth-service.js +0 -65
  358. package/src/templates/identity-service.js +0 -75
@@ -1,904 +0,0 @@
1
- /**
2
- * ServiceProxy v2
3
- *
4
- * Auto-generates proxy clients with:
5
- * - Pluggable routing strategies (round-robin, hash, least-pending)
6
- * - Interceptor chains (deadline, logging, circuit breaker, retry)
7
- * - Pending request tracking (for least-pending routing)
8
- * - Contract-based method validation
9
- */
10
-
11
- import { isPrivateNetwork, isTrustedProxy } from "../core/ForgeContext.js";
12
- import { AdaptiveConcurrencyLimiter } from "../core/Ingress.js";
13
- import {
14
- bulkheadInterceptor,
15
- CircuitBreaker,
16
- deadlineInterceptor,
17
- isRetryable,
18
- loggingInterceptor,
19
- metricsInterceptor,
20
- runInterceptorChain,
21
- } from "../core/Interceptors.js";
22
- import { RequestContext } from "../core/RequestContext.js";
23
- import { getContract } from "./index.js";
24
-
25
- const _WIRED_SERVICES = new WeakSet();
26
-
27
- // ─── LRU-style cache with oldest-20% eviction ────────────────────────
28
- const MAX_CACHE_SIZE = 1000;
29
- const EVICT_COUNT = Math.floor(MAX_CACHE_SIZE * 0.2); // 200
30
-
31
- /**
32
- * Evict the oldest 20% of entries from a Map whose values are
33
- * `{ fn, order }` objects. Entries are sorted by insertion order
34
- * and the lowest-order entries are removed.
35
- */
36
- function evictOldest(cache) {
37
- if (cache.size <= MAX_CACHE_SIZE) return;
38
- // Map iteration order is insertion order, so the first EVICT_COUNT entries are oldest
39
- let removed = 0;
40
- for (const key of cache.keys()) {
41
- if (removed >= EVICT_COUNT) break;
42
- cache.delete(key);
43
- removed++;
44
- }
45
- }
46
-
47
- // ─── Simple token-bucket rate limiter (per-route, per-IP) ────────────
48
- const _rateLimitBuckets = new Map();
49
- const MAX_RATE_LIMIT_BUCKETS = 100_000;
50
-
51
- const _rateLimitCleanupTimer = setInterval(() => {
52
- const now = Date.now();
53
- for (const [key, bucket] of _rateLimitBuckets) {
54
- if (now - bucket.windowStart > 120_000) {
55
- _rateLimitBuckets.delete(key);
56
- }
57
- }
58
- }, 60_000);
59
- _rateLimitCleanupTimer.unref?.();
60
-
61
- function parseRateLimit(spec) {
62
- const m = /^(\d+)\/(sec|min|hour)$/i.exec(spec);
63
- if (!m) return null;
64
- const limit = parseInt(m[1], 10);
65
- const windowMs = { sec: 1000, min: 60_000, hour: 3_600_000 }[m[2].toLowerCase()];
66
- return { limit, windowMs };
67
- }
68
-
69
- let _lastEvictionCheck = 0;
70
-
71
- function checkRateLimit(routeKey, ip, config) {
72
- const now = Date.now();
73
- if (_rateLimitBuckets.size > MAX_RATE_LIMIT_BUCKETS && now - _lastEvictionCheck >= 10_000) {
74
- _lastEvictionCheck = now;
75
- setImmediate(() => {
76
- const cutoff = Date.now();
77
- for (const [k, b] of _rateLimitBuckets) {
78
- if (cutoff - b.windowStart > 120_000) _rateLimitBuckets.delete(k);
79
- }
80
- });
81
- }
82
- const bucketKey = `${routeKey}:${ip}`;
83
- let bucket = _rateLimitBuckets.get(bucketKey);
84
- if (!bucket || now - bucket.windowStart >= config.windowMs) {
85
- bucket = { count: 1, windowStart: now };
86
- _rateLimitBuckets.set(bucketKey, bucket);
87
- return true;
88
- }
89
- bucket.count++;
90
- return bucket.count <= config.limit;
91
- }
92
-
93
- /** Sentinel returned by handleProxyRequest when the message isn't a proxy request. */
94
- export const NOT_HANDLED = Symbol("NOT_HANDLED");
95
-
96
- /**
97
- * Per-target concurrency limiters, shared across all proxies on this node.
98
- * @type {Map<string, AdaptiveConcurrencyLimiter>}
99
- */
100
- const _concurrencyLimiters = new Map();
101
-
102
- function getConcurrencyLimiter(targetName, config = {}) {
103
- if (!_concurrencyLimiters.has(targetName)) {
104
- _concurrencyLimiters.set(targetName, new AdaptiveConcurrencyLimiter(targetName, config));
105
- }
106
- return _concurrencyLimiters.get(targetName);
107
- }
108
-
109
- /** Expose for status endpoints */
110
- export function getAllConcurrencyStats() {
111
- const stats = {};
112
- for (const [name, limiter] of _concurrencyLimiters) {
113
- stats[name] = limiter.stats;
114
- }
115
- return stats;
116
- }
117
-
118
- /**
119
- * Build proxy clients for all services this service connects to.
120
- *
121
- * @param {ForgeContext} ctx - The calling service's context
122
- * @param {Map<string, Function>} serviceClasses - service name → ServiceClass
123
- * @param {Map<string, {service, ctx}>} localServices - Colocated instances
124
- * @param {Object} [options]
125
- * @param {Object} [options.interceptors] - Default interceptors for all proxies
126
- * @returns {Object} Map of service name → proxy client
127
- */
128
- export function buildServiceProxies(ctx, serviceClasses, localServices = new Map(), options = {}) {
129
- const proxies = {};
130
-
131
- for (const [serviceName, ServiceClass] of serviceClasses) {
132
- if (serviceName === ctx.serviceName) continue;
133
-
134
- const contract = getContract(ServiceClass);
135
- proxies[serviceName] = createServiceProxy(
136
- ctx,
137
- serviceName,
138
- contract,
139
- localServices.get(serviceName) ?? null,
140
- options,
141
- );
142
- }
143
-
144
- return proxies;
145
- }
146
-
147
- /**
148
- * Create a proxy client for a single target service.
149
- */
150
- export function createServiceProxy(ctx, targetName, contract, localInstance, options) {
151
- // Wire routing strategy to EndpointResolver if configured
152
- const routingStrategy = contract?.routingStrategy ?? options.routingStrategy;
153
- if (routingStrategy && ctx._endpointResolver) {
154
- ctx._endpointResolver.setStrategy(targetName, routingStrategy);
155
- }
156
-
157
- // Build interceptor chain (retry is handled as an outer loop, not in the chain)
158
- const interceptors = buildInterceptors(ctx, targetName, contract, options);
159
-
160
- // Retry config (service-level default; per-method overrides in createProxiedMethod)
161
- const retryConfig = contract?.retry ?? options.retry;
162
-
163
- // Circuit breakers are per-endpoint (host:port), so different endpoints
164
- // can trip independently. Colocated calls share a single CB per service.
165
- const cbOptions = contract?.circuitBreaker ?? options.circuitBreaker ?? {};
166
- const MAX_ENDPOINT_CBS = 100;
167
- /** @type {Map<string, CircuitBreaker>} endpoint string → CB instance */
168
- const endpointCircuitBreakers = new Map();
169
- // Shared CB for colocated (non-network) calls
170
- const colocatedCircuitBreaker = new CircuitBreaker(cbOptions);
171
-
172
- /**
173
- * Get or create a circuit breaker for a specific endpoint.
174
- * @param {string|null} endpointKey - e.g. "192.168.1.5:4001", or null for colocated
175
- * @returns {CircuitBreaker}
176
- */
177
- function getCircuitBreaker(endpointKey) {
178
- if (!endpointKey) return colocatedCircuitBreaker;
179
- let cb = endpointCircuitBreakers.get(endpointKey);
180
- if (!cb) {
181
- // Cap to prevent unbounded growth
182
- if (endpointCircuitBreakers.size >= MAX_ENDPOINT_CBS) {
183
- // Evict the oldest entry
184
- const firstKey = endpointCircuitBreakers.keys().next().value;
185
- endpointCircuitBreakers.delete(firstKey);
186
- }
187
- cb = new CircuitBreaker(cbOptions);
188
- endpointCircuitBreakers.set(endpointKey, cb);
189
- }
190
- return cb;
191
- }
192
-
193
- /** Remove a stale endpoint's CB */
194
- function removeEndpointCB(endpointKey) {
195
- endpointCircuitBreakers.delete(endpointKey);
196
- }
197
-
198
- if (contract) {
199
- return buildContractProxy(
200
- ctx,
201
- targetName,
202
- contract,
203
- localInstance,
204
- interceptors,
205
- { getCircuitBreaker, removeEndpointCB, colocatedCircuitBreaker, endpointCircuitBreakers },
206
- retryConfig,
207
- );
208
- }
209
-
210
- return buildDynamicProxy(ctx, targetName, localInstance, interceptors, { getCircuitBreaker, removeEndpointCB, colocatedCircuitBreaker, endpointCircuitBreakers }, retryConfig);
211
- }
212
-
213
- /**
214
- * Build the interceptor chain for a proxy.
215
- *
216
- * Returns an array with a null placeholder at index 1 for the
217
- * per-attempt circuit breaker interceptor. This avoids allocating
218
- * a new array on every call — the caller just swaps slot [1].
219
- *
220
- * Layout: [deadline, CB_SLOT, concurrency, metrics?, logging]
221
- */
222
- function buildInterceptors(ctx, targetName, contract, options) {
223
- const chain = [];
224
-
225
- // Deadline propagation (always on)
226
- const timeout = contract?.defaultTimeout ?? options.defaultTimeout ?? 5000;
227
- chain.push(deadlineInterceptor(timeout));
228
-
229
- // Slot [1] reserved for circuit breaker (swapped per-attempt)
230
- chain.push(null);
231
-
232
- // Bulkhead: limits concurrent outgoing calls per service
233
- const bulkheadConfig = contract?.bulkhead ?? options.bulkhead ?? {};
234
- chain.push(bulkheadInterceptor(bulkheadConfig));
235
-
236
- // Adaptive concurrency limiting (always on for non-local services)
237
- const concurrencyConfig = contract?.concurrency ?? options.concurrency ?? {};
238
- const limiter = getConcurrencyLimiter(targetName, concurrencyConfig);
239
- chain.push(concurrencyInterceptor(limiter));
240
-
241
- // Metrics (always on if available)
242
- if (ctx.metrics) {
243
- chain.push(metricsInterceptor(ctx.metrics));
244
- }
245
-
246
- // Logging (debug level)
247
- chain.push(loggingInterceptor(ctx.logger));
248
-
249
- return chain;
250
- }
251
-
252
- /**
253
- * Adaptive concurrency interceptor.
254
- * Automatically wraps every proxy call with concurrency limiting.
255
- * No manual wrapping needed — it's invisible to the developer.
256
- */
257
- function concurrencyInterceptor(limiter) {
258
- return async function adaptiveConcurrency(callCtx, next) {
259
- const slot = limiter.tryAcquire();
260
-
261
- if (!slot.acquired) {
262
- const err = new Error(
263
- `Service "${callCtx.target}" at concurrency limit (${limiter.limit}). ` +
264
- `${limiter.inFlight} in flight. Rejecting ${callCtx.method}.`,
265
- );
266
- err.code = "CONCURRENCY_LIMIT";
267
- err.statusCode = 503;
268
- throw err;
269
- }
270
-
271
- try {
272
- const result = await next();
273
- slot.release(true);
274
- return result;
275
- } catch (err) {
276
- slot.release(false);
277
- throw err;
278
- }
279
- };
280
- }
281
-
282
- /**
283
- * Build a contract-based proxy with typed methods.
284
- */
285
- function buildContractProxy(
286
- ctx,
287
- targetName,
288
- contract,
289
- localInstance,
290
- interceptors,
291
- cbBag,
292
- retryConfig,
293
- ) {
294
- const proxy = {};
295
-
296
- for (const [methodName, meta] of contract.methods) {
297
- proxy[methodName] = createProxiedMethod(
298
- ctx,
299
- targetName,
300
- methodName,
301
- meta,
302
- localInstance,
303
- contract,
304
- interceptors,
305
- cbBag,
306
- retryConfig,
307
- );
308
- }
309
-
310
- // Generic call method with private method guard, contract validation, and caching
311
- const $callCache = new Map();
312
- proxy.$call = (methodName, ...args) => {
313
- if (typeof methodName !== 'string' || methodName.startsWith('_')) {
314
- throw new Error(`Cannot call private method "${methodName}" via $call`);
315
- }
316
- if (contract && !contract.methods.has(methodName)) {
317
- throw new Error(`Method "${methodName}" is not exposed on ${targetName}`);
318
- }
319
- evictOldest($callCache);
320
- let fn = $callCache.get(methodName);
321
- if (!fn) {
322
- fn = createProxiedMethod(
323
- ctx,
324
- targetName,
325
- methodName,
326
- {},
327
- localInstance,
328
- contract,
329
- interceptors,
330
- cbBag,
331
- retryConfig,
332
- );
333
- $callCache.set(methodName, fn);
334
- }
335
- return fn(...args);
336
- };
337
-
338
- // Invalidate method cache for hot-reload scenarios
339
- proxy.$invalidate = () => {
340
- $callCache.clear();
341
- for (const key of Object.keys(proxy)) {
342
- if (key.startsWith('$')) continue;
343
- delete proxy[key];
344
- }
345
- // Re-create proxied methods from contract
346
- for (const [methodName, meta] of contract.methods) {
347
- proxy[methodName] = createProxiedMethod(
348
- ctx, targetName, methodName, meta, localInstance, contract,
349
- interceptors, cbBag, retryConfig,
350
- );
351
- }
352
- };
353
-
354
- // Metadata
355
- proxy.$name = targetName;
356
- proxy.$methods = [...contract.methods.keys()];
357
- proxy.$isLocal = !!localInstance;
358
- proxy.$circuitBreaker = cbBag.colocatedCircuitBreaker;
359
- proxy.$endpointCircuitBreakers = cbBag.endpointCircuitBreakers;
360
-
361
- return proxy;
362
- }
363
-
364
- /**
365
- * Build a dynamic proxy (no contract — any method name accepted).
366
- */
367
- function buildDynamicProxy(ctx, targetName, localInstance, interceptors, cbBag, retryConfig) {
368
- const methodCache = new Map();
369
- return new Proxy(
370
- {},
371
- {
372
- get(_, methodName) {
373
- if (methodName === "$name") return targetName;
374
- if (methodName === "$isLocal") return !!localInstance;
375
- if (methodName === "$circuitBreaker") return cbBag.colocatedCircuitBreaker;
376
- if (methodName === "$endpointCircuitBreakers") return cbBag.endpointCircuitBreakers;
377
- if (methodName === "$invalidate") return () => { methodCache.clear(); };
378
- if (methodName === "then") return undefined;
379
-
380
- evictOldest(methodCache);
381
- if (methodCache.has(methodName)) return methodCache.get(methodName);
382
- const fn = createProxiedMethod(
383
- ctx,
384
- targetName,
385
- methodName,
386
- {},
387
- localInstance,
388
- null,
389
- interceptors,
390
- cbBag,
391
- retryConfig,
392
- );
393
- methodCache.set(methodName, fn);
394
- return fn;
395
- },
396
- },
397
- );
398
- }
399
-
400
- /**
401
- * Create a single proxied method with full interceptor + routing support.
402
- *
403
- * Retry is implemented as an outer loop around the interceptor chain so that
404
- * each attempt gets a fresh chain execution (deadline, circuit breaker,
405
- * metrics, logging all run on every attempt).
406
- */
407
- function createProxiedMethod(
408
- ctx,
409
- targetName,
410
- methodName,
411
- meta,
412
- localInstance,
413
- contract,
414
- interceptors,
415
- cbBag,
416
- retryConfig,
417
- ) {
418
- // Resolve retry settings: per-method overrides service-level
419
- const methodRetry = meta.options?.retry ?? retryConfig;
420
- const retryDisabled = methodRetry === false;
421
- const maxAttempts = retryDisabled ? 1 : (methodRetry?.maxAttempts ?? 3);
422
- const baseDelayMs = retryDisabled ? 0 : (methodRetry?.baseDelayMs ?? 100);
423
- const maxDelayMs = retryDisabled ? 0 : (methodRetry?.maxDelayMs ?? 2000);
424
- const idempotent = meta.options?.idempotent;
425
-
426
- return async (...args) => {
427
- // Enforce localOnly: reject remote calls for methods marked localOnly
428
- if (meta.options?.localOnly && !localInstance) {
429
- throw new Error(`Method '${methodName}' on service '${targetName}' is marked localOnly and cannot be called remotely`);
430
- }
431
-
432
- // Capture the current RequestContext (from the calling service's request)
433
- const rctx = RequestContext.current();
434
-
435
- /** @type {CallContext} */
436
- const callCtx = {
437
- from: ctx.serviceName,
438
- target: targetName,
439
- method: methodName,
440
- args,
441
- deadline: rctx?.deadline ?? null,
442
- timeout: meta.options?.timeout ?? 5000,
443
- metadata: Object.create(null),
444
- attempt: 0,
445
- // Propagated context
446
- correlationId: rctx?.correlationId ?? null,
447
- traceId: rctx?.traceId ?? null,
448
- auth: rctx?.auth ?? null,
449
- };
450
-
451
- const startTime = performance.now();
452
- let lastError;
453
-
454
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
455
- callCtx.attempt = attempt;
456
-
457
- // Resolve endpoint ONCE per attempt — used for both circuit breaker
458
- // selection and the actual HTTP call. This avoids round-robin advancing
459
- // twice, which would cause circuit breaker state to accumulate against
460
- // a different endpoint than the one actually called.
461
- let endpointKey = null;
462
- let resolvedEndpoint = null;
463
- if (!localInstance) {
464
- resolvedEndpoint = ctx._endpointResolver?.resolve(targetName) ?? null;
465
- if (resolvedEndpoint) {
466
- endpointKey = `${resolvedEndpoint.host}:${resolvedEndpoint.port}`;
467
- } else {
468
- const port = ctx._servicePorts?.[targetName];
469
- if (port) endpointKey = `127.0.0.1:${port}`;
470
- }
471
- // COR-C1: Notify strategy of pending request (enables LeastPendingStrategy)
472
- if (endpointKey && ctx._endpointResolver) {
473
- ctx._endpointResolver.acquireEndpoint(targetName, endpointKey);
474
- }
475
- }
476
- callCtx.resolvedEndpoint = resolvedEndpoint;
477
- const circuitBreaker = cbBag.getCircuitBreaker(endpointKey);
478
-
479
- // Abort early if circuit breaker is open and not yet ready for a probe
480
- if (circuitBreaker.state === 'open' && Date.now() < circuitBreaker.nextAttemptTime) {
481
- const err = new Error(
482
- `Circuit breaker OPEN for ${targetName}.${methodName}` +
483
- ` — service appears unavailable. Retry after ${new Date(circuitBreaker.nextAttemptTime).toISOString()}`,
484
- );
485
- err.code = 'CIRCUIT_OPEN';
486
- throw err;
487
- }
488
-
489
- // Build per-attempt interceptor chain with the correct circuit breaker.
490
- // Must copy because concurrent calls share the `interceptors` template —
491
- // mutating slot [1] in-place would race with other in-flight chains.
492
- const attemptChain = interceptors.slice();
493
- attemptChain[1] = circuitBreaker.createInterceptor();
494
-
495
- try {
496
- const result = await runInterceptorChain(attemptChain, callCtx, async (finalCtx) => {
497
- // === Colocated: direct function call with context propagation ===
498
- if (localInstance) {
499
- // Validate method is exposed in the contract (prevents calling private methods via colocated dispatch)
500
- if (contract && !contract.methods.has(methodName)) {
501
- throw new Error(`Method '${methodName}' is not exposed on service '${targetName}'`);
502
- }
503
-
504
- const method = localInstance.service[methodName];
505
- if (typeof method === "function") {
506
- const callFn = () => {
507
- if (rctx) {
508
- const childCtx = rctx.child(targetName, methodName);
509
- if (finalCtx.deadline) childCtx.deadline = finalCtx.deadline;
510
- return RequestContext.run(childCtx, () => method.apply(localInstance.service, args));
511
- }
512
- return method.apply(localInstance.service, args);
513
- };
514
-
515
- if (finalCtx.deadline) {
516
- const timeLeft = finalCtx.deadline - Date.now();
517
- if (timeLeft <= 0) throw new Error(`Deadline exceeded for ${targetName}.${methodName}`);
518
- let timer = null;
519
- const timeoutPromise = new Promise((_, reject) => {
520
- timer = setTimeout(() => reject(new Error(`Deadline exceeded for colocated call ${targetName}.${methodName}`)), timeLeft);
521
- });
522
- return Promise.race([callFn(), timeoutPromise]).finally(() => { if (timer) clearTimeout(timer); });
523
- }
524
- return callFn();
525
- }
526
- throw new Error(`${targetName} has no method "${methodName}"`);
527
- }
528
-
529
- // === Remote: HTTP invoke when endpoint is known, otherwise IPC request fallback ===
530
- // Calculate effective timeout respecting deadline.
531
- let effectiveTimeout = finalCtx.timeout ?? 5000;
532
- if (finalCtx.deadline) {
533
- const remaining = finalCtx.deadline - Date.now();
534
- if (remaining <= 0) throw new Error(`Deadline exceeded for ${targetName}.${methodName}`);
535
- effectiveTimeout = Math.min(effectiveTimeout, remaining);
536
- }
537
-
538
- let targetHost = "127.0.0.1";
539
- let targetPort;
540
-
541
- // Use the endpoint resolved once at the top of this attempt
542
- // (stored in callCtx) to avoid double round-robin advancement.
543
- const endpoint = finalCtx.resolvedEndpoint;
544
- if (endpoint) {
545
- targetHost = endpoint.host;
546
- targetPort = endpoint.port;
547
- } else {
548
- targetPort = ctx._servicePorts?.[targetName];
549
- }
550
-
551
- if (!targetPort) {
552
- // Internal/background services may not expose HTTP ports. Use IPC request path.
553
- if (typeof ctx.request === "function") {
554
- const payload = {
555
- __forge_method: methodName,
556
- __forge_args: args,
557
- };
558
- if (finalCtx.deadline) {
559
- payload.__forge_deadline = finalCtx.deadline;
560
- }
561
- return ctx.request(targetName, payload, effectiveTimeout);
562
- }
563
- throw new Error(
564
- `No endpoint known for service "${targetName}". ` +
565
- `Check that it's listed in forge.config and has a port.`,
566
- );
567
- }
568
-
569
- const headers = { "Content-Type": "application/json" };
570
-
571
- if (rctx) {
572
- Object.assign(headers, rctx.toHeaders());
573
- // Override deadline with remaining time from callCtx (shrinks on retry)
574
- if (finalCtx.deadline) {
575
- const remaining = finalCtx.deadline - Date.now();
576
- if (remaining <= 0) {
577
- throw new Error(`Deadline exceeded for ${targetName}.${methodName}`);
578
- }
579
- headers["x-forge-deadline"] = String(remaining);
580
- }
581
- }
582
-
583
- const resp = await fetch(`http://${targetHost}:${targetPort}/__forge/invoke`, {
584
- method: "POST",
585
- headers,
586
- body: JSON.stringify({
587
- method: methodName,
588
- args,
589
- }),
590
- signal: AbortSignal.timeout(effectiveTimeout),
591
- });
592
-
593
- // M-11: Enforce response body size limit to prevent OOM from oversized responses
594
- const contentLength = resp.headers.get('content-length');
595
- if (contentLength && parseInt(contentLength, 10) > 10 * 1024 * 1024) {
596
- throw new Error(`Response from ${targetHost}:${targetPort} exceeds 10MB limit`);
597
- }
598
- const data = await resp.json();
599
- if (data.error) {
600
- const err = new Error(data.error);
601
- err.statusCode = resp.status;
602
- throw err;
603
- }
604
-
605
- return data.result;
606
- });
607
-
608
- return result;
609
- } catch (err) {
610
- lastError = err;
611
-
612
- // Never retry circuit-open errors — the breaker is tripped
613
- if (err.code === 'CIRCUIT_OPEN') {
614
- throw err;
615
- }
616
-
617
- if (!isRetryable(err, idempotent) || attempt >= maxAttempts - 1) {
618
- throw err;
619
- }
620
-
621
- // Check deadline before retrying
622
- if (callCtx.deadline && Date.now() >= callCtx.deadline) {
623
- throw new Error(`Deadline exceeded after ${attempt + 1} attempts to ${callCtx.target}.${callCtx.method}`);
624
- }
625
-
626
- // Exponential backoff with jitter, capped to remaining deadline
627
- let delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt + Math.random() * 100);
628
- if (callCtx.deadline) {
629
- const timeLeft = callCtx.deadline - Date.now();
630
- if (timeLeft <= 0) {
631
- throw new Error(`Deadline exceeded after ${attempt + 1} attempts to ${callCtx.target}.${callCtx.method}`);
632
- }
633
- delay = Math.min(delay, timeLeft);
634
- }
635
-
636
- await new Promise((resolve) => setTimeout(resolve, delay));
637
- } finally {
638
- // COR-C1: Release pending slot so LeastPendingStrategy sees accurate counts
639
- if (!localInstance && endpointKey && ctx._endpointResolver) {
640
- ctx._endpointResolver.releaseEndpoint(targetName, endpointKey);
641
- }
642
- }
643
- }
644
-
645
- throw lastError;
646
- };
647
- }
648
-
649
- // ─── Request Handler (receiving side) ───────────────────────
650
-
651
- /**
652
- * Handle an incoming proxy-style RPC request on the receiving service.
653
- *
654
- * Detects the __forge_method convention, checks deadlines,
655
- * validates the method is exposed, and dispatches.
656
- */
657
- export function handleProxyRequest(service, from, payload) {
658
- if (payload?.__forge_method) {
659
- const methodName = payload.__forge_method;
660
- const args = payload.__forge_args ?? [];
661
-
662
- // Check deadline propagation (value is an absolute timestamp in ms)
663
- let absoluteDeadline = null;
664
- if (payload.__forge_deadline) {
665
- absoluteDeadline = Number(payload.__forge_deadline);
666
- const remaining = absoluteDeadline - Date.now();
667
- if (remaining <= 0) {
668
- throw new Error(`Deadline already exceeded for ${service.ctx?.serviceName}.${methodName}` + ` (caller: ${from})`);
669
- }
670
- }
671
-
672
- // Contract/whitelist check first — reject unexposed methods before probing internals
673
- const contract = getContract(service.constructor);
674
- if (contract && !contract.methods.has(methodName)) {
675
- throw new Error(`Method "${methodName}" is not exposed on this service`);
676
- }
677
-
678
- // Reject private methods when there is no contract to gate access
679
- if (!contract && methodName.startsWith('_')) {
680
- throw new Error(`Method "${methodName}" is private and cannot be invoked remotely`);
681
- }
682
-
683
- const method = service[methodName];
684
- if (typeof method !== "function") {
685
- throw new Error(
686
- `Service "${service.ctx?.serviceName}" has no method "${methodName}". ` +
687
- `Available: ${getExposedMethods(service).join(", ") || "none"}`,
688
- );
689
- }
690
-
691
- // Enforce deadline during execution if set
692
- if (absoluteDeadline) {
693
- const remaining = absoluteDeadline - Date.now();
694
- if (remaining <= 0) {
695
- throw new Error(`Deadline already exceeded for ${service.ctx?.serviceName}.${methodName} (caller: ${from})`);
696
- }
697
- let timer = null;
698
- const timeoutPromise = new Promise((_, reject) => {
699
- timer = setTimeout(() => reject(new Error('Deadline exceeded during execution')), remaining);
700
- if (timer) timer.unref();
701
- });
702
- // Re-check deadline immediately before race to close the window
703
- const currentRemaining = absoluteDeadline - Date.now();
704
- if (currentRemaining <= 0) {
705
- clearTimeout(timer);
706
- throw new Error('Deadline exceeded before execution start');
707
- }
708
- return Promise.race([method.apply(service, args), timeoutPromise]).finally(() => { if (timer) clearTimeout(timer); });
709
- }
710
-
711
- return method.apply(service, args);
712
- }
713
-
714
- return NOT_HANDLED;
715
- }
716
-
717
- function getExposedMethods(service) {
718
- const contract = getContract(service.constructor);
719
- if (contract) return [...contract.methods.keys()];
720
-
721
- return Object.getOwnPropertyNames(Object.getPrototypeOf(service)).filter(
722
- (m) => m !== "constructor" && !m.startsWith("_") && typeof service[m] === "function",
723
- );
724
- }
725
-
726
- /**
727
- * Auto-register HTTP routes from @Route decorator / contract metadata.
728
- *
729
- * For every route declared via `@Route(method, path)` or the plain-JS
730
- * `static contract = { routes: [...] }`, this function registers the
731
- * corresponding handler on the service's HTTP router.
732
- *
733
- * **Contract route handler signature:**
734
- *
735
- * async handler(body, params, query) -> result
736
- *
737
- * - `body` — parsed request body (`req.body`)
738
- * - `params` — URL path parameters (`req.params`), e.g. `{ id: '42' }` for `/users/:id`
739
- * - `query` — query-string parameters (`req.query`), e.g. `{ page: '2' }`
740
- * - Return value is automatically serialized as JSON via `res.json(result)`.
741
- * POST routes respond with 201; all other methods respond with 200.
742
- * Errors are caught and returned as `{ error: message }` with 404 (if the
743
- * message contains "not found") or 500.
744
- *
745
- * **How this differs from manual `ctx.router` routes:**
746
- *
747
- * When you register routes manually in `onStart(ctx)`, the handler receives
748
- * the raw `(req, res)` pair and you are responsible for sending the response:
749
- *
750
- * ctx.router.get('/health', (req, res) => {
751
- * res.json({ ok: true });
752
- * });
753
- *
754
- * Contract-based handlers are higher-level: the framework supplies the
755
- * individual pieces of the request as arguments and handles serialization
756
- * and status codes for you.
757
- *
758
- * @example
759
- * // ── Contract / decorator approach ──────────────────────
760
- * // Handler receives (body, params, query) and returns a value.
761
- *
762
- * class UserService extends Service {
763
- * @Expose()
764
- * @Route('GET', '/users/:id')
765
- * async getUser(body, params, query) {
766
- * return this.db.findUser(params.id); // auto-serialized, 200 OK
767
- * }
768
- *
769
- * @Expose()
770
- * @Route('POST', '/users')
771
- * async createUser(body, params, query) {
772
- * return this.db.insert(body); // auto-serialized, 201 Created
773
- * }
774
- * }
775
- *
776
- * // ── Manual approach (in onStart) ──────────────────────
777
- * // Handler receives (req, res) and must call res.json() itself.
778
- *
779
- * async onStart(ctx) {
780
- * ctx.router.get('/users/:id', async (req, res) => {
781
- * const user = await this.db.findUser(req.params.id);
782
- * res.json(user); // you choose the status code
783
- * });
784
- * }
785
- *
786
- * @param {Service} service - The service instance whose routes to register
787
- * @param {ForgeContext} ctx - The service's ForgeContext (must be type 'edge')
788
- */
789
- export function autoRegisterRoutes(service, ctx) {
790
- const contract = getContract(service.constructor);
791
- if (!contract || contract.routes.length === 0) return;
792
-
793
- for (const route of contract.routes) {
794
- const handler = service[route.handlerName];
795
- if (typeof handler !== "function") {
796
- throw new Error(`Route handler "${route.handlerName}" not found on service`);
797
- }
798
-
799
- if (ctx.serviceType === "edge") {
800
- const method = route.httpMethod.toLowerCase();
801
- const routeRateLimit = route.rateLimit ? parseRateLimit(route.rateLimit) : null;
802
- const routeKey = `${method}:${route.path}`;
803
-
804
- ctx.router[method](route.path, async (req, res) => {
805
- const rctx = req.ctx ?? (req.headers ? RequestContext.fromPropagation(req.headers) : new RequestContext());
806
-
807
- // @Auth enforcement -- defense-in-depth even without ForgeProxy
808
- if (route.auth) {
809
- if (!rctx.auth) {
810
- res.json({ error: "Authentication required" }, 401);
811
- return;
812
- }
813
- if (Array.isArray(route.auth.roles) && route.auth.roles.length > 0) {
814
- if (!route.auth.roles.includes(rctx.auth.role)) {
815
- res.json({ error: "Insufficient permissions" }, 403);
816
- return;
817
- }
818
- }
819
- }
820
-
821
- // @RateLimit enforcement -- per-route, per-IP token bucket
822
- if (routeRateLimit) {
823
- const rawAddr = req.socket?.remoteAddress ?? "";
824
- let ip;
825
- // Trust X-Forwarded-For only from explicitly trusted proxies (FORGE_TRUSTED_PROXIES),
826
- // or fall back to any private network if FORGE_TRUSTED_PROXIES is not configured.
827
- const trustXff = process.env.FORGE_TRUSTED_PROXIES
828
- ? isTrustedProxy(rawAddr)
829
- : isPrivateNetwork(rawAddr);
830
- if (trustXff) {
831
- ip = req.headers?.["x-forwarded-for"]?.split(",")[0]?.trim() || rawAddr;
832
- } else {
833
- ip = rawAddr || "unknown";
834
- }
835
- if (!checkRateLimit(routeKey, ip, routeRateLimit)) {
836
- res.json({ error: "Rate limit exceeded" }, 429);
837
- return;
838
- }
839
- }
840
-
841
- await RequestContext.run(rctx, async () => {
842
- try {
843
- const result = await handler.call(service, req.body, req.params, req.query);
844
- const statusCode = route.httpMethod === "POST" ? 201 : 200;
845
- res.json(result, statusCode);
846
- } catch (err) {
847
- let message;
848
- let code;
849
-
850
- if (err == null) {
851
- message = "Unknown error";
852
- code = 500;
853
- } else if (typeof err === 'string') {
854
- message = err;
855
- code = 500;
856
- } else {
857
- message = err.message ?? "Unknown error";
858
- if (err.statusCode) {
859
- code = err.statusCode;
860
- } else if (message.toLowerCase().includes("not found")) {
861
- code = 404;
862
- } else {
863
- code = 500;
864
- }
865
- }
866
-
867
- // Don't leak internals on 5xx errors
868
- if (code >= 500) {
869
- res.json({ error: "Internal server error" }, code);
870
- } else {
871
- res.json({ error: message }, code);
872
- }
873
- }
874
- });
875
- });
876
- }
877
- }
878
- }
879
-
880
- /**
881
- * Auto-wire event subscriptions from @On decorators.
882
- */
883
- export function autoWireSubscriptions(service, _ctx) {
884
- if (_WIRED_SERVICES.has(service)) return; // Prevent double-wrapping on hot reload
885
-
886
- const contract = getContract(service.constructor);
887
- if (!contract || contract.subscriptions.length === 0) return;
888
-
889
- _WIRED_SERVICES.add(service);
890
- const originalOnMessage = service.onMessage.bind(service);
891
-
892
- service.onMessage = async (from, payload) => {
893
- if (payload?.__forge_event) {
894
- for (const sub of contract.subscriptions) {
895
- if (sub.service === from && sub.event === payload.__forge_event) {
896
- const handler = service[sub.handlerName];
897
- if (handler) await handler.call(service, payload.__forge_data);
898
- }
899
- }
900
- return;
901
- }
902
- return originalOnMessage(from, payload);
903
- };
904
- }