@huynle/hub 0.0.0-stage → 0.6.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 (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,375 @@
1
+ // Speech settings (persisted in state.json): which provider, model, voice, key and base URL to use for
2
+ // speech-to-text and text-to-speech. Precedence, per direction and field: the user's own settings (multi-
3
+ // user hubs) > the saved server default > hub.json (`speech`) > the hub's environment (OPENROUTER_API_KEY,
4
+ // VOICE_STT_MODEL, VOICE_TTS_MODEL, VOICE_VOICE). Keys are only ever shown masked ({set, last4, source});
5
+ // a user sees last4 of their own key only.
6
+ import { ValidationError } from "../auth"
7
+ import { OPENROUTER_BASE_URL } from "./openrouter"
8
+
9
+ export const SPEECH_PROVIDERS = ["openrouter", "openai-compatible"] as const
10
+ export type SpeechProviderName = (typeof SPEECH_PROVIDERS)[number]
11
+
12
+ export interface StoredStt {
13
+ provider?: SpeechProviderName
14
+ model?: string
15
+ apiKey?: string
16
+ baseURL?: string
17
+ language?: string
18
+ }
19
+
20
+ export interface StoredTts {
21
+ provider?: SpeechProviderName
22
+ model?: string
23
+ voice?: string
24
+ apiKey?: string
25
+ baseURL?: string
26
+ speed?: number
27
+ }
28
+
29
+ export interface StoredSpeech {
30
+ stt?: StoredStt
31
+ tts?: StoredTts
32
+ }
33
+
34
+ /** Hub environment defaults (see CentralConfig). */
35
+ export interface SpeechDefaults {
36
+ /** OPENROUTER_API_KEY; only ever used with the openrouter provider. */
37
+ readonly apiKey?: string
38
+ readonly sttModel: string
39
+ readonly ttsModel: string
40
+ readonly voice: string
41
+ /** Speech settings from hub.json, layered under the saved ones. */
42
+ readonly file?: StoredSpeech
43
+ }
44
+
45
+ /** user: the caller's own key; settings: the saved server default; config: hub.json; env: OPENROUTER_API_KEY. */
46
+ export type KeySource = "user" | "settings" | "config" | "env" | "none"
47
+
48
+ export interface KeyView {
49
+ readonly set: boolean
50
+ readonly last4?: string
51
+ readonly source: KeySource
52
+ }
53
+
54
+ /** Effective settings of one direction, including the key to use (never sent to clients). */
55
+ export interface Resolved {
56
+ readonly provider: SpeechProviderName
57
+ readonly model: string
58
+ readonly baseURL: string
59
+ readonly apiKey?: string
60
+ readonly keySource: KeySource
61
+ }
62
+
63
+ export interface ResolvedStt extends Resolved {
64
+ readonly language?: string
65
+ }
66
+
67
+ export interface ResolvedTts extends Resolved {
68
+ readonly voice: string
69
+ readonly speed?: number
70
+ }
71
+
72
+ export interface SttView {
73
+ readonly provider: SpeechProviderName
74
+ readonly model: string
75
+ readonly baseURL?: string
76
+ readonly language?: string
77
+ readonly apiKey: KeyView
78
+ }
79
+
80
+ export interface TtsView {
81
+ readonly provider: SpeechProviderName
82
+ readonly model: string
83
+ readonly voice: string
84
+ readonly baseURL?: string
85
+ readonly speed?: number
86
+ readonly apiKey: KeyView
87
+ }
88
+
89
+ /** GET /api/settings/speech */
90
+ export interface SpeechView {
91
+ readonly stt: SttView
92
+ readonly tts: TtsView
93
+ readonly voiceHints: Readonly<Record<string, readonly string[]>>
94
+ /** Whether each direction is usable (see speechReady). */
95
+ readonly ready: SpeechReady
96
+ }
97
+
98
+ /** A user's own saved fields, key masked (source "user"). */
99
+ export interface OwnSpeechView {
100
+ readonly stt: Omit<StoredStt, "apiKey"> & { readonly apiKey?: KeyView }
101
+ readonly tts: Omit<StoredTts, "apiKey"> & { readonly apiKey?: KeyView }
102
+ }
103
+
104
+ /** GET /api/settings/speech for a user of a multi-user hub: effective settings plus where they come from. */
105
+ export interface UserSpeechView extends SpeechView {
106
+ readonly scope: "user"
107
+ /** The server default (keys: set and source only). */
108
+ readonly default: { readonly stt: SttView; readonly tts: TtsView }
109
+ readonly user: OwnSpeechView
110
+ }
111
+
112
+ /** Voices known to work, keyed by model ID prefix (voices are model-specific). */
113
+ export const VOICE_HINTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
114
+ "microsoft/mai-voice": ["en-US-Harper:MAI-Voice-2.1-Flash"],
115
+ "hexgrad/kokoro-82m": ["af_heart", "af_bella", "af_nicole", "af_sky", "am_adam", "am_michael", "bf_emma", "bm_george"],
116
+ "openai/": ["alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse"],
117
+ })
118
+
119
+ export const MAX_NAME_CHARS = 200
120
+ export const MIN_SPEED = 0.5
121
+ export const MAX_SPEED = 2
122
+ const MAX_KEY_CHARS = 500
123
+ const MAX_URL_CHARS = 500
124
+ /** Keys go into an Authorization header: printable ASCII without spaces. */
125
+ const KEY_PATTERN = /^[\x21-\x7e]+$/
126
+ const LANGUAGE_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8}){0,3}$/
127
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"])
128
+
129
+ /**
130
+ * Hosts on a private network, where plain http:// is acceptable for self-hosted speech servers:
131
+ * loopback, RFC 1918 and Tailscale (100.64/10) addresses, single-label names (Docker services) and
132
+ * .local / .lan / .internal / .home.arpa names.
133
+ */
134
+ export function isPrivateHost(hostname: string): boolean {
135
+ if (LOCAL_HOSTS.has(hostname)) return true
136
+ const ip = hostname.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
137
+ if (ip) {
138
+ const [a, b] = [Number(ip[1]), Number(ip[2])]
139
+ return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127)
140
+ }
141
+ if (!hostname.includes(".")) return /^[a-z0-9-]+$/i.test(hostname)
142
+ return /\.(local|lan|internal|home\.arpa)$/i.test(hostname)
143
+ }
144
+
145
+ /**
146
+ * One direction's saved settings over hub.json's. The file's section is ignored when the saved provider
147
+ * differs from it, and its key is dropped when the saved base URL differs: a key never follows the
148
+ * settings to another endpoint.
149
+ */
150
+ export function layer<T extends StoredStt | StoredTts>(file: T | undefined, saved: T | undefined): T | undefined {
151
+ if (!file) return saved
152
+ if (saved?.provider !== undefined && saved.provider !== (file.provider ?? "openrouter")) return saved
153
+ const merged: T = { ...file, ...saved }
154
+ if (saved?.baseURL !== undefined && saved.baseURL !== file.baseURL && saved.apiKey === undefined) delete merged.apiKey
155
+ return merged
156
+ }
157
+
158
+ function resolve(
159
+ own: { user?: StoredStt | StoredTts; saved?: StoredStt | StoredTts },
160
+ merged: StoredStt | StoredTts | undefined,
161
+ model: string,
162
+ defaults: SpeechDefaults,
163
+ ): Resolved {
164
+ const provider = merged?.provider ?? "openrouter"
165
+ const envKey = provider === "openrouter" ? defaults.apiKey : undefined
166
+ const key = merged?.apiKey
167
+ const apiKey = key ?? envKey
168
+ return {
169
+ provider,
170
+ model: merged?.model ?? model,
171
+ baseURL: merged?.baseURL ?? OPENROUTER_BASE_URL,
172
+ ...(apiKey ? { apiKey } : {}),
173
+ keySource: key ? (own.user?.apiKey === key ? "user" : own.saved?.apiKey === key ? "settings" : "config") : envKey ? "env" : "none",
174
+ }
175
+ }
176
+
177
+ /** One direction's layers merged: user over the saved server default over hub.json. */
178
+ function merged<K extends "stt" | "tts">(kind: K, stored: StoredSpeech, defaults: SpeechDefaults, user?: StoredSpeech): StoredSpeech[K] {
179
+ const lower = layer(defaults.file?.[kind], stored[kind]) as StoredSpeech[K]
180
+ return user ? (layer(lower, user[kind]) as StoredSpeech[K]) : lower
181
+ }
182
+
183
+ /** The server default and hub.json merged: the layer under a user's own settings. */
184
+ export function lowerLayers(stored: StoredSpeech, defaults: SpeechDefaults): StoredSpeech {
185
+ const stt = merged("stt", stored, defaults)
186
+ const tts = merged("tts", stored, defaults)
187
+ return { ...(stt ? { stt } : {}), ...(tts ? { tts } : {}) }
188
+ }
189
+
190
+ export function resolveStt(stored: StoredSpeech, defaults: SpeechDefaults, user?: StoredSpeech): ResolvedStt {
191
+ const stt = merged("stt", stored, defaults, user)
192
+ return { ...resolve({ user: user?.stt, saved: stored.stt }, stt, defaults.sttModel, defaults), ...(stt?.language ? { language: stt.language } : {}) }
193
+ }
194
+
195
+ export function resolveTts(stored: StoredSpeech, defaults: SpeechDefaults, user?: StoredSpeech): ResolvedTts {
196
+ const tts = merged("tts", stored, defaults, user)
197
+ return {
198
+ ...resolve({ user: user?.tts, saved: stored.tts }, tts, defaults.ttsModel, defaults),
199
+ voice: tts?.voice ?? defaults.voice,
200
+ ...(tts?.speed !== undefined ? { speed: tts.speed } : {}),
201
+ }
202
+ }
203
+
204
+ /** `own`: last4 only for the caller's own key (user views), else for any key (the server default's view). */
205
+ function keyView(resolved: Resolved, own?: "user"): KeyView {
206
+ const key = resolved.apiKey
207
+ if (!key) return { set: false, source: "none" }
208
+ const last4 = key.length >= 12 && (own === undefined || resolved.keySource === "user")
209
+ return { set: true, ...(last4 ? { last4: key.slice(-4) } : {}), source: resolved.keySource }
210
+ }
211
+
212
+ function sections(stored: StoredSpeech, defaults: SpeechDefaults, user: StoredSpeech | undefined, own?: "user") {
213
+ const stt = resolveStt(stored, defaults, user)
214
+ const tts = resolveTts(stored, defaults, user)
215
+ const sttView: SttView = {
216
+ provider: stt.provider,
217
+ model: stt.model,
218
+ ...(merged("stt", stored, defaults, user)?.baseURL ? { baseURL: stt.baseURL } : {}),
219
+ ...(stt.language ? { language: stt.language } : {}),
220
+ apiKey: keyView(stt, own),
221
+ }
222
+ const ttsView: TtsView = {
223
+ provider: tts.provider,
224
+ model: tts.model,
225
+ voice: tts.voice,
226
+ ...(merged("tts", stored, defaults, user)?.baseURL ? { baseURL: tts.baseURL } : {}),
227
+ ...(tts.speed !== undefined ? { speed: tts.speed } : {}),
228
+ apiKey: keyView(tts, own),
229
+ }
230
+ return { stt: sttView, tts: ttsView, ready: speechReady(stt, tts) }
231
+ }
232
+
233
+ /** Settings as shown to the phone (the server default's scope): effective values, keys masked. */
234
+ export function speechView(stored: StoredSpeech, defaults: SpeechDefaults): SpeechView {
235
+ const { stt, tts, ready } = sections(stored, defaults, undefined)
236
+ return { stt, tts, voiceHints: VOICE_HINTS, ready }
237
+ }
238
+
239
+ /**
240
+ * A user's view: effective settings (own → server default → hub.json → env), the server default and the
241
+ * user's own fields. Keys that are not the user's own show only whether they are set and where from.
242
+ */
243
+ export function userSpeechView(stored: StoredSpeech, defaults: SpeechDefaults, user: StoredSpeech): UserSpeechView {
244
+ const { stt, tts, ready } = sections(stored, defaults, user, "user")
245
+ const fallback = sections(stored, defaults, undefined, "user")
246
+ const own = <T extends StoredStt | StoredTts>(section: T | undefined) => {
247
+ const { apiKey, ...rest } = section ?? ({} as T)
248
+ const view = { ...rest } as Omit<T, "apiKey"> & { apiKey?: KeyView }
249
+ if (apiKey) view.apiKey = { set: true, ...(apiKey.length >= 12 ? { last4: apiKey.slice(-4) } : {}), source: "user" }
250
+ return view
251
+ }
252
+ return {
253
+ stt,
254
+ tts,
255
+ voiceHints: VOICE_HINTS,
256
+ ready,
257
+ scope: "user",
258
+ default: { stt: fallback.stt, tts: fallback.tts },
259
+ user: { stt: own(user.stt), tts: own(user.tts) },
260
+ }
261
+ }
262
+
263
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
264
+
265
+ /** https:// anywhere, or plain http:// to a private-network host (self-hosted speech servers). Credentials, query and fragment refused. */
266
+ export function validBaseURL(raw: unknown, field: string): string {
267
+ if (typeof raw !== "string" || raw.length > MAX_URL_CHARS) throw new ValidationError(`${field} must be a URL`)
268
+ let url: URL
269
+ try {
270
+ url = new URL(raw.trim())
271
+ } catch {
272
+ throw new ValidationError(`${field} must be a URL`)
273
+ }
274
+ const local = url.protocol === "http:" && isPrivateHost(url.hostname)
275
+ if (url.protocol !== "https:" && !local) {
276
+ throw new ValidationError(`${field} must start with https:// (http:// only for private-network hosts)`)
277
+ }
278
+ if (url.username || url.password || url.search || url.hash) {
279
+ throw new ValidationError(`${field} must not contain credentials, a query or a fragment`)
280
+ }
281
+ return url.toString().replace(/\/+$/, "")
282
+ }
283
+
284
+ type Section = Record<string, unknown>
285
+
286
+ /** Applies one optional text field: null or "" clears, a string sets (validated), absent keeps. */
287
+ function text(next: Section, input: Section, key: string, field: string, check?: (value: string) => boolean): void {
288
+ if (!(key in input)) return
289
+ const value = input[key]
290
+ if (value === null || value === "") return void delete next[key]
291
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`)
292
+ const trimmed = value.trim()
293
+ if (!trimmed || trimmed.length > MAX_NAME_CHARS || /[\x00-\x1f\x7f]/.test(trimmed) || (check && !check(trimmed))) {
294
+ throw new ValidationError(`${field} is not valid`)
295
+ }
296
+ next[key] = trimmed
297
+ }
298
+
299
+ function section<T extends StoredStt | StoredTts>(prev: T | undefined, input: unknown, kind: "stt" | "tts", file?: T): T | undefined {
300
+ if (input === undefined) return prev
301
+ if (!isObj(input)) throw new ValidationError(`${kind} must be an object`)
302
+ const next: Section = { ...prev }
303
+
304
+ if ("provider" in input) {
305
+ if (input.provider === null) delete next.provider
306
+ else if (!SPEECH_PROVIDERS.includes(input.provider as SpeechProviderName)) {
307
+ throw new ValidationError(`${kind}.provider must be one of ${SPEECH_PROVIDERS.join(", ")}`)
308
+ } else next.provider = input.provider
309
+ }
310
+ text(next, input, "model", `${kind}.model`)
311
+ if (kind === "stt") text(next, input, "language", "stt.language", (v) => LANGUAGE_PATTERN.test(v))
312
+ if (kind === "tts") {
313
+ text(next, input, "voice", "tts.voice")
314
+ if ("speed" in input) {
315
+ const speed = input.speed
316
+ if (speed === null) delete next.speed
317
+ else if (typeof speed !== "number" || !Number.isFinite(speed) || speed < MIN_SPEED || speed > MAX_SPEED) {
318
+ throw new ValidationError(`tts.speed must be a number from ${MIN_SPEED} to ${MAX_SPEED}`)
319
+ } else next.speed = speed
320
+ }
321
+ }
322
+ if ("baseURL" in input) {
323
+ if (input.baseURL === null || input.baseURL === "") delete next.baseURL
324
+ else next.baseURL = validBaseURL(input.baseURL, `${kind}.baseURL`)
325
+ }
326
+
327
+ if ("apiKey" in input) {
328
+ const key = input.apiKey
329
+ if (key === null || key === "") delete next.apiKey
330
+ else if (typeof key !== "string" || key.trim().length > MAX_KEY_CHARS || !KEY_PATTERN.test(key.trim())) {
331
+ // Never echo the value: it may be a real key with a typo.
332
+ throw new ValidationError(`${kind}.apiKey must be a single token of printable characters`)
333
+ } else next.apiKey = key.trim()
334
+ } else if ((prev?.provider ?? file?.provider ?? "openrouter") !== (next.provider ?? file?.provider ?? "openrouter") || prev?.baseURL !== next.baseURL) {
335
+ // A stored key never follows the settings to a different endpoint.
336
+ delete next.apiKey
337
+ }
338
+
339
+ const effective = layer(file, next as T)
340
+ if (effective?.provider === "openai-compatible" && !effective.baseURL) {
341
+ throw new ValidationError(`${kind}.baseURL is required for the openai-compatible provider`)
342
+ }
343
+ return next as T
344
+ }
345
+
346
+ /** Partial update from untrusted input (validated together with hub.json's layer); throws ValidationError and returns a new object. */
347
+ export function updateSpeech(prev: StoredSpeech, input: unknown, file: StoredSpeech = {}): StoredSpeech {
348
+ if (!isObj(input)) throw new ValidationError("Expected {stt?, tts?}")
349
+ const stt = section(prev.stt, input.stt, "stt", file.stt)
350
+ const tts = section(prev.tts, input.tts, "tts", file.tts)
351
+ return { ...(stt ? { stt } : {}), ...(tts ? { tts } : {}) }
352
+ }
353
+
354
+ export interface SpeechReady {
355
+ readonly stt: boolean
356
+ readonly tts: boolean
357
+ /** Why speech is not usable yet (absent when both directions are ready). */
358
+ readonly reason?: string
359
+ }
360
+
361
+ /** Whether a direction can work at all: openrouter needs a key, openai-compatible a base URL. */
362
+ function missing(resolved: Resolved): string | undefined {
363
+ if (resolved.provider === "openrouter") return resolved.apiKey ? undefined : "an OpenRouter API key"
364
+ return resolved.baseURL ? undefined : "a base URL"
365
+ }
366
+
367
+ export function speechReady(stt: Resolved, tts: Resolved): SpeechReady {
368
+ const needs = [
369
+ ["Speech-to-text", missing(stt)],
370
+ ["Text-to-speech", missing(tts)],
371
+ ].filter((entry): entry is [string, string] => entry[1] !== undefined)
372
+ const ready = { stt: !missing(stt), tts: !missing(tts) }
373
+ if (needs.length === 0) return ready
374
+ return { ...ready, reason: `${needs.map(([what, need]) => `${what} needs ${need}`).join("; ")}. Set it in Settings → Speech.` }
375
+ }
@@ -0,0 +1,300 @@
1
+ // Spoke WebSocket endpoint (/spoke/ws): bearer-token authentication, hello/welcome handshake, frame
2
+ // validation, heartbeats, server→spoke request/response correlation, and spoke→server requests.
3
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
4
+ import {
5
+ encode,
6
+ MAX_FRAME,
7
+ parseSpokeMessage,
8
+ PROTOCOL_VERSION,
9
+ type Req,
10
+ type ResultFor,
11
+ type ServerMessage,
12
+ type ServerRequest,
13
+ type SpokeRequest,
14
+ } from "../protocol"
15
+ import type { Auth } from "./auth"
16
+ import type { PushDetail, PushExtra, PushKind } from "./push"
17
+ import type { Registry } from "./registry"
18
+ import { newID, type Store } from "./store"
19
+
20
+ /** Push notifications (implemented by Push in push.ts) to `ownerID`'s devices. Returns the number of devices notified. */
21
+ export interface Notifier {
22
+ notify(kind: PushKind, sessionID: string, title: string, detail?: PushDetail, body?: string, ownerID?: string, extra?: PushExtra): Promise<number>
23
+ }
24
+
25
+ /** The spoke answered with an error, or disconnected before answering. */
26
+ export class SpokeError extends Error {
27
+ constructor(message: string) {
28
+ super(message)
29
+ this.name = "SpokeError"
30
+ }
31
+ }
32
+
33
+ /** A request that would not fit in one protocol frame (the spoke would drop it and never answer). */
34
+ export class RequestTooLargeError extends Error {
35
+ constructor(op: string) {
36
+ super(`The ${op} request is too large to send to OpenCode`)
37
+ this.name = "RequestTooLargeError"
38
+ }
39
+ }
40
+
41
+ export class RequestTimeoutError extends Error {
42
+ constructor(op: string) {
43
+ super(`OpenCode did not answer the ${op} request in time`)
44
+ this.name = "RequestTimeoutError"
45
+ }
46
+ }
47
+
48
+ export interface SpokeSocketData {
49
+ readonly connID: string
50
+ readonly machineID: string
51
+ readonly machineName: string
52
+ ready: boolean
53
+ lastSeen: number
54
+ helloTimer?: ReturnType<typeof setTimeout>
55
+ }
56
+
57
+ type Socket = ServerWebSocket<SpokeSocketData>
58
+
59
+ export interface SpokeHubOptions {
60
+ readonly store: Store
61
+ readonly auth: Auth
62
+ readonly registry: Registry
63
+ readonly publicURL: string
64
+ readonly notifier: Notifier
65
+ /** Sends a message to the SSE subscribers of `tenant` (a user ID). */
66
+ readonly broadcast: (message: { type: string; [key: string]: unknown }, tenant: string) => void
67
+ readonly turnTimeoutMs: number
68
+ /** Added to turnTimeoutMs for the hub-side wait (default TURN_GRACE_MS). */
69
+ readonly turnGraceMs?: number
70
+ readonly requestTimeoutMs?: number
71
+ readonly helloTimeoutMs?: number
72
+ readonly heartbeatMs?: number
73
+ readonly staleMs?: number
74
+ }
75
+
76
+ interface Pending {
77
+ readonly connID: string
78
+ readonly op: string
79
+ readonly resolve: (value: unknown) => void
80
+ readonly reject: (error: Error) => void
81
+ readonly timer: ReturnType<typeof setTimeout>
82
+ }
83
+
84
+ /** Extra time the hub waits past the turn timeout so the spoke's own timeout result arrives first. */
85
+ export const TURN_GRACE_MS = 15_000
86
+ /** Creating a session and opening it in a new Herdr tab (the spoke allows `herdr agent start` 20 s). */
87
+ export const CREATE_IN_HERDR_TIMEOUT_MS = 45_000
88
+
89
+ const CLOSE_PROTOCOL = 4000
90
+ const CLOSE_REVOKED = 4001
91
+ const CLOSE_STALE = 4002
92
+
93
+ export class SpokeHub {
94
+ private readonly sockets = new Map<string, Socket>()
95
+ private readonly pending = new Map<string, Pending>()
96
+ private readonly heartbeat: ReturnType<typeof setInterval>
97
+ private seq = 0
98
+ readonly websocket: WebSocketHandler<SpokeSocketData>
99
+
100
+ constructor(private readonly options: SpokeHubOptions) {
101
+ const staleMs = options.staleMs ?? 60_000
102
+ this.heartbeat = setInterval(() => {
103
+ const now = Date.now()
104
+ for (const ws of this.sockets.values()) {
105
+ if (now - ws.data.lastSeen > staleMs) ws.close(CLOSE_STALE, "heartbeat timeout")
106
+ else ws.ping()
107
+ }
108
+ }, options.heartbeatMs ?? 20_000)
109
+ this.heartbeat.unref?.()
110
+
111
+ this.websocket = {
112
+ open: (ws) => this.open(ws),
113
+ message: (ws, raw) => this.message(ws, raw),
114
+ close: (ws) => this.closed(ws),
115
+ pong: (ws) => this.seen(ws),
116
+ idleTimeout: 0,
117
+ }
118
+ }
119
+
120
+ /** Authenticates and upgrades /spoke/ws. Returns a Response on failure, undefined when upgraded. */
121
+ upgrade(request: Request, server: Server<SpokeSocketData>): Response | undefined {
122
+ const match = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")
123
+ const machine = match?.[1] ? this.options.store.findMachine(match[1]) : undefined
124
+ const owner = machine && this.options.store.user(machine.ownerID)
125
+ if (!machine || !owner || owner.disabled) return Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
126
+ const data: SpokeSocketData = {
127
+ connID: newID("conn"),
128
+ machineID: machine.id,
129
+ machineName: machine.name,
130
+ ready: false,
131
+ lastSeen: Date.now(),
132
+ }
133
+ if (server.upgrade(request, { data })) return undefined
134
+ return Response.json({ error: "Expected a WebSocket upgrade" }, { status: 426 })
135
+ }
136
+
137
+ /** Sends a request to a connected spoke and resolves with its result. */
138
+ request<R extends ServerRequest>(connID: string, req: R): Promise<ResultFor[R["op"]]> {
139
+ const ws = this.sockets.get(connID)
140
+ if (!ws?.data.ready) return Promise.reject(new SpokeError("OpenCode process disconnected"))
141
+ const id = `r${++this.seq}`
142
+ const frame = encode({ t: "req", id, req })
143
+ if (frame.length > MAX_FRAME) return Promise.reject(new RequestTooLargeError(req.op))
144
+ const base = this.options.requestTimeoutMs ?? 15_000
145
+ const timeoutMs =
146
+ req.op === "turn"
147
+ ? this.options.turnTimeoutMs + (this.options.turnGraceMs ?? TURN_GRACE_MS)
148
+ : req.op === "create" && req.herdrWorkspace
149
+ ? Math.max(base, CREATE_IN_HERDR_TIMEOUT_MS) // the spoke waits for OpenCode to start in the new tab
150
+ : base
151
+ return new Promise((resolve, reject) => {
152
+ const timer = setTimeout(() => {
153
+ this.pending.delete(id)
154
+ reject(new RequestTimeoutError(req.op))
155
+ }, timeoutMs)
156
+ this.pending.set(id, { connID, op: req.op, resolve: resolve as (v: unknown) => void, reject, timer })
157
+ ws.send(frame)
158
+ })
159
+ }
160
+
161
+ /** Disconnects every socket of a machine (after revocation). */
162
+ closeMachine(machineID: string, reason: string): void {
163
+ for (const ws of this.sockets.values()) if (ws.data.machineID === machineID) this.bye(ws, reason, CLOSE_REVOKED)
164
+ }
165
+
166
+ stop(): void {
167
+ clearInterval(this.heartbeat)
168
+ for (const ws of this.sockets.values()) this.bye(ws, "server shutting down", 1001)
169
+ }
170
+
171
+ private open(ws: Socket): void {
172
+ this.sockets.set(ws.data.connID, ws)
173
+ ws.data.helloTimer = setTimeout(() => {
174
+ if (!ws.data.ready) this.bye(ws, "hello timeout")
175
+ }, this.options.helloTimeoutMs ?? 10_000)
176
+ }
177
+
178
+ private seen(ws: Socket): void {
179
+ ws.data.lastSeen = Date.now()
180
+ if (ws.data.ready) this.options.registry.touch(ws.data.connID)
181
+ }
182
+
183
+ private message(ws: Socket, raw: string | Buffer): void {
184
+ this.seen(ws)
185
+ const message = parseSpokeMessage(raw)
186
+ if (!message) return this.bye(ws, "protocol error")
187
+ const { registry } = this.options
188
+ const { connID } = ws.data
189
+
190
+ if (!ws.data.ready) {
191
+ if (message.t !== "hello") return this.bye(ws, "protocol error")
192
+ if (message.v !== PROTOCOL_VERSION) {
193
+ return this.bye(ws, `unsupported protocol version ${message.v}; server speaks version ${PROTOCOL_VERSION}`)
194
+ }
195
+ clearTimeout(ws.data.helloTimer)
196
+ ws.data.ready = true
197
+ registry.connect(connID, { id: ws.data.machineID, name: ws.data.machineName }, message)
198
+ this.options.store.touchMachine(ws.data.machineID)
199
+ this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL })
200
+ return
201
+ }
202
+
203
+ switch (message.t) {
204
+ case "hello":
205
+ return this.bye(ws, "protocol error")
206
+ case "sessions":
207
+ registry.setSessions(connID, message.sessions)
208
+ return this.resendConversations(connID)
209
+ case "terminals":
210
+ return registry.setTerminals(connID, message)
211
+ case "event":
212
+ return registry.observe(connID, message.event)
213
+ case "req":
214
+ void this.answer(ws, message)
215
+ return
216
+ case "res": {
217
+ const pending = this.pending.get(message.id)
218
+ if (!pending || pending.connID !== connID) return
219
+ this.pending.delete(message.id)
220
+ clearTimeout(pending.timer)
221
+ if (message.ok) pending.resolve(message.result)
222
+ else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
223
+ return
224
+ }
225
+ case "bye":
226
+ ws.close(1000, "bye")
227
+ return
228
+ }
229
+ }
230
+
231
+ private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
232
+ try {
233
+ const result = await this.handle(ws, frame.req)
234
+ this.send(ws, { t: "res", id: frame.id, ok: true, result })
235
+ } catch (cause) {
236
+ this.send(ws, { t: "res", id: frame.id, ok: false, error: cause instanceof Error ? cause.message : String(cause) })
237
+ }
238
+ }
239
+
240
+ /** The machine's current owner (read live: ownership can move, e.g. on migration). */
241
+ ownerOf(machineID: string): string | undefined {
242
+ return this.options.store.machines().find((m) => m.id === machineID)?.ownerID
243
+ }
244
+
245
+ private async handle(ws: Socket, req: SpokeRequest): Promise<ResultFor[SpokeRequest["op"]]> {
246
+ const { auth, publicURL } = this.options
247
+ const owner = this.ownerOf(ws.data.machineID)
248
+ if (!owner) throw new Error("This machine is no longer enrolled")
249
+ switch (req.op) {
250
+ case "pairCode": {
251
+ const { code, expiresAt } = auth.issuePairCode(owner)
252
+ return { code, url: `${publicURL}/pair`, expiresAt }
253
+ }
254
+ case "handoff": {
255
+ // Only the machine owner's own (or not yet reported) sessions: never another user's title or ID.
256
+ const tenant = this.options.registry.sessionTenant(req.sessionID)
257
+ if (tenant !== undefined && tenant !== owner) throw new Error("Unknown session")
258
+ this.options.broadcast({ type: "handoff", sessionID: req.sessionID }, owner)
259
+ const title = (tenant && this.options.registry.status(req.sessionID)?.[0]?.title) || req.sessionID
260
+ return { notified: await this.options.notifier.notify("handoff", req.sessionID, title, undefined, undefined, owner) }
261
+ }
262
+ case "openLink": {
263
+ const { code } = auth.issueLoginCode(owner)
264
+ const next = req.sessionID ? `/s/${encodeURIComponent(req.sessionID)}${req.voice ? "?voice=1" : ""}` : "/"
265
+ return { url: `${publicURL}/login?code=${code}&next=${next}` }
266
+ }
267
+ }
268
+ }
269
+
270
+ /** Tells a (re)connected owner about sessions the phone has in conversation mode. */
271
+ private resendConversations(connID: string): void {
272
+ for (const sessionID of this.options.registry.conversationResends(connID)) {
273
+ this.request(connID, { op: "conversation", sessionID, on: true }).catch((cause) => {
274
+ console.error("[hub] re-sending conversation mode failed:", cause instanceof Error ? cause.message : cause)
275
+ })
276
+ }
277
+ }
278
+
279
+ private closed(ws: Socket): void {
280
+ clearTimeout(ws.data.helloTimer)
281
+ const { connID } = ws.data
282
+ this.sockets.delete(connID)
283
+ for (const [id, pending] of this.pending) {
284
+ if (pending.connID !== connID) continue
285
+ this.pending.delete(id)
286
+ clearTimeout(pending.timer)
287
+ pending.reject(new SpokeError("OpenCode process disconnected"))
288
+ }
289
+ if (ws.data.ready) this.options.registry.disconnect(connID)
290
+ }
291
+
292
+ private bye(ws: Socket, reason: string, code = CLOSE_PROTOCOL): void {
293
+ this.send(ws, { t: "bye", reason })
294
+ ws.close(code, reason)
295
+ }
296
+
297
+ private send(ws: Socket, message: ServerMessage): void {
298
+ ws.send(encode(message))
299
+ }
300
+ }