@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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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
|
+
}
|