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,1756 +0,0 @@
1
- /**
2
- * ThreadForge Built-in Plugins
3
- *
4
- * import { redis, postgres, s3, cors, cron, realtime } from 'threadforge/plugins';
5
- */
6
-
7
- // ─── Redis ─────────────────────────────────────────────────
8
-
9
- export function redis(options = {}) {
10
- const url = options.url ?? `redis://${options.host ?? "127.0.0.1"}:${options.port ?? 6379}/${options.db ?? 0}`;
11
- const poolSize = Math.max(1, Math.min(options.poolSize ?? 2, 8));
12
-
13
- return {
14
- name: "redis",
15
- version: "1.0.0",
16
- inject: "redis",
17
- validate() {
18
- try {
19
- new URL(url);
20
- } catch {
21
- throw new Error(`Invalid Redis URL: ${url}`);
22
- }
23
- },
24
- env() {
25
- // REL-C2: Sanitize URL before propagating to env — strip credentials
26
- // to prevent exposure via /proc/<pid>/environ. The connect() function
27
- // uses the original `url` variable from closure scope.
28
- try {
29
- const parsed = new URL(url);
30
- parsed.username = '';
31
- parsed.password = '';
32
- return { FORGE_REDIS_URL: parsed.toString() };
33
- } catch {
34
- return { FORGE_REDIS_URL: url };
35
- }
36
- },
37
-
38
- async connect(ctx) {
39
- let RedisClient;
40
- try {
41
- RedisClient = (await import("ioredis")).default;
42
- } catch {
43
- // P16: Pool built-in Redis connections to avoid head-of-line blocking
44
- if (poolSize <= 1) {
45
- return _mkRedis(url, ctx);
46
- }
47
- return _mkRedisPool(url, ctx, poolSize);
48
- }
49
- // ioredis is available — connection errors should propagate
50
- const c = new RedisClient(url, { keyPrefix: options.keyPrefix ?? "", maxRetriesPerRequest: 3, lazyConnect: true });
51
- await c.connect();
52
- return c;
53
- },
54
-
55
- async healthCheck(c) {
56
- try {
57
- return { status: (await c.ping()) === "PONG" ? "ok" : "degraded" };
58
- } catch (e) {
59
- return { status: "error", error: e.message };
60
- }
61
- },
62
- async disconnect(c) {
63
- await c.quit?.();
64
- },
65
- metrics(c) {
66
- return { connected: c.status === "ready" ? 1 : 0 };
67
- },
68
- nginx() {
69
- return options.commanderUI
70
- ? {
71
- locations: [
72
- { path: "/admin/redis", config: "proxy_pass http://127.0.0.1:8081;\nproxy_set_header Host $host;" },
73
- ],
74
- }
75
- : {};
76
- },
77
- };
78
- }
79
-
80
- /**
81
- * Convert a RESP value to a string if it's a Buffer.
82
- * Returns null unchanged. For ioredis API compatibility.
83
- * @internal
84
- */
85
- function _bufToStr(v) {
86
- if (v === null || v === undefined) return v;
87
- if (Buffer.isBuffer(v)) return v.toString('utf8');
88
- return v;
89
- }
90
-
91
- /** @internal Exported for testing only */
92
- export async function _mkRedis(url, ctx) {
93
- const net = await import("node:net");
94
- const p = new URL(url);
95
- const host = p.hostname || "127.0.0.1";
96
- const port = parseInt(p.port || "6379", 10);
97
- const MAX_REDIS_BUFFER = 16 * 1024 * 1024; // 16 MB
98
- let socket,
99
- connected = false,
100
- rq = [],
101
- bufChunks = [],
102
- bufTotalLen = 0;
103
-
104
- const CRLF = Buffer.from("\r\n");
105
-
106
- // Reconnection state
107
- let intentionalClose = false;
108
- let reconnectAttempts = 0;
109
- let reconnectTimer = null;
110
- let subReconnectAttempts = 0;
111
- let subReconnectTimer = null;
112
- let subConnecting = null;
113
- const MAX_RECONNECT_RETRIES = 10;
114
- const BASE_RECONNECT_DELAY = 1000;
115
- const MAX_RECONNECT_DELAY = 30000;
116
-
117
- // C-PLUGIN-5: Helper to cleanly destroy socket and prevent leaks
118
- function destroySocket() {
119
- if (socket) {
120
- socket.removeAllListeners();
121
- socket.destroy();
122
- socket = null;
123
- }
124
- }
125
-
126
- function conn() {
127
- return new Promise((ok, no) => {
128
- // C-PLUGIN-5: Destroy previous socket before creating new one
129
- destroySocket();
130
- socket = net.connect({ host, port });
131
- socket.on("connect", () => {
132
- connected = true;
133
- reconnectAttempts = 0;
134
- ok();
135
- });
136
- socket.on("error", (err) => {
137
- if (!connected) return no(err);
138
- ctx.logger.warn(`Redis socket error: ${err.message}`);
139
- });
140
- socket.on("data", (d) => {
141
- if (bufTotalLen + d.length > MAX_REDIS_BUFFER) {
142
- socket.destroy(new Error('Redis response buffer overflow'));
143
- return;
144
- }
145
- // P-7: Accumulate chunks in array, concat only when parsing needs contiguous buffer
146
- bufChunks.push(d);
147
- bufTotalLen += d.length;
148
- flush();
149
- });
150
- socket.on("close", () => {
151
- connected = false;
152
- clientRef.status = "disconnected";
153
- // Reject pending requests
154
- const pending = rq.splice(0);
155
- for (const r of pending) r.no(new Error("Redis connection closed"));
156
- bufChunks = [];
157
- bufTotalLen = 0;
158
-
159
- if (intentionalClose) return;
160
- scheduleReconnect();
161
- });
162
- });
163
- }
164
-
165
- function scheduleReconnect() {
166
- if (reconnectTimer !== null) return; // Already reconnecting
167
- if (reconnectAttempts >= MAX_RECONNECT_RETRIES) {
168
- ctx.logger.error(`Redis reconnect failed after ${MAX_RECONNECT_RETRIES} attempts, giving up`);
169
- clientRef.status = 'disconnected';
170
- // M-PLUGIN-4: Reject all pending requests on reconnect exhaustion
171
- const pending = rq.splice(0);
172
- for (const r of pending) r.no(new Error('Redis reconnect failed'));
173
- // C-PLUGIN-5: Clean up socket
174
- destroySocket();
175
- return;
176
- }
177
- const delay = Math.min(BASE_RECONNECT_DELAY * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
178
- reconnectAttempts++;
179
- ctx.logger.warn(`Redis reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_RETRIES})`);
180
- reconnectTimer = setTimeout(async () => {
181
- reconnectTimer = null;
182
- try {
183
- await conn();
184
- // Re-authenticate and select DB after reconnect
185
- if (p.password) await cmd("AUTH", p.password);
186
- const db = parseInt(p.pathname?.slice(1) || "0", 10);
187
- if (db > 0) await cmd("SELECT", String(db));
188
- clientRef.status = "ready";
189
- ctx.logger.info("Redis reconnected (built-in client)", { host, port });
190
- } catch (err) {
191
- ctx.logger.warn(`Redis reconnect attempt ${reconnectAttempts} failed: ${err.message}`);
192
- scheduleReconnect();
193
- }
194
- }, delay);
195
- }
196
-
197
- const MAX_NEST_DEPTH = 32;
198
- const MAX_RESP_ARRAY = 10_000;
199
- const MAX_RESP_BULK = 16 * 1024 * 1024; // 16MB
200
-
201
- function flush() {
202
- // P-7: Concat accumulated chunks into a single buffer for parsing
203
- if (bufChunks.length === 0 || rq.length === 0) return;
204
- let buf = bufChunks.length === 1 ? bufChunks[0] : Buffer.concat(bufChunks, bufTotalLen);
205
- bufChunks = [];
206
- bufTotalLen = 0;
207
- while (buf.length && rq.length) {
208
- let r;
209
- try {
210
- r = parse(buf, 0);
211
- } catch (err) {
212
- // Unknown RESP type or parse error — reject current request and reset buffer
213
- rq.shift().no(err);
214
- return;
215
- }
216
- if (!r) break;
217
- buf = r.rem;
218
- rq.shift().ok(r.val);
219
- }
220
- // Keep remaining unparsed data for next flush
221
- if (buf.length > 0) {
222
- bufChunks.push(buf);
223
- bufTotalLen = buf.length;
224
- }
225
- }
226
- function parse(d, depth) {
227
- if (!d.length) return null;
228
- if (depth > MAX_NEST_DEPTH) {
229
- throw new Error('RESP array nesting too deep');
230
- }
231
- const t = d[0]; // byte value: '+' = 43, '-' = 45, ':' = 58, '$' = 36, '*' = 42
232
- const nl = d.indexOf(CRLF[0]); // find \r
233
- if (nl === -1 || nl + 1 >= d.length || d[nl + 1] !== CRLF[1]) return null;
234
- const line = d.slice(1, nl).toString("utf8");
235
- const afterCrlf = d.slice(nl + 2);
236
- // Simple string
237
- if (t === 0x2b) return { val: line, rem: afterCrlf };
238
- // Error
239
- if (t === 0x2d) return { val: new Error(line), rem: afterCrlf };
240
- // Integer
241
- if (t === 0x3a) return { val: parseInt(line, 10), rem: afterCrlf };
242
- // Bulk string — payload is raw Buffer
243
- if (t === 0x24) {
244
- const len = parseInt(line, 10);
245
- if (len < -1 || len > MAX_RESP_BULK) {
246
- throw new Error(`RESP bulk string length out of bounds: ${len}`);
247
- }
248
- if (len === -1) return { val: null, rem: afterCrlf };
249
- // Need len bytes + \r\n after the data
250
- if (afterCrlf.length < len + 2) return null;
251
- const val = Buffer.from(afterCrlf.subarray(0, len));
252
- return { val, rem: afterCrlf.subarray(len + 2) };
253
- }
254
- // Array
255
- if (t === 0x2a) {
256
- const cnt = parseInt(line, 10);
257
- if (cnt < -1 || cnt > MAX_RESP_ARRAY) {
258
- throw new Error(`RESP array count out of bounds: ${cnt}`);
259
- }
260
- if (cnt === -1) return { val: null, rem: afterCrlf };
261
- let rm = afterCrlf;
262
- const a = [];
263
- let totalArraySize = 0;
264
- for (let i = 0; i < cnt; i++) {
265
- const it = parse(rm, depth + 1);
266
- if (!it) return null;
267
- // Track total array element sizes
268
- if (Buffer.isBuffer(it.val)) {
269
- totalArraySize += it.val.length;
270
- if (totalArraySize > MAX_RESP_BULK) {
271
- throw new Error(`RESP array total size exceeds limit: ${totalArraySize}`);
272
- }
273
- }
274
- a.push(it.val);
275
- rm = it.rem;
276
- }
277
- return { val: a, rem: rm };
278
- }
279
- const err = new Error(`Unknown RESP type: '${String.fromCharCode(t)}' (0x${t.toString(16)})`);
280
- err.code = 'RESP_PARSE_ERROR';
281
- throw err;
282
- }
283
- function cmd(...args) {
284
- return new Promise((ok, no) => {
285
- if (!connected) return no(new Error("Redis not connected"));
286
- // H-PLUGIN-4: Reject arguments containing CR/LF (command injection)
287
- for (const a of args) {
288
- if (!Buffer.isBuffer(a) && typeof a === 'string' && (/\r|\n/).test(a)) {
289
- return no(new Error('Redis command argument contains invalid characters (CR/LF)'));
290
- }
291
- }
292
- const header = `*${args.length}\r\n`;
293
- const parts = [header];
294
- for (const a of args) {
295
- if (Buffer.isBuffer(a)) {
296
- parts.push(`$${a.length}\r\n`);
297
- parts.push(a);
298
- parts.push('\r\n');
299
- } else {
300
- const s = String(a);
301
- parts.push(`$${Buffer.byteLength(s)}\r\n${s}\r\n`);
302
- }
303
- }
304
- rq.push({ ok, no });
305
- socket.cork();
306
- for (const part of parts) {
307
- socket.write(part);
308
- }
309
- socket.uncork();
310
- });
311
- }
312
-
313
- await conn();
314
- if (p.password) await cmd("AUTH", p.password);
315
- const db = parseInt(p.pathname?.slice(1) || "0", 10);
316
- if (db > 0) await cmd("SELECT", String(db));
317
- ctx.logger.info("Redis connected (built-in client)", { host, port });
318
-
319
- const clientRef = {
320
- status: "ready",
321
- get: async (k) => _bufToStr(await cmd("GET", k)),
322
- set: (...a) => cmd("SET", ...a),
323
- del: (...k) => cmd("DEL", ...k),
324
- hget: async (k, f) => _bufToStr(await cmd("HGET", k, f)),
325
- hset: (k, f, v) => cmd("HSET", k, f, v),
326
- hgetall: async (k) => {
327
- const a = await cmd("HGETALL", k);
328
- if (!Array.isArray(a)) return {};
329
- const o = {};
330
- for (let i = 0; i < a.length; i += 2) o[_bufToStr(a[i])] = _bufToStr(a[i + 1]);
331
- return o;
332
- },
333
- keys: async (pat) => {
334
- const a = await cmd("KEYS", pat);
335
- return Array.isArray(a) ? a.map(_bufToStr) : a;
336
- },
337
- expire: (k, s) => cmd("EXPIRE", k, s),
338
- ttl: (k) => cmd("TTL", k),
339
- ping: () => cmd("PING"),
340
- incr: (k) => cmd("INCR", k),
341
- decr: (k) => cmd("DECR", k),
342
- lpush: (k, ...v) => cmd("LPUSH", k, ...v),
343
- rpush: (k, ...v) => cmd("RPUSH", k, ...v),
344
- lrange: async (k, s, e) => {
345
- const a = await cmd("LRANGE", k, String(s), String(e));
346
- return Array.isArray(a) ? a.map(_bufToStr) : a;
347
- },
348
- sadd: (k, ...m) => cmd("SADD", k, ...m),
349
- smembers: async (k) => {
350
- const a = await cmd("SMEMBERS", k);
351
- return Array.isArray(a) ? a.map(_bufToStr) : a;
352
- },
353
- publish: (ch, m) => cmd("PUBLISH", ch, m),
354
-
355
- // ─── Subscription support (lazy separate connection) ───
356
- _subSocket: null,
357
- _subConnected: false,
358
- _subBufChunks: [],
359
- _subBufTotalLen: 0,
360
- _subCallbacks: new Map(), // channel → Set<callback>
361
- _psubCallbacks: new Map(), // pattern → Set<callback>
362
- _subSubscribedChannels: new Set(),
363
- _subSubscribedPatterns: new Set(),
364
- _subRq: [],
365
-
366
- async _ensureSubConnection() {
367
- if (this._subConnected) return;
368
- if (subConnecting) return subConnecting;
369
- const self = this;
370
- const subNet = await import("node:net");
371
- subConnecting = new Promise((ok, no) => {
372
- self._subSocket = subNet.connect({ host, port });
373
- self._subSocket.on("connect", () => {
374
- self._subConnected = true;
375
- subReconnectAttempts = 0;
376
- self._subSubscribedChannels.clear();
377
- self._subSubscribedPatterns.clear();
378
- ok();
379
- });
380
- self._subSocket.on("error", (err) => {
381
- if (!self._subConnected) return no(err);
382
- ctx.logger.warn(`Redis sub socket error: ${err.message}`);
383
- });
384
- self._subSocket.on("data", (d) => {
385
- // REL-C1: Guard against unbounded sub buffer growth (same limit as main connection)
386
- if (self._subBufTotalLen + d.length > MAX_REDIS_BUFFER) {
387
- self._subSocket.destroy(new Error('Redis sub response buffer overflow'));
388
- return;
389
- }
390
- // P-8: Chunk-list pattern to avoid O(n^2) Buffer.concat
391
- self._subBufChunks.push(d);
392
- self._subBufTotalLen += d.length;
393
- self._flushSub();
394
- });
395
- self._subSocket.on("close", () => {
396
- self._subConnected = false;
397
- self._subSubscribedChannels.clear();
398
- self._subSubscribedPatterns.clear();
399
- const pending = self._subRq.splice(0);
400
- for (const r of pending) r.no(new Error("Redis sub connection closed"));
401
- self._subBufChunks = [];
402
- self._subBufTotalLen = 0;
403
- self._scheduleSubReconnect();
404
- });
405
- });
406
- let connectError = null;
407
- try {
408
- await subConnecting;
409
- // Auth and DB select on sub connection
410
- if (p.password) {
411
- await self._subCmd("AUTH", p.password);
412
- }
413
- const subDb = parseInt(p.pathname?.slice(1) || "0", 10);
414
- if (subDb > 0) {
415
- await self._subCmd("SELECT", String(subDb));
416
- }
417
- // Re-subscribe desired channels/patterns after reconnect.
418
- for (const channel of self._subCallbacks.keys()) {
419
- if (self._subSubscribedChannels.has(channel)) continue;
420
- await self._subCmd("SUBSCRIBE", channel);
421
- self._subSubscribedChannels.add(channel);
422
- }
423
- for (const pattern of self._psubCallbacks.keys()) {
424
- if (self._subSubscribedPatterns.has(pattern)) continue;
425
- await self._subCmd("PSUBSCRIBE", pattern);
426
- self._subSubscribedPatterns.add(pattern);
427
- }
428
- } catch (err) {
429
- connectError = err;
430
- } finally {
431
- subConnecting = null;
432
- }
433
- if (connectError) {
434
- this._scheduleSubReconnect();
435
- throw connectError;
436
- }
437
- },
438
-
439
- _scheduleSubReconnect() {
440
- if (intentionalClose) return;
441
- if (subReconnectTimer !== null || this._subConnected || subConnecting) return;
442
- if (subReconnectAttempts >= MAX_RECONNECT_RETRIES) {
443
- ctx.logger.error(`Redis sub reconnect failed after ${MAX_RECONNECT_RETRIES} attempts, giving up`);
444
- return;
445
- }
446
- const delay = Math.min(BASE_RECONNECT_DELAY * 2 ** subReconnectAttempts, MAX_RECONNECT_DELAY);
447
- subReconnectAttempts++;
448
- ctx.logger.warn(
449
- `Redis sub reconnecting in ${delay}ms (attempt ${subReconnectAttempts}/${MAX_RECONNECT_RETRIES})`,
450
- );
451
- subReconnectTimer = setTimeout(async () => {
452
- subReconnectTimer = null;
453
- try {
454
- await this._ensureSubConnection();
455
- ctx.logger.info("Redis sub reconnected (built-in client)", { host, port });
456
- } catch (err) {
457
- ctx.logger.warn(`Redis sub reconnect attempt ${subReconnectAttempts} failed: ${err.message}`);
458
- this._scheduleSubReconnect();
459
- }
460
- }, delay);
461
- subReconnectTimer.unref?.();
462
- },
463
-
464
- _subCmd(...args) {
465
- return new Promise((ok, no) => {
466
- if (!this._subConnected) return no(new Error("Redis sub not connected"));
467
- const header = `*${args.length}\r\n`;
468
- const parts = [header];
469
- for (const a of args) {
470
- const s = String(a);
471
- parts.push(`$${Buffer.byteLength(s)}\r\n${s}\r\n`);
472
- }
473
- this._subRq.push({ ok, no });
474
- this._subSocket.cork();
475
- for (const part of parts) this._subSocket.write(part);
476
- this._subSocket.uncork();
477
- });
478
- },
479
-
480
- _flushSub() {
481
- // P-8: Concat accumulated chunks for parsing
482
- if (this._subBufChunks.length === 0) return;
483
- let buf = this._subBufChunks.length === 1 ? this._subBufChunks[0] : Buffer.concat(this._subBufChunks, this._subBufTotalLen);
484
- this._subBufChunks = [];
485
- this._subBufTotalLen = 0;
486
- while (buf.length) {
487
- let r;
488
- try {
489
- r = parse(buf, 0);
490
- } catch {
491
- return;
492
- }
493
- if (!r) break;
494
- buf = r.rem;
495
- const val = r.val;
496
- // Messages are arrays: ["message", channel, data] or ["pmessage", pattern, channel, data]
497
- if (Array.isArray(val)) {
498
- const type = _bufToStr(val[0]);
499
- if (type === 'message') {
500
- const ch = _bufToStr(val[1]);
501
- const data = _bufToStr(val[2]);
502
- const cbs = this._subCallbacks.get(ch);
503
- if (cbs) for (const cb of cbs) cb(data, ch);
504
- continue;
505
- }
506
- if (type === 'pmessage') {
507
- const pat = _bufToStr(val[1]);
508
- const ch = _bufToStr(val[2]);
509
- const data = _bufToStr(val[3]);
510
- const cbs = this._psubCallbacks.get(pat);
511
- if (cbs) for (const cb of cbs) cb(data, ch, pat);
512
- continue;
513
- }
514
- // subscribe/psubscribe confirmations resolve pending _subRq
515
- if (type === 'subscribe' || type === 'psubscribe') {
516
- if (this._subRq.length) this._subRq.shift().ok(val);
517
- continue;
518
- }
519
- // unsubscribe/punsubscribe
520
- if (type === 'unsubscribe' || type === 'punsubscribe') {
521
- if (this._subRq.length) this._subRq.shift().ok(val);
522
- continue;
523
- }
524
- }
525
- // Other responses (OK from AUTH/SELECT)
526
- if (this._subRq.length) this._subRq.shift().ok(val);
527
- }
528
- // Keep remaining unparsed data for next flush
529
- if (buf.length > 0) {
530
- this._subBufChunks.push(buf);
531
- this._subBufTotalLen = buf.length;
532
- }
533
- },
534
-
535
- async subscribe(channel, callback) {
536
- if (!this._subCallbacks.has(channel)) {
537
- this._subCallbacks.set(channel, new Set());
538
- }
539
- this._subCallbacks.get(channel).add(callback);
540
- await this._ensureSubConnection();
541
- if (!this._subSubscribedChannels.has(channel)) {
542
- await this._subCmd("SUBSCRIBE", channel);
543
- this._subSubscribedChannels.add(channel);
544
- }
545
- },
546
-
547
- async psubscribe(pattern, callback) {
548
- if (!this._psubCallbacks.has(pattern)) {
549
- this._psubCallbacks.set(pattern, new Set());
550
- }
551
- this._psubCallbacks.get(pattern).add(callback);
552
- await this._ensureSubConnection();
553
- if (!this._subSubscribedPatterns.has(pattern)) {
554
- await this._subCmd("PSUBSCRIBE", pattern);
555
- this._subSubscribedPatterns.add(pattern);
556
- }
557
- },
558
-
559
- quit: () => {
560
- if (intentionalClose) return Promise.resolve(); // Guard against double-call
561
- // Clean up subscription connection
562
- if (clientRef._subSocket) {
563
- clientRef._subSocket.removeAllListeners();
564
- clientRef._subSocket.destroy();
565
- clientRef._subSocket = null;
566
- clientRef._subConnected = false;
567
- clientRef._subCallbacks.clear();
568
- clientRef._psubCallbacks.clear();
569
- clientRef._subSubscribedChannels.clear();
570
- clientRef._subSubscribedPatterns.clear();
571
- clientRef._subBufChunks = [];
572
- clientRef._subBufTotalLen = 0;
573
- clientRef._subRq.length = 0;
574
- }
575
- return new Promise((resolve) => {
576
- intentionalClose = true;
577
- if (reconnectTimer) {
578
- clearTimeout(reconnectTimer);
579
- reconnectTimer = null;
580
- }
581
- if (subReconnectTimer) {
582
- clearTimeout(subReconnectTimer);
583
- subReconnectTimer = null;
584
- }
585
- if (rq.length === 0) {
586
- connected = false;
587
- // C-PLUGIN-5: Clean socket shutdown on intentional close
588
- destroySocket();
589
- resolve();
590
- return;
591
- }
592
- // Event-based drain: resolve when response queue empties
593
- const origFlush = flush;
594
- flush = function drainFlush() {
595
- origFlush();
596
- if (rq.length === 0) {
597
- flush = origFlush;
598
- connected = false;
599
- destroySocket();
600
- resolve();
601
- }
602
- };
603
- // Safety timeout in case drain never completes
604
- setTimeout(() => {
605
- flush = origFlush;
606
- // Reject all pending requests
607
- const pending = rq.splice(0);
608
- for (const r of pending) {
609
- r.no(new Error('Redis quit timeout - connection forcibly closed'));
610
- }
611
- connected = false;
612
- destroySocket();
613
- resolve();
614
- }, 5000).unref();
615
- });
616
- },
617
- disconnect: () => {
618
- intentionalClose = true;
619
- connected = false;
620
- if (reconnectTimer) {
621
- clearTimeout(reconnectTimer);
622
- reconnectTimer = null;
623
- }
624
- if (subReconnectTimer) {
625
- clearTimeout(subReconnectTimer);
626
- subReconnectTimer = null;
627
- }
628
- // C-PLUGIN-5: Use destroySocket helper
629
- destroySocket();
630
- },
631
- };
632
- return clientRef;
633
- }
634
-
635
- /**
636
- * P16: Redis connection pool for the built-in RESP client.
637
- * Maintains multiple connections and round-robins commands across them
638
- * to avoid head-of-line blocking on a single connection.
639
- * @internal
640
- */
641
- async function _mkRedisPool(url, ctx, size) {
642
- const connections = [];
643
- for (let i = 0; i < size; i++) {
644
- connections.push(await _mkRedis(url, ctx));
645
- }
646
- let rrIndex = 0;
647
-
648
- function nextConn() {
649
- const conn = connections[rrIndex % connections.length];
650
- rrIndex = (rrIndex + 1) % 1_000_000_000;
651
- return conn;
652
- }
653
-
654
- // Build a pooled client that delegates to underlying connections round-robin
655
- const pooled = {
656
- status: "ready",
657
- get _poolConnections() { return connections; },
658
- get: (k) => nextConn().get(k),
659
- set: (...a) => nextConn().set(...a),
660
- del: (...k) => nextConn().del(...k),
661
- hget: (k, f) => nextConn().hget(k, f),
662
- hset: (k, f, v) => nextConn().hset(k, f, v),
663
- hgetall: (k) => nextConn().hgetall(k),
664
- keys: (pat) => nextConn().keys(pat),
665
- expire: (k, s) => nextConn().expire(k, s),
666
- ttl: (k) => nextConn().ttl(k),
667
- ping: () => nextConn().ping(),
668
- incr: (k) => nextConn().incr(k),
669
- decr: (k) => nextConn().decr(k),
670
- lpush: (k, ...v) => nextConn().lpush(k, ...v),
671
- rpush: (k, ...v) => nextConn().rpush(k, ...v),
672
- lrange: (k, s, e) => nextConn().lrange(k, s, e),
673
- sadd: (k, ...m) => nextConn().sadd(k, ...m),
674
- smembers: (k) => nextConn().smembers(k),
675
- publish: (ch, m) => nextConn().publish(ch, m),
676
- // Subscriptions use the first connection's sub support
677
- subscribe: (ch, cb) => connections[0].subscribe(ch, cb),
678
- psubscribe: (pat, cb) => connections[0].psubscribe(pat, cb),
679
- quit: async () => {
680
- pooled.status = "disconnected";
681
- await Promise.all(connections.map(c => c.quit()));
682
- },
683
- disconnect: () => {
684
- pooled.status = "disconnected";
685
- for (const c of connections) c.disconnect();
686
- },
687
- };
688
- ctx.logger.info(`Redis pool created (${size} connections)`, { host: new URL(url).hostname });
689
- return pooled;
690
- }
691
-
692
- // ─── PostgreSQL ────────────────────────────────────────────
693
-
694
- export function postgres(options = {}) {
695
- const url = options.url ?? process.env.DATABASE_URL;
696
- return {
697
- name: "postgres",
698
- version: "1.0.0",
699
- inject: "pg",
700
- validate() {
701
- if (!url && !process.env.DATABASE_URL) throw new Error("PostgreSQL plugin requires url or DATABASE_URL");
702
- },
703
- env() {
704
- return url ? { FORGE_PG_URL: url } : {};
705
- },
706
- async connect(ctx) {
707
- const pg = await import("pg");
708
- const Pool = pg.default?.Pool ?? pg.Pool;
709
- const poolMax = options.poolSize ?? 10;
710
- const pool = new Pool({
711
- connectionString: url ?? process.env.FORGE_PG_URL,
712
- max: poolMax,
713
- idleTimeoutMillis: options.idleTimeout ?? 30000,
714
- connectionTimeoutMillis: 10000,
715
- statement_timeout: 30000,
716
- });
717
- const c = await pool.connect();
718
- c.release();
719
- ctx.logger.info("PostgreSQL connected", { pool: poolMax });
720
-
721
- // H-PLUGIN-1: Pool saturation logging (max once per 60s)
722
- let _lastSaturationWarn = 0;
723
- const origQuery = pool.query.bind(pool);
724
- pool.query = function (...args) {
725
- const now = Date.now();
726
- if (now - _lastSaturationWarn > 60000) {
727
- if (pool.waitingCount > 0 || pool.idleCount < poolMax * 0.2) {
728
- _lastSaturationWarn = now;
729
- ctx.logger.warn('PostgreSQL pool saturation warning', {
730
- waiting: pool.waitingCount,
731
- idle: pool.idleCount,
732
- total: pool.totalCount,
733
- max: poolMax,
734
- });
735
- }
736
- }
737
- return origQuery(...args);
738
- };
739
-
740
- return pool;
741
- },
742
- async healthCheck(pool) {
743
- try {
744
- await pool.query("SELECT 1");
745
- return { status: "ok", total: pool.totalCount, idle: pool.idleCount };
746
- } catch (e) {
747
- return { status: "error", error: e.message };
748
- }
749
- },
750
- async disconnect(pool) {
751
- await pool.end();
752
- },
753
- metrics(pool) {
754
- return { pool_total: pool.totalCount ?? 0, pool_idle: pool.idleCount ?? 0, pool_waiting: pool.waitingCount ?? 0 };
755
- },
756
- nginx() {
757
- return options.pgAdminUI
758
- ? {
759
- locations: [
760
- {
761
- path: "/admin/pgadmin",
762
- config: `proxy_pass http://127.0.0.1:${options.pgAdminPort ?? 5050};\nproxy_set_header Host $host;`,
763
- },
764
- ],
765
- }
766
- : {};
767
- },
768
- };
769
- }
770
-
771
- // ─── S3 ────────────────────────────────────────────────────
772
-
773
- // H-PLUGIN-8: S3 key validation helper
774
- function validateS3Key(key) {
775
- if (typeof key !== 'string') throw new Error('S3 key must be a string');
776
- if (key.length < 1 || key.length > 1024) throw new Error(`S3 key length out of bounds: ${key.length}`);
777
- if (key.includes('..')) throw new Error('S3 key must not contain ".."');
778
- if (key.startsWith('/')) throw new Error('S3 key must not start with "/"');
779
- if (/[\x00-\x1F\x7F]/.test(key)) throw new Error('S3 key must not contain control characters');
780
- }
781
-
782
- const MAX_STUB_SIZE = 100 * 1024 * 1024; // 100MB total
783
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per file
784
-
785
- export function s3(options = {}) {
786
- return {
787
- name: "s3",
788
- version: "1.0.0",
789
- inject: "storage",
790
- validate() {
791
- if (!options.bucket) throw new Error("S3 plugin requires bucket");
792
- },
793
- env() {
794
- const e = {};
795
- if (options.endpoint) e.FORGE_S3_ENDPOINT = options.endpoint;
796
- if (options.region) e.AWS_REGION = options.region;
797
- return e;
798
- },
799
- async connect(ctx) {
800
- try {
801
- const { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, HeadBucketCommand } = await import(
802
- "@aws-sdk/client-s3"
803
- );
804
- const cfg = { region: options.region ?? "us-east-1" };
805
- if (options.endpoint) {
806
- cfg.endpoint = options.endpoint;
807
- cfg.forcePathStyle = true;
808
- }
809
- const raw = new S3Client(cfg);
810
- const bucket = options.bucket;
811
- ctx.logger.info("S3 connected", { bucket, region: cfg.region });
812
- return {
813
- _isStub: false,
814
- _raw: raw,
815
- _HeadBucketCommand: HeadBucketCommand,
816
- put: (k, b, ct) => { validateS3Key(k); return raw.send(new PutObjectCommand({ Bucket: bucket, Key: k, Body: b, ContentType: ct })); },
817
- get: async (k) => { validateS3Key(k); return (await raw.send(new GetObjectCommand({ Bucket: bucket, Key: k }))).Body; },
818
- del: (k) => { validateS3Key(k); return raw.send(new DeleteObjectCommand({ Bucket: bucket, Key: k })); },
819
- url: (k) => {
820
- validateS3Key(k);
821
- return options.endpoint
822
- ? `${options.endpoint}/${bucket}/${k}`
823
- : `https://${bucket}.s3.${cfg.region}.amazonaws.com/${k}`;
824
- },
825
- bucket,
826
- };
827
- } catch {
828
- const store = new Map();
829
- let totalSize = 0;
830
- ctx.logger.warn("S3 stub active (install @aws-sdk/client-s3)");
831
- return {
832
- _isStub: true,
833
- put: async (k, b) => {
834
- validateS3Key(k);
835
- const size = Buffer.isBuffer(b) ? b.length : (typeof b === 'string' ? Buffer.byteLength(b) : 0);
836
- if (size > MAX_FILE_SIZE) throw new Error(`S3 stub: file size ${size} exceeds limit of ${MAX_FILE_SIZE}`);
837
- // Subtract old value size if overwriting
838
- const old = store.get(k);
839
- if (old !== undefined) {
840
- const oldSize = Buffer.isBuffer(old) ? old.length : (typeof old === 'string' ? Buffer.byteLength(old) : 0);
841
- totalSize -= oldSize;
842
- }
843
- if (totalSize + size > MAX_STUB_SIZE) throw new Error(`S3 stub: total size would exceed limit of ${MAX_STUB_SIZE}`);
844
- totalSize += size;
845
- store.set(k, b);
846
- },
847
- get: async (k) => { validateS3Key(k); return store.get(k) ?? null; },
848
- del: async (k) => {
849
- validateS3Key(k);
850
- const old = store.get(k);
851
- if (old !== undefined) {
852
- const oldSize = Buffer.isBuffer(old) ? old.length : (typeof old === 'string' ? Buffer.byteLength(old) : 0);
853
- totalSize -= oldSize;
854
- }
855
- store.delete(k);
856
- },
857
- url: (k) => { validateS3Key(k); return `mem://${options.bucket}/${k}`; },
858
- bucket: options.bucket,
859
- };
860
- }
861
- },
862
- async healthCheck(s) {
863
- if (s._isStub === true) return { status: "ok", bucket: s.bucket, note: "in-memory stub" };
864
- if (s._isStub === false && s._raw && s._HeadBucketCommand) {
865
- try {
866
- await s._raw.send(new s._HeadBucketCommand({ Bucket: s.bucket }));
867
- return { status: "ok", bucket: s.bucket };
868
- } catch (err) {
869
- return { status: "error", bucket: s.bucket, error: err.message };
870
- }
871
- }
872
- // Fallback for clients not created by connect() (e.g., in tests)
873
- return { status: "ok", bucket: s.bucket };
874
- },
875
- async disconnect() {},
876
- };
877
- }
878
-
879
- // ─── CORS ──────────────────────────────────────────────────
880
-
881
- export function cors(options = {}) {
882
- const origins = options.origins ?? ["*"];
883
- const methods = options.methods ?? ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
884
- const headers = options.headers ?? ["Content-Type", "Authorization", "X-Correlation-ID"];
885
- const creds = options.credentials ?? false;
886
- const maxAge = options.maxAge ?? 86400;
887
-
888
- if (origins.includes("*") && creds) {
889
- throw new Error(
890
- 'CORS misconfiguration: credentials: true is incompatible with origins: ["*"]. ' +
891
- "Specify explicit origins or disable credentials."
892
- );
893
- }
894
-
895
- return {
896
- name: "cors",
897
- version: "1.0.0",
898
- inject: "_cors",
899
- async connect() {
900
- return { origins, methods, headers };
901
- },
902
- middleware() {
903
- return function corsMiddleware(req, res, next) {
904
- const origin = req.headers.origin;
905
- let matched = false;
906
-
907
- if (origins.includes("*") && !creds) {
908
- res.setHeader("Access-Control-Allow-Origin", "*");
909
- matched = true;
910
- } else if (origin) {
911
- // Check exact match first
912
- if (origins.includes(origin)) {
913
- matched = true;
914
- } else {
915
- // Check wildcard subdomain patterns (e.g., *.example.com)
916
- for (const allowed of origins) {
917
- if (allowed.startsWith("*.")) {
918
- const suffix = allowed.slice(1); // e.g., ".example.com"
919
- try {
920
- const originHost = new URL(origin).hostname.toLowerCase();
921
- // REL-C3: *.example.com should NOT match bare example.com — wildcard
922
- // requires at least one subdomain level. This is the standard interpretation
923
- // per RFC 6125 and browser SAN matching.
924
- if (originHost.endsWith(suffix)) {
925
- // Verify the part before suffix is a valid non-empty subdomain segment
926
- const beforeSuffix = originHost.slice(0, -suffix.length);
927
- if (beforeSuffix && /^[a-z0-9]([a-z0-9-]*\.)*$/.test(beforeSuffix)) {
928
- matched = true;
929
- break;
930
- }
931
- }
932
- } catch {
933
- // Invalid origin URL, skip
934
- }
935
- }
936
- }
937
- }
938
- if (matched) {
939
- res.setHeader("Access-Control-Allow-Origin", origin);
940
- if (creds) res.setHeader("Access-Control-Allow-Credentials", "true");
941
- }
942
- }
943
-
944
- if (matched) {
945
- res.setHeader("Access-Control-Allow-Methods", methods.join(", "));
946
- res.setHeader("Access-Control-Allow-Headers", headers.join(", "));
947
- // M-PLUGIN-3: Expose correlation and custom response headers to frontend JS
948
- res.setHeader("Access-Control-Expose-Headers", "X-Correlation-ID, X-Request-ID, X-Trace-ID");
949
- res.setHeader("Access-Control-Max-Age", String(maxAge));
950
- res.setHeader("Vary", "Origin");
951
- }
952
-
953
- if (req.method === "OPTIONS") {
954
- if (!matched) {
955
- res.writeHead(403);
956
- res.end();
957
- return;
958
- }
959
- res.writeHead(204);
960
- res.end();
961
- return;
962
- }
963
- next();
964
- };
965
- },
966
- async disconnect() {},
967
- };
968
- }
969
-
970
- // ─── Realtime (WebSocket Utilities) ────────────────────────
971
-
972
- const REALTIME_CHANNEL_RE = /^[a-zA-Z0-9:_-]{1,128}$/;
973
- const REALTIME_BACKPRESSURE_STRATEGIES = new Set(["drop", "close"]);
974
-
975
- function _assertRealtimeChannel(channel, allowedChannels) {
976
- if (typeof channel !== "string" || channel.trim() === "") {
977
- throw new Error("Realtime channel must be a non-empty string");
978
- }
979
- if (!REALTIME_CHANNEL_RE.test(channel)) {
980
- throw new Error(`Invalid realtime channel "${channel}". Use [a-zA-Z0-9:_-] (max 128 chars)`);
981
- }
982
- if (allowedChannels && allowedChannels.size > 0 && !allowedChannels.has(channel)) {
983
- throw new Error(`Realtime channel "${channel}" is not in allowedChannels`);
984
- }
985
- }
986
-
987
- function _encodeRealtimeEnvelope(channel, payload, senderId, maxPayloadBytes) {
988
- let type = "text";
989
- let body = "";
990
-
991
- if (Buffer.isBuffer(payload)) {
992
- type = "base64";
993
- body = payload.toString("base64");
994
- } else if (typeof payload === "string") {
995
- type = "text";
996
- body = payload;
997
- } else {
998
- type = "json";
999
- body = JSON.stringify(payload);
1000
- }
1001
-
1002
- const size = Buffer.byteLength(body);
1003
- if (size > maxPayloadBytes) {
1004
- throw new Error(`Realtime payload exceeds maxPayloadBytes (${maxPayloadBytes})`);
1005
- }
1006
-
1007
- return {
1008
- v: 1,
1009
- senderId,
1010
- channel,
1011
- type,
1012
- body,
1013
- ts: Date.now(),
1014
- };
1015
- }
1016
-
1017
- function _decodeRealtimeEnvelope(envelope) {
1018
- if (envelope.type === "base64") {
1019
- return Buffer.from(envelope.body, "base64");
1020
- }
1021
- if (envelope.type === "json") {
1022
- return JSON.parse(envelope.body);
1023
- }
1024
- return envelope.body;
1025
- }
1026
-
1027
- function _sendRealtimePayload(ws, payload) {
1028
- if (!ws || ws._closed) return false;
1029
- if (Buffer.isBuffer(payload)) {
1030
- ws.sendBinary(payload);
1031
- } else if (typeof payload === "string") {
1032
- ws.send(payload);
1033
- } else {
1034
- ws.send(JSON.stringify(payload));
1035
- }
1036
- return true;
1037
- }
1038
-
1039
- function _normalizeRealtimeDecision(decision, fallback = {}) {
1040
- if (decision === undefined || decision === null || decision === true) {
1041
- return { allow: true, ...fallback };
1042
- }
1043
- if (decision === false) {
1044
- return { allow: false, ...fallback };
1045
- }
1046
- if (typeof decision === "object") {
1047
- return {
1048
- ...fallback,
1049
- ...decision,
1050
- allow: decision.allow !== false,
1051
- };
1052
- }
1053
- return { allow: Boolean(decision), ...fallback };
1054
- }
1055
-
1056
- function _socketBufferedBytes(ws) {
1057
- const sock = ws?.socket;
1058
- if (!sock) return 0;
1059
- const bytes = sock.writableLength ?? sock.bufferSize ?? 0;
1060
- return Number.isFinite(bytes) ? bytes : 0;
1061
- }
1062
-
1063
- export function realtime(options = {}) {
1064
- const adapter = options.adapter ?? (options.redisUrl ? "redis" : "memory");
1065
- const busChannel = options.busChannel ?? "forge:realtime";
1066
- const maxPayloadBytes = options.maxPayloadBytes ?? 256 * 1024;
1067
- const maxConnections = options.maxConnections ?? 10_000;
1068
- const maxSocketBufferBytes = options.maxSocketBufferBytes ?? 512 * 1024;
1069
- const backpressureStrategy = options.backpressureStrategy ?? "drop";
1070
- const redisSubHealthcheckMs = options.redisSubHealthcheckMs ?? 2000;
1071
- const strictBusPublish = options.strictBusPublish ?? false;
1072
- const authorize = options.authorize;
1073
- const authorizeUpgrade = options.authorizeUpgrade;
1074
- const authorizeConnect = options.authorizeConnect;
1075
- const allowedChannels = options.allowedChannels ? new Set(options.allowedChannels) : null;
1076
-
1077
- return {
1078
- name: "realtime",
1079
- version: "1.0.0",
1080
- inject: "realtime",
1081
-
1082
- validate() {
1083
- if (!["memory", "redis"].includes(adapter)) {
1084
- throw new Error(`Realtime adapter must be "memory" or "redis", got "${adapter}"`);
1085
- }
1086
- if (!REALTIME_CHANNEL_RE.test(busChannel)) {
1087
- throw new Error(`Invalid realtime busChannel "${busChannel}"`);
1088
- }
1089
- if (!Number.isInteger(maxPayloadBytes) || maxPayloadBytes <= 0) {
1090
- throw new Error(`Realtime maxPayloadBytes must be a positive integer, got ${maxPayloadBytes}`);
1091
- }
1092
- if (!Number.isInteger(maxConnections) || maxConnections <= 0) {
1093
- throw new Error(`Realtime maxConnections must be a positive integer, got ${maxConnections}`);
1094
- }
1095
- if (!Number.isInteger(maxSocketBufferBytes) || maxSocketBufferBytes <= 0) {
1096
- throw new Error(
1097
- `Realtime maxSocketBufferBytes must be a positive integer, got ${maxSocketBufferBytes}`,
1098
- );
1099
- }
1100
- if (!REALTIME_BACKPRESSURE_STRATEGIES.has(backpressureStrategy)) {
1101
- throw new Error(
1102
- `Realtime backpressureStrategy must be "drop" or "close", got "${backpressureStrategy}"`,
1103
- );
1104
- }
1105
- if (!Number.isInteger(redisSubHealthcheckMs) || redisSubHealthcheckMs <= 0) {
1106
- throw new Error(
1107
- `Realtime redisSubHealthcheckMs must be a positive integer, got ${redisSubHealthcheckMs}`,
1108
- );
1109
- }
1110
- if (authorize !== undefined && typeof authorize !== "function") {
1111
- throw new Error(`Realtime authorize must be a function`);
1112
- }
1113
- if (authorizeUpgrade !== undefined && typeof authorizeUpgrade !== "function") {
1114
- throw new Error(`Realtime authorizeUpgrade must be a function`);
1115
- }
1116
- if (authorizeConnect !== undefined && typeof authorizeConnect !== "function") {
1117
- throw new Error(`Realtime authorizeConnect must be a function`);
1118
- }
1119
- if (allowedChannels) {
1120
- for (const ch of allowedChannels) _assertRealtimeChannel(ch, null);
1121
- }
1122
- if (adapter === "redis") {
1123
- const url = options.redisUrl ?? process.env.FORGE_REALTIME_REDIS_URL ?? process.env.FORGE_REDIS_URL;
1124
- if (!url) {
1125
- throw new Error('Realtime adapter "redis" requires redisUrl or FORGE_REALTIME_REDIS_URL/FORGE_REDIS_URL');
1126
- }
1127
- }
1128
- },
1129
-
1130
- env() {
1131
- if (adapter !== "redis") return {};
1132
- const url = options.redisUrl ?? process.env.FORGE_REALTIME_REDIS_URL ?? process.env.FORGE_REDIS_URL;
1133
- return url ? { FORGE_REALTIME_REDIS_URL: url } : {};
1134
- },
1135
-
1136
- async connect(ctx) {
1137
- const connections = new Set();
1138
- const socketsByChannel = new Map(); // channel -> Set<ws>
1139
- const channelsBySocket = new Map(); // ws -> Set<channel>
1140
- const socketMeta = new WeakMap(); // ws -> { req, meta }
1141
- const senderId = `${ctx.serviceName}:${ctx.workerId}:${process.pid}`;
1142
- const metrics = {
1143
- published: 0,
1144
- delivered: 0,
1145
- dropped: 0,
1146
- backpressureDropped: 0,
1147
- busPublishErrors: 0,
1148
- redisReconnects: 0,
1149
- };
1150
-
1151
- let redisPub = null;
1152
- let redisSub = null;
1153
- let usingIoRedis = false;
1154
- let builtInSubWatchdog = null;
1155
- let builtInSubProbeRunning = false;
1156
- let lastBackpressureLogAt = 0;
1157
-
1158
- const shouldLogBackpressure = () => {
1159
- const now = Date.now();
1160
- if (now - lastBackpressureLogAt < 30_000) return false;
1161
- lastBackpressureLogAt = now;
1162
- return true;
1163
- };
1164
-
1165
- const runAuthorizeHook = async (stage, payload) => {
1166
- const defaultDenied = stage === "upgrade"
1167
- ? { statusCode: 403, reason: "Forbidden" }
1168
- : { closeCode: 1008, closeReason: "Policy violation" };
1169
- const stageHook = stage === "upgrade" ? authorizeUpgrade : stage === "connect" ? authorizeConnect : null;
1170
- if (typeof stageHook === "function") {
1171
- const result = await stageHook({ stage, ...payload });
1172
- return _normalizeRealtimeDecision(result, defaultDenied);
1173
- }
1174
- if (typeof authorize === "function") {
1175
- const result = await authorize({ stage, ...payload });
1176
- return _normalizeRealtimeDecision(result, defaultDenied);
1177
- }
1178
- return { allow: true };
1179
- };
1180
-
1181
- const ensureSocketTracked = (ws, req = null, meta = null) => {
1182
- if (!connections.has(ws)) {
1183
- connections.add(ws);
1184
- channelsBySocket.set(ws, new Set());
1185
- }
1186
- const prev = socketMeta.get(ws) ?? {};
1187
- socketMeta.set(ws, {
1188
- req: req ?? prev.req ?? null,
1189
- meta: meta ?? prev.meta ?? null,
1190
- });
1191
- };
1192
-
1193
- const cleanupSocket = (ws) => {
1194
- const socketChannels = channelsBySocket.get(ws);
1195
- if (socketChannels) {
1196
- for (const channel of socketChannels) {
1197
- const members = socketsByChannel.get(channel);
1198
- if (members) {
1199
- members.delete(ws);
1200
- if (members.size === 0) socketsByChannel.delete(channel);
1201
- }
1202
- }
1203
- }
1204
- channelsBySocket.delete(ws);
1205
- connections.delete(ws);
1206
- socketMeta.delete(ws);
1207
- };
1208
-
1209
- const handleBackpressure = (ws, channel) => {
1210
- metrics.backpressureDropped++;
1211
- metrics.dropped++;
1212
- if (backpressureStrategy === "close") {
1213
- try {
1214
- ws.close?.(1013, "Backpressure");
1215
- } catch {}
1216
- cleanupSocket(ws);
1217
- }
1218
- if (shouldLogBackpressure()) {
1219
- ctx.logger.warn("Realtime dropping message due to socket backpressure", {
1220
- channel,
1221
- strategy: backpressureStrategy,
1222
- maxSocketBufferBytes,
1223
- });
1224
- }
1225
- };
1226
-
1227
- const deliverEnvelope = (envelope, { excludeWs = null } = {}) => {
1228
- const members = socketsByChannel.get(envelope.channel);
1229
- if (!members || members.size === 0) return 0;
1230
- let delivered = 0;
1231
- let payload;
1232
- try {
1233
- payload = _decodeRealtimeEnvelope(envelope);
1234
- } catch (err) {
1235
- ctx.logger.warn(`Realtime decode failed`, { error: err.message, channel: envelope.channel });
1236
- return 0;
1237
- }
1238
-
1239
- for (const ws of members) {
1240
- if (excludeWs && ws === excludeWs) continue;
1241
- if (_socketBufferedBytes(ws) > maxSocketBufferBytes) {
1242
- handleBackpressure(ws, envelope.channel);
1243
- continue;
1244
- }
1245
- try {
1246
- if (_sendRealtimePayload(ws, payload)) {
1247
- delivered++;
1248
- } else {
1249
- metrics.dropped++;
1250
- }
1251
- } catch {
1252
- metrics.dropped++;
1253
- }
1254
- }
1255
- metrics.delivered += delivered;
1256
- return delivered;
1257
- };
1258
-
1259
- const handleBusMessage = (raw) => {
1260
- try {
1261
- const envelope = JSON.parse(raw);
1262
- if (!envelope || envelope.v !== 1 || typeof envelope.channel !== "string") return;
1263
- if (envelope.senderId === senderId) return; // already delivered locally
1264
- deliverEnvelope(envelope);
1265
- } catch (err) {
1266
- ctx.logger.warn(`Realtime bus message parse failed`, { error: err.message });
1267
- }
1268
- };
1269
-
1270
- const publishToBus = async (envelope) => {
1271
- if (adapter !== "redis") return { ok: true, skipped: true };
1272
- if (!redisPub) return { ok: false, skipped: true, error: new Error("Redis publisher unavailable") };
1273
- try {
1274
- await redisPub.publish(busChannel, JSON.stringify(envelope));
1275
- return { ok: true };
1276
- } catch (err) {
1277
- metrics.busPublishErrors++;
1278
- ctx.logger.warn("Realtime publish to bus failed", { error: err.message, busChannel });
1279
- return { ok: false, error: err };
1280
- }
1281
- };
1282
-
1283
- if (adapter === "redis") {
1284
- const redisUrl = options.redisUrl ?? process.env.FORGE_REALTIME_REDIS_URL ?? process.env.FORGE_REDIS_URL;
1285
- try {
1286
- const Redis = (await import("ioredis")).default;
1287
- const retryStrategy = (times) => Math.min(100 * 2 ** Math.min(times, 8), 5000);
1288
- redisPub = new Redis(redisUrl, {
1289
- lazyConnect: true,
1290
- maxRetriesPerRequest: null,
1291
- retryStrategy,
1292
- });
1293
- redisSub = new Redis(redisUrl, {
1294
- lazyConnect: true,
1295
- maxRetriesPerRequest: null,
1296
- autoResubscribe: true,
1297
- retryStrategy,
1298
- });
1299
- await redisPub.connect();
1300
- await redisSub.connect();
1301
- await redisSub.subscribe(busChannel);
1302
- redisSub.on("reconnecting", () => {
1303
- metrics.redisReconnects++;
1304
- });
1305
- redisSub.on("message", (_channel, message) => {
1306
- handleBusMessage(message);
1307
- });
1308
- redisPub.on("error", (err) => {
1309
- ctx.logger.warn("Realtime redis publisher error", { error: err.message });
1310
- });
1311
- redisSub.on("error", (err) => {
1312
- ctx.logger.warn("Realtime redis subscriber error", { error: err.message });
1313
- });
1314
- usingIoRedis = true;
1315
- ctx.logger.info("Realtime connected (redis adapter)", { busChannel });
1316
- } catch {
1317
- redisPub = await _mkRedis(redisUrl, ctx);
1318
- redisSub = await _mkRedis(redisUrl, ctx);
1319
- await redisSub.subscribe(busChannel, (message) => handleBusMessage(message));
1320
- // Built-in client doesn't expose reconnect events; monitor sub connection health.
1321
- builtInSubWatchdog = setInterval(async () => {
1322
- if (builtInSubProbeRunning) return;
1323
- if (!redisSub || redisSub._subConnected !== false) return;
1324
- builtInSubProbeRunning = true;
1325
- try {
1326
- metrics.redisReconnects++;
1327
- await redisSub._ensureSubConnection?.();
1328
- } catch (err) {
1329
- ctx.logger.warn("Realtime built-in redis subscriber reconnect failed", {
1330
- error: err.message,
1331
- });
1332
- } finally {
1333
- builtInSubProbeRunning = false;
1334
- }
1335
- }, redisSubHealthcheckMs);
1336
- builtInSubWatchdog.unref?.();
1337
- ctx.logger.info("Realtime connected (redis adapter, built-in client)", { busChannel });
1338
- }
1339
- } else {
1340
- ctx.logger.info("Realtime connected (memory adapter)");
1341
- }
1342
-
1343
- const client = {
1344
- adapter,
1345
- busChannel,
1346
-
1347
- join(ws, channel) {
1348
- _assertRealtimeChannel(channel, allowedChannels);
1349
- ensureSocketTracked(ws);
1350
- const socketChannels = channelsBySocket.get(ws);
1351
- socketChannels.add(channel);
1352
- if (!socketsByChannel.has(channel)) socketsByChannel.set(channel, new Set());
1353
- socketsByChannel.get(channel).add(ws);
1354
- return true;
1355
- },
1356
-
1357
- leave(ws, channel) {
1358
- const socketChannels = channelsBySocket.get(ws);
1359
- if (!socketChannels) return false;
1360
- socketChannels.delete(channel);
1361
- const members = socketsByChannel.get(channel);
1362
- if (members) {
1363
- members.delete(ws);
1364
- if (members.size === 0) socketsByChannel.delete(channel);
1365
- }
1366
- return true;
1367
- },
1368
-
1369
- async publish(channel, payload, opts = {}) {
1370
- _assertRealtimeChannel(channel, allowedChannels);
1371
- const envelope = _encodeRealtimeEnvelope(channel, payload, senderId, maxPayloadBytes);
1372
- const localDelivered = deliverEnvelope(envelope, { excludeWs: opts.excludeWs ?? null });
1373
- metrics.published++;
1374
- const busResult = await publishToBus(envelope);
1375
- if (!busResult.ok && strictBusPublish) {
1376
- throw new Error(`Realtime bus publish failed: ${busResult.error?.message ?? "unknown error"}`);
1377
- }
1378
- return {
1379
- delivered: localDelivered,
1380
- busPublished: busResult.ok === true || busResult.skipped === true,
1381
- };
1382
- },
1383
-
1384
- broadcast(payload, opts = {}) {
1385
- const envelope = _encodeRealtimeEnvelope("__broadcast__", payload, senderId, maxPayloadBytes);
1386
- const decoded = _decodeRealtimeEnvelope(envelope);
1387
- let delivered = 0;
1388
- for (const ws of connections) {
1389
- if (opts.excludeWs && ws === opts.excludeWs) continue;
1390
- if (_socketBufferedBytes(ws) > maxSocketBufferBytes) {
1391
- handleBackpressure(ws, "__broadcast__");
1392
- continue;
1393
- }
1394
- try {
1395
- if (_sendRealtimePayload(ws, decoded)) {
1396
- delivered++;
1397
- } else {
1398
- metrics.dropped++;
1399
- }
1400
- } catch {
1401
- metrics.dropped++;
1402
- }
1403
- }
1404
- metrics.delivered += delivered;
1405
- metrics.published++;
1406
- return { delivered };
1407
- },
1408
-
1409
- attach(ws, attachOptions = {}) {
1410
- ensureSocketTracked(ws, attachOptions.req ?? null, attachOptions.meta ?? null);
1411
- for (const ch of attachOptions.channels ?? []) {
1412
- this.join(ws, ch);
1413
- }
1414
- return {
1415
- join: (channel) => this.join(ws, channel),
1416
- leave: (channel) => this.leave(ws, channel),
1417
- publish: (channel, payload) => this.publish(channel, payload, { excludeWs: ws }),
1418
- detach: () => cleanupSocket(ws),
1419
- };
1420
- },
1421
-
1422
- channels() {
1423
- return [...socketsByChannel.keys()];
1424
- },
1425
-
1426
- connectionCount() {
1427
- return connections.size;
1428
- },
1429
-
1430
- async _onWsUpgrade(hookCtx) {
1431
- if (connections.size >= maxConnections) {
1432
- return { allow: false, statusCode: 503, reason: "WebSocket capacity reached" };
1433
- }
1434
- const decision = await runAuthorizeHook("upgrade", hookCtx);
1435
- if (!decision.allow) {
1436
- return {
1437
- allow: false,
1438
- statusCode: decision.statusCode ?? 403,
1439
- reason: decision.reason ?? "Forbidden",
1440
- headers: decision.headers ?? {},
1441
- };
1442
- }
1443
- return { allow: true };
1444
- },
1445
-
1446
- async _onWsConnect(hookCtx) {
1447
- const { ws, req, meta } = hookCtx ?? {};
1448
- ensureSocketTracked(ws, req, meta);
1449
- const decision = await runAuthorizeHook("connect", hookCtx);
1450
- if (!decision.allow) {
1451
- try {
1452
- ws?.close?.(decision.closeCode ?? 1008, decision.closeReason ?? "Policy violation");
1453
- } catch {}
1454
- cleanupSocket(ws);
1455
- return {
1456
- allow: false,
1457
- closeCode: decision.closeCode ?? 1008,
1458
- closeReason: decision.closeReason ?? "Policy violation",
1459
- };
1460
- }
1461
- return { allow: true };
1462
- },
1463
-
1464
- _registerConnection(ws, req, meta) {
1465
- ensureSocketTracked(ws, req, meta);
1466
- },
1467
-
1468
- _cleanupConnection(ws) {
1469
- cleanupSocket(ws);
1470
- },
1471
-
1472
- _metrics() {
1473
- return {
1474
- connections: connections.size,
1475
- channels: socketsByChannel.size,
1476
- published: metrics.published,
1477
- delivered: metrics.delivered,
1478
- dropped: metrics.dropped,
1479
- backpressureDropped: metrics.backpressureDropped,
1480
- busPublishErrors: metrics.busPublishErrors,
1481
- redisReconnects: metrics.redisReconnects,
1482
- };
1483
- },
1484
-
1485
- async _shutdown() {
1486
- for (const ws of connections) {
1487
- cleanupSocket(ws);
1488
- }
1489
- if (builtInSubWatchdog) {
1490
- clearInterval(builtInSubWatchdog);
1491
- builtInSubWatchdog = null;
1492
- }
1493
- if (redisSub) {
1494
- if (usingIoRedis) {
1495
- redisSub.removeAllListeners("message");
1496
- }
1497
- await redisSub.quit?.();
1498
- redisSub = null;
1499
- }
1500
- if (redisPub) {
1501
- await redisPub.quit?.();
1502
- redisPub = null;
1503
- }
1504
- },
1505
- };
1506
-
1507
- return client;
1508
- },
1509
-
1510
- async disconnect(client) {
1511
- await client._shutdown?.();
1512
- },
1513
-
1514
- metrics(client) {
1515
- return client._metrics?.() ?? {};
1516
- },
1517
-
1518
- onWsUpgrade(client, ctx) {
1519
- return client._onWsUpgrade?.(ctx);
1520
- },
1521
-
1522
- async onWsConnect(client, ctx) {
1523
- return client._onWsConnect?.(ctx);
1524
- },
1525
-
1526
- onWsClose(client, ctx) {
1527
- client._cleanupConnection?.(ctx.ws);
1528
- },
1529
- };
1530
- }
1531
-
1532
- // ─── Cron ──────────────────────────────────────────────────
1533
-
1534
- /**
1535
- * Parse a single cron field into a Set of matching values.
1536
- * Supports: star, star-slash-N, N, N-M, N-M/S, comma-separated lists.
1537
- * @param {string} field - cron field string
1538
- * @param {number} min - minimum valid value
1539
- * @param {number} max - maximum valid value
1540
- * @returns {Set<number>}
1541
- */
1542
- function _parseCronField(field, min, max) {
1543
- const values = new Set();
1544
- for (const part of field.split(',')) {
1545
- const trimmed = part.trim();
1546
- // */N or N-M/S
1547
- const stepMatch = trimmed.match(/^(\*|(\d+)-(\d+))\/(\d+)$/);
1548
- if (stepMatch) {
1549
- const step = parseInt(stepMatch[4], 10);
1550
- if (step <= 0) throw new Error(`Invalid cron step: ${trimmed}`);
1551
- let start = min, end = max;
1552
- if (stepMatch[2] !== undefined) {
1553
- start = parseInt(stepMatch[2], 10);
1554
- end = parseInt(stepMatch[3], 10);
1555
- }
1556
- for (let i = start; i <= end; i += step) values.add(i);
1557
- continue;
1558
- }
1559
- // *
1560
- if (trimmed === '*') {
1561
- for (let i = min; i <= max; i++) values.add(i);
1562
- continue;
1563
- }
1564
- // N-M range
1565
- const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
1566
- if (rangeMatch) {
1567
- const s = parseInt(rangeMatch[1], 10);
1568
- const e = parseInt(rangeMatch[2], 10);
1569
- for (let i = s; i <= e; i++) values.add(i);
1570
- continue;
1571
- }
1572
- // Single value
1573
- const num = parseInt(trimmed, 10);
1574
- if (Number.isNaN(num) || num < min || num > max) {
1575
- throw new Error(`Invalid cron value "${trimmed}" (valid range: ${min}-${max})`);
1576
- }
1577
- values.add(num);
1578
- }
1579
- return values;
1580
- }
1581
-
1582
- /**
1583
- * Parse a 5-field cron expression and return either:
1584
- * - { type: 'interval', ms } for simple periodic patterns (e.g. "* /5 * * * *")
1585
- * - { type: 'complex', minutes, hours, daysOfMonth, months, daysOfWeek } for complex patterns
1586
- */
1587
- function parseCronExpression(expr) {
1588
- const parts = expr.trim().split(/\s+/);
1589
- if (parts.length !== 5) {
1590
- throw new Error(`Invalid cron expression "${expr}": expected 5 fields, got ${parts.length}`);
1591
- }
1592
- const [minF, hourF, domF, monF, dowF] = parts;
1593
-
1594
- // Try to detect simple interval patterns for setInterval
1595
- if (hourF === '*' && domF === '*' && monF === '*' && dowF === '*') {
1596
- if (minF === '*') return { type: 'interval', ms: 60_000 };
1597
- const stepMatch = minF.match(/^\*\/(\d+)$/);
1598
- if (stepMatch) return { type: 'interval', ms: parseInt(stepMatch[1], 10) * 60_000 };
1599
- }
1600
- if (minF === '0' && domF === '*' && monF === '*' && dowF === '*') {
1601
- if (hourF === '*') return { type: 'interval', ms: 3_600_000 };
1602
- const stepMatch = hourF.match(/^\*\/(\d+)$/);
1603
- if (stepMatch) return { type: 'interval', ms: parseInt(stepMatch[1], 10) * 3_600_000 };
1604
- }
1605
- if (minF === '0' && hourF === '0' && monF === '*' && dowF === '*') {
1606
- if (domF === '*') return { type: 'interval', ms: 86_400_000 };
1607
- }
1608
-
1609
- // Complex pattern — parse all fields
1610
- return {
1611
- type: 'complex',
1612
- minutes: _parseCronField(minF, 0, 59),
1613
- hours: _parseCronField(hourF, 0, 23),
1614
- daysOfMonth: _parseCronField(domF, 1, 31),
1615
- months: _parseCronField(monF, 1, 12),
1616
- daysOfWeek: _parseCronField(dowF, 0, 6),
1617
- };
1618
- }
1619
-
1620
- /**
1621
- * Calculate milliseconds until the next matching time for a complex cron schedule.
1622
- * Scans up to 366 days ahead.
1623
- */
1624
- function _nextCronTimeout(parsed) {
1625
- const now = new Date();
1626
- const check = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes() + 1, 0, 0);
1627
- const limit = 366 * 24 * 60; // max minutes to scan
1628
- for (let i = 0; i < limit; i++) {
1629
- const m = check.getMinutes();
1630
- const h = check.getHours();
1631
- const dom = check.getDate();
1632
- const mon = check.getMonth() + 1;
1633
- const dow = check.getDay();
1634
- if (parsed.minutes.has(m) && parsed.hours.has(h) &&
1635
- parsed.daysOfMonth.has(dom) && parsed.months.has(mon) &&
1636
- parsed.daysOfWeek.has(dow)) {
1637
- return check.getTime() - now.getTime();
1638
- }
1639
- check.setMinutes(check.getMinutes() + 1);
1640
- }
1641
- throw new Error('Could not find next cron run time within 366 days');
1642
- }
1643
-
1644
- export function cron(options = {}) {
1645
- const leaderOnly = options.leaderOnly !== false;
1646
- return {
1647
- name: "cron",
1648
- version: "1.0.0",
1649
- inject: "cron",
1650
- async connect(ctx) {
1651
- // Leader election: workerId === 0 is the default leader.
1652
- // The supervisor can explicitly designate a leader via ctx._isCronLeader.
1653
- const isLeader = ctx.workerId === 0 || ctx._isCronLeader === true,
1654
- jobs = new Map(),
1655
- timers = [];
1656
-
1657
- if (leaderOnly && !isLeader && ctx.workerId !== 0) {
1658
- ctx.logger.info(`Cron: worker ${ctx.workerId} is not the cron leader, cron jobs will not run on this worker`);
1659
- }
1660
-
1661
- return {
1662
- jobs,
1663
- schedule(name, expr, fn, options = {}) {
1664
- if (leaderOnly && !isLeader) return;
1665
-
1666
- const parsed = parseCronExpression(expr);
1667
-
1668
- // Run immediately unless explicitly disabled
1669
- if (options.immediate !== false) {
1670
- (async () => {
1671
- try {
1672
- ctx.logger.info(`Cron: ${name} (initial run)`);
1673
- await fn();
1674
- } catch (e) {
1675
- ctx.logger.error(`Cron "${name}" initial run failed: ${e.message}`);
1676
- }
1677
- })();
1678
- }
1679
-
1680
- let _running = false;
1681
- let _lastSuccess = Date.now();
1682
-
1683
- const runJob = async () => {
1684
- const intervalMs = parsed.type === 'interval' ? parsed.ms : 60_000;
1685
- const timeoutMs = Math.floor(intervalMs * 0.9);
1686
- if (_running) {
1687
- if (Date.now() - _lastSuccess > intervalMs * 2) {
1688
- ctx.logger.warn(`Cron "${name}" appears stuck — no success for ${Math.round((Date.now() - _lastSuccess) / 1000)}s`);
1689
- }
1690
- return;
1691
- }
1692
- _running = true;
1693
- try {
1694
- ctx.logger.info(`Cron: ${name}`);
1695
- await Promise.race([
1696
- fn(),
1697
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Cron "${name}" timed out after ${timeoutMs}ms`)), timeoutMs)),
1698
- ]);
1699
- _lastSuccess = Date.now();
1700
- } catch (e) {
1701
- ctx.logger.error(`Cron "${name}" failed: ${e.message}`);
1702
- } finally {
1703
- _running = false;
1704
- }
1705
- };
1706
-
1707
- let t;
1708
- if (parsed.type === 'interval') {
1709
- t = setInterval(runJob, parsed.ms);
1710
- jobs.set(name, { expr, timer: t });
1711
- timers.push(t);
1712
- ctx.logger.info(`Cron: "${name}" every ${parsed.ms / 1000}s`);
1713
- } else {
1714
- // Complex pattern: use setTimeout with recalculation
1715
- let active = true;
1716
- function scheduleNext() {
1717
- if (!active) return;
1718
- const delayMs = _nextCronTimeout(parsed);
1719
- t = setTimeout(async () => {
1720
- await runJob();
1721
- scheduleNext();
1722
- }, delayMs);
1723
- jobs.set(name, { expr, timer: t, _cancelComplex() { active = false; } });
1724
- // Update the timers array entry
1725
- const idx = timers.indexOf(t);
1726
- if (idx === -1) timers.push(t);
1727
- }
1728
- scheduleNext();
1729
- ctx.logger.info(`Cron: "${name}" scheduled (complex expression: ${expr})`);
1730
- }
1731
- },
1732
- cancel(name) {
1733
- const j = jobs.get(name);
1734
- if (j) {
1735
- clearInterval(j.timer);
1736
- clearTimeout(j.timer);
1737
- if (j._cancelComplex) j._cancelComplex();
1738
- const idx = timers.indexOf(j.timer);
1739
- if (idx !== -1) timers.splice(idx, 1);
1740
- jobs.delete(name);
1741
- }
1742
- },
1743
- listJobs: () => [...jobs.keys()],
1744
- _timers: timers,
1745
- };
1746
- },
1747
- async disconnect(c) {
1748
- c._timers.forEach((t) => { clearInterval(t); clearTimeout(t); });
1749
- for (const j of c.jobs.values()) {
1750
- if (j._cancelComplex) j._cancelComplex();
1751
- }
1752
- c._timers.length = 0;
1753
- c.jobs.clear();
1754
- },
1755
- };
1756
- }