@huynle/hub 0.6.0 → 0.7.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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,425 @@
1
+ // Bus over Valkey/Redis pub/sub (decision vkxkqiuf §1, §5): the cluster's implementation of ./types Bus.
2
+ //
3
+ // Connections (Bun.RedisClient, opened lazily on first use, never in the constructor):
4
+ // - one command connection for PUBLISH;
5
+ // - subscriber connections made with duplicate() (a subscribed connection takes no other commands). A topic's
6
+ // k-th local subscription goes on the k-th subscriber connection, so each connection listens to a topic at most
7
+ // once and PUBLISH's count (subscribed connections) is exactly the number of subscriptions in the cluster.
8
+ // Usually that is one subscriber connection per instance; connection 0 also carries rpc and reply channels.
9
+ //
10
+ // Channels, under `prefix` (default "hub:bus:"): t:<topic> for publish/subscribe, rpc:<topic> for requests,
11
+ // reply:<instanceID> for this instance's replies, sync:<instanceID>:<n> for sync() markers. Payloads are JSON
12
+ // envelopes, so every subscriber decodes its own copy.
13
+ //
14
+ // Request/reply: request() publishes {id, replyTo, m} on rpc:<topic>; 0 receivers is BusNoResponderError at once.
15
+ // The responder publishes {id, ok, v | e} to replyTo; ok:false becomes BusRemoteError. The timeout starts before
16
+ // anything is sent. One responder per topic per instance (not enforced across instances: the first reply wins;
17
+ // pod-scoped topics such as pod.<id>.rpc have one server by construction).
18
+ //
19
+ // Delivery is at most once and publish resolves on send, before subscribers ran. sync() waits until everything
20
+ // published before it has reached this instance's subscribers and their handlers settled (a marker through each
21
+ // subscriber connection: Redis keeps per-connection order). Do not await sync() inside a handler.
22
+ //
23
+ // Reconnects: Bun reconnects a dropped connection by itself, but the server has forgotten its subscriptions while
24
+ // the client keeps its listeners and leaves subscriber mode (Bun 1.3.5). On a subscriber connection's onconnect the
25
+ // bus closes it and subscribes its channels on a fresh duplicate. Messages sent while it was down are lost.
26
+ import { randomUUID } from "node:crypto"
27
+ import { realTimers } from "./memory-bus"
28
+ import { BusNoResponderError, BusRemoteError, BusTimeoutError, type Bus, type BusHandler, type BusResponder, type Data, type RequestOptions, type Timers, type Unsubscribe } from "./types"
29
+
30
+ type Listener = (message: string, channel: string) => void
31
+
32
+ /** The part of Bun.RedisClient the bus uses (a fake in unit tests). */
33
+ export interface BusRedis {
34
+ publish(channel: string, message: string): Promise<number>
35
+ subscribe(channel: string, listener: Listener): Promise<number>
36
+ unsubscribe(channel: string, listener?: Listener): Promise<void>
37
+ duplicate(): Promise<BusRedis>
38
+ close(): void
39
+ /** Runs when the client (re)connects. */
40
+ onconnect: (() => void) | null
41
+ }
42
+
43
+ export interface ValkeyBusOptions {
44
+ /** Opens the command connection (default: new Bun.RedisClient(url)); subscriber connections are its duplicates. */
45
+ readonly client?: () => BusRedis
46
+ /** Names this instance's reply and sync channels (default: a random UUID; unique per process). */
47
+ readonly instanceID?: string
48
+ /** Request timeouts (injectable for tests). */
49
+ readonly timers?: Timers
50
+ /** Called with a subscriber's or responder's error (default: logged). */
51
+ readonly onError?: (error: unknown, topic: string) => void
52
+ /** Channel name prefix (default "hub:bus:"). */
53
+ readonly prefix?: string
54
+ /** How long sync() waits for a marker to come back (default 5000 ms). */
55
+ readonly syncTimeoutMs?: number
56
+ }
57
+
58
+ /** A subscriber connection slot; its connection is replaced after a reconnect. */
59
+ interface SubscriberConnection {
60
+ /** The current connection, once it listens on every channel the slot had when it opened. */
61
+ client: Promise<BusRedis>
62
+ /** The current connection once it is open (close() closes it at once). */
63
+ open?: BusRedis
64
+ /** The topics subscribed on this slot (each at most once). */
65
+ readonly topics: Set<string>
66
+ /** Every channel the slot listens on, with its one listener (what a replacement connection subscribes). */
67
+ readonly channels: Map<string, Listener>
68
+ readonly syncChannel: string
69
+ }
70
+
71
+ interface PendingRequest {
72
+ readonly topic: string
73
+ readonly resolve: (reply: Data) => void
74
+ readonly reject: (error: unknown) => void
75
+ readonly timer: unknown
76
+ }
77
+
78
+ /** On an rpc channel. */
79
+ interface RequestEnvelope {
80
+ readonly id: string
81
+ readonly replyTo: string
82
+ readonly m?: Data
83
+ }
84
+
85
+ /** On a reply channel: the reply (`v`) or the responder's error message (`e`). */
86
+ interface ReplyEnvelope {
87
+ readonly id: string
88
+ readonly ok: boolean
89
+ readonly v?: Data
90
+ readonly e?: string
91
+ }
92
+
93
+ export class ValkeyBus implements Bus {
94
+ private readonly makeClient: () => BusRedis
95
+ private readonly instanceID: string
96
+ private readonly prefix: string
97
+ private readonly onError: (error: unknown, topic: string) => void
98
+ private readonly syncTimeoutMs: number
99
+ private readonly timers: Timers
100
+ private readonly replyChannel: string
101
+ private readonly responders = new Map<string, BusResponder>()
102
+ private readonly pending = new Map<string, PendingRequest>()
103
+ private nextRequest = 0
104
+ private replies?: Promise<void>
105
+ private main?: BusRedis
106
+ private readonly subscribers = new Map<number, SubscriberConnection>()
107
+ private readonly inFlight = new Set<Promise<void>>()
108
+ private readonly syncWaiters = new Map<string, () => void>()
109
+ /** The SUBSCRIBE sent per connection and channel (so a channel is never subscribed twice on one connection). */
110
+ private readonly subscribed = new WeakMap<BusRedis, Map<string, Promise<unknown>>>()
111
+ private closed = false
112
+
113
+ constructor(url: string, options: ValkeyBusOptions = {}) {
114
+ this.makeClient = options.client ?? (() => new Bun.RedisClient(url))
115
+ this.instanceID = options.instanceID ?? randomUUID()
116
+ this.prefix = options.prefix ?? "hub:bus:"
117
+ this.onError = options.onError ?? ((error, topic) => console.error(`hub: bus subscriber on ${topic} failed:`, error))
118
+ this.syncTimeoutMs = options.syncTimeoutMs ?? 5000
119
+ this.timers = options.timers ?? realTimers
120
+ this.replyChannel = `${this.prefix}reply:${this.instanceID}`
121
+ }
122
+
123
+ async publish(topic: string, message: Data): Promise<number> {
124
+ return this.connection().publish(this.topicChannel(topic), JSON.stringify({ m: message }))
125
+ }
126
+
127
+ async subscribe(topic: string, handler: BusHandler): Promise<Unsubscribe> {
128
+ this.assertOpen()
129
+ let index = 0
130
+ while (this.subscribers.get(index)?.topics.has(topic)) index++
131
+ const subscriber = this.subscriber(index)
132
+ subscriber.topics.add(topic)
133
+ const channel = this.topicChannel(topic)
134
+ let done = false
135
+ // The guard covers a channel given up while its connection was being replaced.
136
+ const listener: Listener = (raw) => {
137
+ if (!done) this.dispatch(topic, raw, handler)
138
+ }
139
+ try {
140
+ await this.listen(subscriber, channel, listener)
141
+ } catch (error) {
142
+ subscriber.topics.delete(topic)
143
+ throw error
144
+ }
145
+ return async () => {
146
+ if (done) return
147
+ done = true
148
+ subscriber.topics.delete(topic)
149
+ await this.unlisten(subscriber, channel, listener)
150
+ }
151
+ }
152
+
153
+ async serve(topic: string, responder: BusResponder): Promise<Unsubscribe> {
154
+ this.assertOpen()
155
+ if (this.responders.has(topic)) throw new Error(`already serving ${topic}`)
156
+ this.responders.set(topic, responder)
157
+ const channel = this.rpcChannel(topic)
158
+ const listener: Listener = (raw) => this.answer(topic, raw, responder)
159
+ const subscriber = this.subscriber(0)
160
+ try {
161
+ await this.listen(subscriber, channel, listener)
162
+ } catch (error) {
163
+ if (this.responders.get(topic) === responder) this.responders.delete(topic)
164
+ throw error
165
+ }
166
+ let done = false
167
+ return async () => {
168
+ if (done) return
169
+ done = true
170
+ if (this.responders.get(topic) === responder) this.responders.delete(topic)
171
+ await this.unlisten(subscriber, channel, listener)
172
+ }
173
+ }
174
+
175
+ request<T = Data>(topic: string, message: Data, options: RequestOptions): Promise<T> {
176
+ return new Promise<T>((resolve, reject) => {
177
+ if (this.closed) return reject(new Error("bus closed"))
178
+ const id = String(++this.nextRequest)
179
+ // Set before anything is sent, so the timeout covers connecting too.
180
+ const timer = this.timers.set(() => this.settle(id, (p) => p.reject(new BusTimeoutError(topic, options.timeoutMs))), options.timeoutMs)
181
+ this.pending.set(id, { topic, resolve: resolve as (reply: Data) => void, reject, timer })
182
+ const envelope: RequestEnvelope = { id, replyTo: this.replyChannel, m: message }
183
+ void (async () => {
184
+ await this.listenForReplies()
185
+ const receivers = await this.connection().publish(this.rpcChannel(topic), JSON.stringify(envelope))
186
+ if (receivers === 0) this.settle(id, (p) => p.reject(new BusNoResponderError(topic)))
187
+ })().catch((error) => this.settle(id, (p) => p.reject(error)))
188
+ })
189
+ }
190
+
191
+ /** Resolves once every message published before the call reached this instance's handlers and they settled. */
192
+ async sync(): Promise<void> {
193
+ await Promise.all([...this.subscribers.values()].map((subscriber) => this.syncSubscriber(subscriber)))
194
+ await Promise.allSettled([...this.inFlight])
195
+ }
196
+
197
+ private async syncSubscriber(subscriber: SubscriberConnection): Promise<void> {
198
+ await subscriber.client
199
+ const token = randomUUID()
200
+ let timer: unknown
201
+ try {
202
+ const seen = new Promise<void>((resolve, reject) => {
203
+ this.syncWaiters.set(token, resolve)
204
+ timer = realTimers.set(() => reject(new Error(`bus sync: no echo within ${this.syncTimeoutMs} ms`)), this.syncTimeoutMs)
205
+ })
206
+ if ((await this.connection().publish(subscriber.syncChannel, token)) === 0) throw new Error("bus sync: subscriber connection lost")
207
+ await seen
208
+ } finally {
209
+ this.syncWaiters.delete(token)
210
+ realTimers.clear(timer)
211
+ }
212
+ }
213
+
214
+ private dispatch(topic: string, raw: string, handler: BusHandler): void {
215
+ let message: Data
216
+ try {
217
+ message = (JSON.parse(raw) as { m?: Data }).m
218
+ } catch (error) {
219
+ this.onError(error, topic)
220
+ return
221
+ }
222
+ const run = (async () => {
223
+ try {
224
+ await handler(message, topic)
225
+ } catch (error) {
226
+ this.onError(error, topic)
227
+ }
228
+ })()
229
+ this.track(run)
230
+ }
231
+
232
+ /** Closes every connection and rejects pending requests; later calls reject. */
233
+ close(): void {
234
+ if (this.closed) return
235
+ this.closed = true
236
+ for (const id of [...this.pending.keys()]) this.settle(id, (p) => p.reject(new Error("bus closed")))
237
+ this.main?.close()
238
+ // A connection still opening closes itself when it arrives (see subscriber()).
239
+ for (const subscriber of this.subscribers.values()) subscriber.open?.close()
240
+ this.subscribers.clear()
241
+ }
242
+
243
+ /** Answers one request on a served topic: the responder's reply or error goes to the asker's reply channel. */
244
+ private answer(topic: string, raw: string, responder: BusResponder): void {
245
+ let request: RequestEnvelope
246
+ try {
247
+ request = JSON.parse(raw) as RequestEnvelope
248
+ if (typeof request?.id !== "string" || typeof request.replyTo !== "string") throw new Error("malformed request")
249
+ } catch (error) {
250
+ this.onError(error, topic)
251
+ return
252
+ }
253
+ const run = (async () => {
254
+ let reply: string
255
+ try {
256
+ reply = JSON.stringify({ id: request.id, ok: true, v: await responder(request.m, topic) } satisfies ReplyEnvelope)
257
+ } catch (error) {
258
+ reply = JSON.stringify({ id: request.id, ok: false, e: error instanceof Error ? error.message : String(error) } satisfies ReplyEnvelope)
259
+ }
260
+ if (this.closed) return
261
+ try {
262
+ await this.connection().publish(request.replyTo, reply)
263
+ } catch (error) {
264
+ // Closing this instance cancels replies on the way; that is not a responder failure.
265
+ if (!this.closed) this.onError(error, topic)
266
+ }
267
+ })()
268
+ this.track(run)
269
+ }
270
+
271
+ /** Subscribes this instance's reply channel (once). */
272
+ private listenForReplies(): Promise<void> {
273
+ this.replies ??= (async () => {
274
+ await this.listen(this.subscriber(0), this.replyChannel, (raw) => this.receiveReply(raw))
275
+ })().catch((error) => {
276
+ this.replies = undefined
277
+ throw error
278
+ })
279
+ return this.replies
280
+ }
281
+
282
+ private receiveReply(raw: string): void {
283
+ let reply: ReplyEnvelope
284
+ try {
285
+ reply = JSON.parse(raw) as ReplyEnvelope
286
+ } catch (error) {
287
+ this.onError(error, this.replyChannel)
288
+ return
289
+ }
290
+ this.settle(String(reply?.id), (p) => (reply.ok ? p.resolve(reply.v) : p.reject(new BusRemoteError(p.topic, String(reply.e)))))
291
+ }
292
+
293
+ /** Ends a pending request once (later replies, timeouts and errors are ignored). */
294
+ private settle(id: string, fn: (pending: PendingRequest) => void): void {
295
+ const pending = this.pending.get(id)
296
+ if (!pending) return
297
+ this.pending.delete(id)
298
+ this.timers.clear(pending.timer)
299
+ fn(pending)
300
+ }
301
+
302
+ private track(run: Promise<void>): void {
303
+ this.inFlight.add(run)
304
+ void run.finally(() => this.inFlight.delete(run))
305
+ }
306
+
307
+ private assertOpen(): void {
308
+ if (this.closed) throw new Error("bus closed")
309
+ }
310
+
311
+ private connection(): BusRedis {
312
+ this.assertOpen()
313
+ return (this.main ??= this.makeClient())
314
+ }
315
+
316
+ /** Listens on a channel through a subscriber slot (one listener per channel and slot). */
317
+ private async listen(subscriber: SubscriberConnection, channel: string, listener: Listener): Promise<void> {
318
+ subscriber.channels.set(channel, listener)
319
+ for (;;) {
320
+ const current = subscriber.client
321
+ try {
322
+ const client = await current
323
+ if (current !== subscriber.client) continue
324
+ await this.subscribeOnce(client, channel, listener)
325
+ return
326
+ } catch (error) {
327
+ // Replaced meanwhile: the new connection subscribes what the slot has; make sure of it on that one.
328
+ if (current !== subscriber.client) continue
329
+ if (subscriber.channels.get(channel) === listener) subscriber.channels.delete(channel)
330
+ throw error
331
+ }
332
+ }
333
+ }
334
+
335
+ private subscribeOnce(client: BusRedis, channel: string, listener: Listener): Promise<unknown> {
336
+ let sent = this.subscribed.get(client)
337
+ if (!sent) this.subscribed.set(client, (sent = new Map()))
338
+ const existing = sent.get(channel)
339
+ if (existing) return existing
340
+ const subscribing = client.subscribe(channel, listener).catch((error) => {
341
+ sent.delete(channel)
342
+ throw error
343
+ })
344
+ sent.set(channel, subscribing)
345
+ return subscribing
346
+ }
347
+
348
+ /**
349
+ * Stops listening on a channel. An UNSUBSCRIBE that fails (the connection is down or being replaced) is not an
350
+ * error: the replacement connection subscribes only what the slot still has.
351
+ */
352
+ private async unlisten(subscriber: SubscriberConnection, channel: string, listener: Listener): Promise<void> {
353
+ if (subscriber.channels.get(channel) !== listener) return
354
+ subscriber.channels.delete(channel)
355
+ const client = subscriber.open
356
+ if (!client || this.closed) return
357
+ this.subscribed.get(client)?.delete(channel)
358
+ await client.unsubscribe(channel, listener).catch(() => {})
359
+ }
360
+
361
+ private subscriber(index: number): SubscriberConnection {
362
+ const existing = this.subscribers.get(index)
363
+ if (existing) return existing
364
+ const syncChannel = `${this.prefix}sync:${this.instanceID}:${index}`
365
+ const sync: Listener = (token) => this.syncWaiters.get(token)?.()
366
+ const subscriber: SubscriberConnection = { client: Promise.resolve(undefined!), topics: new Set(), channels: new Map([[syncChannel, sync]]), syncChannel }
367
+ this.subscribers.set(index, subscriber)
368
+ this.openSubscriber(subscriber, index)
369
+ return subscriber
370
+ }
371
+
372
+ /** Opens the slot's connection (a duplicate of the command connection) and subscribes the slot's channels. */
373
+ private openSubscriber(subscriber: SubscriberConnection, index: number): void {
374
+ const replacing = subscriber.open !== undefined
375
+ subscriber.open = undefined
376
+ subscriber.client = (async () => {
377
+ const client = await this.connection().duplicate()
378
+ if (this.closed) {
379
+ client.close()
380
+ throw new Error("bus closed")
381
+ }
382
+ subscriber.open = client
383
+ client.onconnect = () => void this.checkSubscriber(subscriber, client, index)
384
+ await Promise.all([...subscriber.channels].map(([channel, listener]) => this.subscribeOnce(client, channel, listener)))
385
+ return client
386
+ })()
387
+ subscriber.client.catch((error) => {
388
+ if (this.closed || this.subscribers.get(index) !== subscriber) return
389
+ // Later calls open a new slot; what this one carried is gone.
390
+ this.subscribers.delete(index)
391
+ subscriber.open?.close()
392
+ if (replacing) this.onError(error, `subscriber connection ${index}`)
393
+ })
394
+ }
395
+
396
+ /**
397
+ * On a subscriber connection's onconnect (Bun runs it for the first connection too, and after reconnecting a
398
+ * dropped one): once the slot has opened, a PUBLISH on its sync channel reaching nobody means the server forgot
399
+ * the subscriptions (a reconnect). The client is then in no state to redo them cleanly (it keeps its listeners
400
+ * but left subscriber mode): close it and subscribe the slot's channels on a fresh connection.
401
+ */
402
+ private async checkSubscriber(subscriber: SubscriberConnection, client: BusRedis, index: number): Promise<void> {
403
+ try {
404
+ await subscriber.client
405
+ if (this.closed || subscriber.open !== client) return
406
+ if ((await this.connection().publish(subscriber.syncChannel, "probe")) > 0) return
407
+ } catch (error) {
408
+ if (this.closed || subscriber.open !== client) return
409
+ this.onError(error, `subscriber connection ${index}`)
410
+ return
411
+ }
412
+ if (this.closed || subscriber.open !== client) return
413
+ client.onconnect = null
414
+ client.close()
415
+ this.openSubscriber(subscriber, index)
416
+ }
417
+
418
+ private topicChannel(topic: string): string {
419
+ return `${this.prefix}t:${topic}`
420
+ }
421
+
422
+ private rpcChannel(topic: string): string {
423
+ return `${this.prefix}rpc:${topic}`
424
+ }
425
+ }
@@ -0,0 +1,190 @@
1
+ // Catalog in Valkey (design §9): one hash per user, a field per (sessionID, connID) report, merged on read, so
2
+ // every pod writes only its own connections' reports and any pod reads them all.
3
+ //
4
+ // A field is the JSON array [sessionID, connID]; its value is `<seq> <json>`, seq a per-user counter that keeps
5
+ // report order (a new report moves to the end) when the clock ties. A set per (user, connection) indexes the
6
+ // connection's fields, so deleteConn (the connection went away) removes them without scanning. Valkey 8 has no
7
+ // hash-field TTL, so a report with a ttl carries expiresAt (the `now` clock) and reads skip it once passed; the
8
+ // reader then removes it (only if it is still the report it read, so a concurrent renewal stays). Reports of a
9
+ // pod that died without saying so go that way, or through deleteConn from a sweeper.
10
+ import { RedisClient } from "bun"
11
+ import { mergeNewestWins, type CatalogMerge } from "./memory-catalog"
12
+ import type { Catalog, CatalogEntry, CatalogSession, CatalogValue, Clock } from "./types"
13
+
14
+ export interface ValkeyCatalogOptions {
15
+ readonly now?: Clock
16
+ /** How a session's reports combine (default: the newest report wins per field). */
17
+ readonly merge?: CatalogMerge
18
+ /** Key prefix (default "hub:"). */
19
+ readonly prefix?: string
20
+ }
21
+
22
+ /** Where a user's catalog lives. Every key shares the user's hash tag, so scripts may use them together. */
23
+ export function valkeyCatalogKeys(userID: string, prefix = "hub:"): { reports: string; seq: string; conn: (connID: string) => string } {
24
+ const reports = `${prefix}cat:{${encodeURIComponent(userID)}}`
25
+ return { reports, seq: `${reports}:seq`, conn: (connID) => `${reports}:conn:${connID}` }
26
+ }
27
+
28
+ // Shared by the scripts: KEYS[1] reports, KEYS[2] seq; drops the counter with the last report.
29
+ const TIDY = `
30
+ local function tidy()
31
+ if redis.call('HLEN', KEYS[1]) == 0 then redis.call('DEL', KEYS[2]) end
32
+ end
33
+ local function connOf(field) return cjson.decode(field)[2] end
34
+ `
35
+
36
+ // KEYS[3] the connection's index; ARGV[1] field, ARGV[2] stored JSON.
37
+ const SET = `
38
+ local seq = redis.call('INCR', KEYS[2])
39
+ redis.call('HSET', KEYS[1], ARGV[1], seq .. ' ' .. ARGV[2])
40
+ redis.call('SADD', KEYS[3], ARGV[1])
41
+ `
42
+
43
+ // KEYS[3] the connection's index; ARGV[1] field.
44
+ const DELETE_ONE = `${TIDY}
45
+ redis.call('HDEL', KEYS[1], ARGV[1])
46
+ redis.call('SREM', KEYS[3], ARGV[1])
47
+ tidy()
48
+ `
49
+
50
+ // ARGV[1] the session's field prefix, ARGV[2] the index key prefix.
51
+ const DELETE_SESSION = `${TIDY}
52
+ for _, field in ipairs(redis.call('HKEYS', KEYS[1])) do
53
+ if string.sub(field, 1, #ARGV[1]) == ARGV[1] then
54
+ redis.call('HDEL', KEYS[1], field)
55
+ redis.call('SREM', ARGV[2] .. connOf(field), field)
56
+ end
57
+ end
58
+ tidy()
59
+ `
60
+
61
+ // KEYS[3] the connection's index.
62
+ const DELETE_CONN = `${TIDY}
63
+ for _, field in ipairs(redis.call('SMEMBERS', KEYS[3])) do redis.call('HDEL', KEYS[1], field) end
64
+ redis.call('DEL', KEYS[3])
65
+ tidy()
66
+ `
67
+
68
+ // ARGV[1] the index key prefix, then field and value pairs: removes each field still holding that value.
69
+ const DROP_EXPIRED = `${TIDY}
70
+ for i = 2, #ARGV, 2 do
71
+ if redis.call('HGET', KEYS[1], ARGV[i]) == ARGV[i + 1] then
72
+ redis.call('HDEL', KEYS[1], ARGV[i])
73
+ redis.call('SREM', ARGV[1] .. connOf(ARGV[i]), ARGV[i])
74
+ end
75
+ end
76
+ tidy()
77
+ `
78
+
79
+ interface Stored {
80
+ readonly v: CatalogValue
81
+ readonly at: number
82
+ readonly e?: number
83
+ }
84
+
85
+ interface Report extends CatalogEntry {
86
+ readonly seq: number
87
+ }
88
+
89
+ const fieldOf = (sessionID: string, connID: string) => JSON.stringify([sessionID, connID])
90
+ /** The start of every field of a session. */
91
+ const sessionPrefix = (sessionID: string) => `${JSON.stringify([sessionID]).slice(0, -1)},`
92
+
93
+ export class ValkeyCatalog implements Catalog {
94
+ private readonly client: RedisClient
95
+ private readonly ownsClient: boolean
96
+ private readonly now: Clock
97
+ private readonly merge: CatalogMerge
98
+ private readonly prefix: string
99
+
100
+ /** On a url (its own connection, made on first use) or a client the caller owns. */
101
+ constructor(valkey: string | RedisClient, options: ValkeyCatalogOptions = {}) {
102
+ this.ownsClient = typeof valkey === "string"
103
+ this.client = typeof valkey === "string" ? new RedisClient(valkey) : valkey
104
+ this.now = options.now ?? Date.now
105
+ this.merge = options.merge ?? mergeNewestWins
106
+ this.prefix = options.prefix ?? "hub:"
107
+ }
108
+
109
+ async set(userID: string, sessionID: string, connID: string, value: CatalogValue, options: { readonly ttlMs?: number } = {}): Promise<void> {
110
+ const at = this.now()
111
+ const stored: Stored = { v: value, at, ...(options.ttlMs !== undefined ? { e: at + options.ttlMs } : {}) }
112
+ await this.eval(SET, userID, connID, [fieldOf(sessionID, connID), JSON.stringify(stored)])
113
+ }
114
+
115
+ async delete(userID: string, sessionID: string, connID?: string): Promise<void> {
116
+ if (connID !== undefined) await this.eval(DELETE_ONE, userID, connID, [fieldOf(sessionID, connID)])
117
+ else await this.eval(DELETE_SESSION, userID, undefined, [sessionPrefix(sessionID), this.keys(userID).conn("")])
118
+ }
119
+
120
+ async deleteConn(userID: string, connID: string): Promise<void> {
121
+ await this.eval(DELETE_CONN, userID, connID, [])
122
+ }
123
+
124
+ async get(userID: string, sessionID: string): Promise<CatalogSession | undefined> {
125
+ const reports = (await this.live(userID)).filter((report) => report.sessionID === sessionID)
126
+ return reports.length ? this.session(sessionID, reports) : undefined
127
+ }
128
+
129
+ async list(userID: string): Promise<CatalogSession[]> {
130
+ const bySession = new Map<string, CatalogEntry[]>()
131
+ for (const report of await this.live(userID)) {
132
+ const reports = bySession.get(report.sessionID)
133
+ if (reports) reports.push(report)
134
+ else bySession.set(report.sessionID, [report])
135
+ }
136
+ return [...bySession.keys()].sort().map((sessionID) => this.session(sessionID, bySession.get(sessionID)!))
137
+ }
138
+
139
+ async entries(userID: string): Promise<CatalogEntry[]> {
140
+ return (await this.live(userID)).map(({ sessionID, connID, value, at }) => ({ sessionID, connID, value, at }))
141
+ }
142
+
143
+ /** Closes the connection when the catalog made it. */
144
+ close(): void {
145
+ if (this.ownsClient) this.client.close()
146
+ }
147
+
148
+ /** The user's unexpired reports in report order; expired ones are removed from Valkey. */
149
+ private async live(userID: string): Promise<Report[]> {
150
+ const raw = await this.client.send("HGETALL", [this.keys(userID).reports])
151
+ const pairs: [string, string][] = Array.isArray(raw)
152
+ ? Array.from({ length: raw.length / 2 }, (_, i) => [String(raw[2 * i]), String(raw[2 * i + 1])])
153
+ : Object.entries((raw ?? {}) as Record<string, string>)
154
+ const t = this.now()
155
+ const reports: Report[] = []
156
+ const expired: string[] = []
157
+ for (const [field, stored] of pairs) {
158
+ const space = stored.indexOf(" ")
159
+ const { v, at, e } = JSON.parse(stored.slice(space + 1)) as Stored
160
+ if (e !== undefined && e <= t) {
161
+ expired.push(field, stored)
162
+ continue
163
+ }
164
+ const [sessionID, connID] = JSON.parse(field) as [string, string]
165
+ reports.push({ sessionID, connID, value: v, at, seq: Number(stored.slice(0, space)) })
166
+ }
167
+ if (expired.length) await this.eval(DROP_EXPIRED, userID, undefined, [this.keys(userID).conn(""), ...expired])
168
+ return reports.sort((a, b) => a.seq - b.seq)
169
+ }
170
+
171
+ private session(sessionID: string, reports: CatalogEntry[]): CatalogSession {
172
+ return {
173
+ sessionID,
174
+ value: structuredClone(this.merge(reports)),
175
+ connIDs: reports.map((report) => report.connID),
176
+ at: Math.max(...reports.map((report) => report.at)),
177
+ }
178
+ }
179
+
180
+ private keys(userID: string) {
181
+ return valkeyCatalogKeys(userID, this.prefix)
182
+ }
183
+
184
+ /** Runs a script on the user's keys (the connection's index third, when the script uses one). */
185
+ private eval(script: string, userID: string, connID: string | undefined, args: string[]): Promise<unknown> {
186
+ const keys = this.keys(userID)
187
+ const list = connID === undefined ? [keys.reports, keys.seq] : [keys.reports, keys.seq, keys.conn(connID)]
188
+ return this.client.send("EVAL", [script, String(list.length), ...list, ...args])
189
+ }
190
+ }
@@ -0,0 +1,36 @@
1
+ // A lazily opened Valkey connection for the Valkey backends (design §9; decision vkxkqiuf: Bun.RedisClient,
2
+ // no new dependency). The hub builds its backends synchronously (startCentral), so nothing connects, or even
3
+ // parses the url, until the first command. Bun.RedisClient accepts redis://, rediss://, valkey:// and
4
+ // valkeys:// urls; it reconnects on its own and queues commands while offline.
5
+
6
+ /** The part of Bun.RedisClient the backends use (a shared client, or a test double). */
7
+ export interface ValkeyClient {
8
+ send(command: string, args: string[]): Promise<unknown>
9
+ close(): void
10
+ }
11
+
12
+ /** A url (the connection opens and owns its own client) or a client shared with other backends. */
13
+ export type ValkeySource = string | ValkeyClient
14
+
15
+ export class ValkeyConnection {
16
+ private client?: ValkeyClient
17
+ private readonly url?: string
18
+
19
+ constructor(source: ValkeySource) {
20
+ if (typeof source === "string") this.url = source
21
+ else this.client = source
22
+ }
23
+
24
+ /** Sends a command, opening the url's client on first use. */
25
+ async send(command: string, args: string[]): Promise<unknown> {
26
+ this.client ??= new Bun.RedisClient(this.url!)
27
+ return this.client.send(command, args)
28
+ }
29
+
30
+ /** Closes the client opened from a url; a shared client is left to its owner. */
31
+ close(): void {
32
+ if (this.url === undefined || !this.client) return
33
+ this.client.close()
34
+ this.client = undefined
35
+ }
36
+ }
@@ -0,0 +1,38 @@
1
+ // Counters in Valkey: fixed windows as INCR + PEXPIRE NX in one Lua script, so the first increment of a window
2
+ // starts its expiry and later ones never extend it, atomically. Keys go under a prefix (default "ctr:").
3
+ // Expiry is server-side, so it follows the server's clock, not an injected one.
4
+ import type { Counters } from "./types"
5
+ import { ValkeyConnection, type ValkeySource } from "./valkey-client"
6
+
7
+ const INCR_WINDOW = `local n = redis.call('INCR', KEYS[1])
8
+ redis.call('PEXPIRE', KEYS[1], ARGV[1], 'NX')
9
+ return n`
10
+
11
+ export interface ValkeyCountersOptions {
12
+ /** Prepended to every key (default "ctr:"). */
13
+ readonly prefix?: string
14
+ }
15
+
16
+ export class ValkeyCounters implements Counters {
17
+ private readonly valkey: ValkeyConnection
18
+ private readonly prefix: string
19
+
20
+ constructor(source: ValkeySource, options: ValkeyCountersOptions = {}) {
21
+ this.valkey = new ValkeyConnection(source)
22
+ this.prefix = options.prefix ?? "ctr:"
23
+ }
24
+
25
+ async incr(key: string, ttlMs: number): Promise<number> {
26
+ if (!(Number.isInteger(ttlMs) && ttlMs > 0)) throw new Error("Counters.incr: ttl must be a positive whole number of milliseconds")
27
+ return Number(await this.valkey.send("EVAL", [INCR_WINDOW, "1", this.prefix + key, String(ttlMs)]))
28
+ }
29
+
30
+ async get(key: string): Promise<number> {
31
+ return Number((await this.valkey.send("GET", [this.prefix + key])) ?? 0)
32
+ }
33
+
34
+ /** Closes the connection when it was opened from a url. */
35
+ async close(): Promise<void> {
36
+ this.valkey.close()
37
+ }
38
+ }