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