@huynle/hub 0.6.1 → 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,184 @@
1
+ // The node's settings and credentials. node.json (decision 6) sits next to the spoke's config.json, in the same
2
+ // directory (~/.config/hub, or wherever HUB_SPOKE_CONFIG points): the spoke's normalize() drops fields it does not
3
+ // know, so node settings never live in config.json. The node reads config.json only for the hub URL and the
4
+ // machine token (spoke and node share one machine record) and never writes it.
5
+ //
6
+ // node.json: { "workspaceRoots": ["/Users/huy/projects"], "labels": {"gpu": "no"}, "slots": 6, "memoryMB": 49152,
7
+ // "openshell": { "enabled": true, "gateway": "openshell", "image": "ghcr.io/huynle/hub-sandbox:0.1",
8
+ // "profiles": { "untrusted-code": { "providers": ["anthropic"] } } },
9
+ // "harnesses": { "claude-code": false } }
10
+ // `harnesses` overrides what the host runtime detects (harnesses.ts): true offers a harness, false withholds it.
11
+ import { readFileSync } from "node:fs"
12
+ import { homedir, hostname } from "node:os"
13
+ import path from "node:path"
14
+ import { isAbsolutePath, labelsProblem, NODE_ROOTS_MAX, NODE_SLOTS_MAX } from "../../protocol/src/index.ts"
15
+ import { normalizeOpenShellProfiles, type OpenShellProfiles } from "../../runtime-openshell/src/profiles.ts"
16
+ import { configPath, readConfig } from "../../../src/config.ts"
17
+
18
+ type Env = Readonly<Record<string, string | undefined>>
19
+
20
+ export const NODE_CONFIG_FILE = "node.json"
21
+ /** Concurrent launches when node.json sets no `slots`. */
22
+ export const DEFAULT_SLOTS = 4
23
+ const MEMORY_MB_MAX = 2 ** 31
24
+ const GATEWAY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/
25
+ const IMAGE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/
26
+ /** A harness id as hellos and launch specs spell it. */
27
+ const HARNESS = /^[a-z0-9][a-z0-9._-]{0,63}$/
28
+
29
+ export interface NodeConfig {
30
+ /** Every launch workspace must resolve (realpath) under one of these. Empty: the node prepares nothing. */
31
+ readonly workspaceRoots: readonly string[]
32
+ /** Labels the node adds to its own (os, arch); placement matches on them. */
33
+ readonly labels: Readonly<Record<string, string>>
34
+ readonly slots: number
35
+ readonly memoryMB?: number
36
+ /** The `openshell` runtime's settings; absent when node.json has no openshell section. */
37
+ readonly openshell?: OpenShellNodeConfig
38
+ /** Per-harness overrides of detection (true: offer it, false: withhold it); absent: detection alone. */
39
+ readonly harnesses?: Readonly<Record<string, boolean>>
40
+ }
41
+
42
+ /** node.json `openshell`: whether the node offers sandboxes, through which gateway, with which image and profiles. */
43
+ export interface OpenShellNodeConfig {
44
+ readonly enabled: boolean
45
+ /** An OpenShell gateway name (`--gateway`); default: the CLI's active gateway. */
46
+ readonly gateway?: string
47
+ /** The image for profiles without their own (default: the runtime's). */
48
+ readonly image?: string
49
+ readonly profiles: OpenShellProfiles
50
+ }
51
+
52
+ /** A config and what was wrong with the input (each bad field is dropped, with one problem line). */
53
+ export interface NodeConfigResult {
54
+ readonly config: NodeConfig
55
+ readonly problems: readonly string[]
56
+ }
57
+
58
+ /** node.json in the spoke config's directory: ~/.config/hub/node.json, or next to $HUB_SPOKE_CONFIG. */
59
+ export function nodeConfigPath(env: Env = process.env, home: string = homedir()): string {
60
+ return path.join(path.dirname(configPath(env, home)), NODE_CONFIG_FILE)
61
+ }
62
+
63
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
64
+ const whole = (v: unknown, min: number, max: number): v is number => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max
65
+ const show = (v: unknown) => JSON.stringify(v)?.slice(0, 80) ?? String(v)
66
+
67
+ /** Normalizes untrusted node.json content, field by field. */
68
+ export function normalizeNodeConfig(raw: unknown): NodeConfigResult {
69
+ const problems: string[] = []
70
+ let r: Record<string, unknown> = {}
71
+ if (isObject(raw)) r = raw
72
+ else if (raw !== undefined) problems.push("node.json must hold a JSON object")
73
+
74
+ const workspaceRoots: string[] = []
75
+ if (r.workspaceRoots !== undefined && !Array.isArray(r.workspaceRoots)) problems.push("workspaceRoots must be a list of absolute paths")
76
+ for (const root of Array.isArray(r.workspaceRoots) ? r.workspaceRoots : []) {
77
+ if (!isAbsolutePath(root) || root === "/") {
78
+ problems.push(`workspaceRoots: ${show(root)} is not an absolute path (without . or .. segments, and not /)`)
79
+ continue
80
+ }
81
+ const clean = root.replace(/\/+$/, "")
82
+ if (!workspaceRoots.includes(clean)) workspaceRoots.push(clean)
83
+ }
84
+ if (workspaceRoots.length > NODE_ROOTS_MAX) {
85
+ problems.push(`workspaceRoots: at most ${NODE_ROOTS_MAX} roots; the rest are ignored`)
86
+ workspaceRoots.length = NODE_ROOTS_MAX
87
+ }
88
+
89
+ let labels: Record<string, string> = {}
90
+ if (r.labels !== undefined) {
91
+ const problem = labelsProblem(r.labels)
92
+ if (problem) problems.push(`labels: ${problem}`)
93
+ else labels = { ...(r.labels as Record<string, string>) }
94
+ }
95
+
96
+ let slots = DEFAULT_SLOTS
97
+ if (r.slots !== undefined) {
98
+ if (whole(r.slots, 1, NODE_SLOTS_MAX)) slots = r.slots
99
+ else problems.push(`slots must be a whole number from 1 to ${NODE_SLOTS_MAX}`)
100
+ }
101
+
102
+ let memoryMB: number | undefined
103
+ if (r.memoryMB !== undefined) {
104
+ if (whole(r.memoryMB, 1, MEMORY_MB_MAX)) memoryMB = r.memoryMB
105
+ else problems.push("memoryMB must be a positive whole number")
106
+ }
107
+
108
+ const openshell = r.openshell === undefined ? undefined : normalizeOpenShell(r.openshell, problems)
109
+ const harnesses = r.harnesses === undefined ? undefined : normalizeHarnesses(r.harnesses, problems)
110
+
111
+ return {
112
+ config: { workspaceRoots, labels, slots, ...(memoryMB !== undefined ? { memoryMB } : {}), ...(openshell ? { openshell } : {}), ...(harnesses ? { harnesses } : {}) },
113
+ problems,
114
+ }
115
+ }
116
+
117
+ function normalizeHarnesses(raw: unknown, problems: string[]): Record<string, boolean> | undefined {
118
+ if (!isObject(raw)) {
119
+ problems.push("harnesses must be an object of harness names to true or false")
120
+ return undefined
121
+ }
122
+ const out: Record<string, boolean> = {}
123
+ for (const [name, on] of Object.entries(raw)) {
124
+ if (!HARNESS.test(name)) problems.push(`harnesses: ${show(name)} is not a harness name`)
125
+ else if (typeof on !== "boolean") problems.push(`harnesses.${name} must be true or false`)
126
+ else out[name] = on
127
+ }
128
+ return out
129
+ }
130
+
131
+ function normalizeOpenShell(raw: unknown, problems: string[]): OpenShellNodeConfig | undefined {
132
+ if (!isObject(raw)) {
133
+ problems.push("openshell must be an object")
134
+ return undefined
135
+ }
136
+ let enabled = false
137
+ if (raw.enabled !== undefined) {
138
+ if (typeof raw.enabled === "boolean") enabled = raw.enabled
139
+ else problems.push("openshell.enabled must be true or false")
140
+ }
141
+ let gateway: string | undefined
142
+ if (raw.gateway !== undefined) {
143
+ if (typeof raw.gateway === "string" && GATEWAY.test(raw.gateway)) gateway = raw.gateway
144
+ else problems.push(`openshell.gateway: ${show(raw.gateway)} is not a gateway name`)
145
+ }
146
+ let image: string | undefined
147
+ if (raw.image !== undefined) {
148
+ if (typeof raw.image === "string" && IMAGE.test(raw.image)) image = raw.image
149
+ else problems.push(`openshell.image: ${show(raw.image)} is not an image reference`)
150
+ }
151
+ const profiles = normalizeOpenShellProfiles(raw.profiles)
152
+ problems.push(...profiles.problems)
153
+ return { enabled, ...(gateway ? { gateway } : {}), ...(image ? { image } : {}), profiles }
154
+ }
155
+
156
+ /** Reads node.json; a missing file is the defaults, unreadable or bad JSON the defaults with a problem. */
157
+ export function readNodeConfig(file: string): NodeConfigResult {
158
+ let text: string
159
+ try {
160
+ text = readFileSync(file, "utf8")
161
+ } catch (cause) {
162
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return normalizeNodeConfig(undefined)
163
+ return { config: normalizeNodeConfig(undefined).config, problems: [`${file}: cannot read (${(cause as Error).message})`] }
164
+ }
165
+ try {
166
+ return normalizeNodeConfig(JSON.parse(text))
167
+ } catch {
168
+ return { config: normalizeNodeConfig(undefined).config, problems: [`${file}: not valid JSON`] }
169
+ }
170
+ }
171
+
172
+ /** What the node connects with: the hub URL, the machine token and the machine ID, from the spoke's config.json. */
173
+ export interface SpokeCredentials {
174
+ readonly server: string
175
+ readonly token: string
176
+ readonly machineID?: string
177
+ }
178
+
179
+ /** The enrolled machine's credentials from config.json (read-only), or undefined when it holds no token. */
180
+ export function readSpokeCredentials(env: Env = process.env, home: string = homedir()): SpokeCredentials | undefined {
181
+ const config = readConfig(configPath(env, home), hostname())
182
+ if (!config.token) return undefined
183
+ return { server: config.server, token: config.token, ...(config.machineID ? { machineID: config.machineID } : {}) }
184
+ }
@@ -0,0 +1,283 @@
1
+ // The node's single outbound WebSocket to the hub (design §6.3): /spoke/ws with the machine token (spoke and node
2
+ // share one machine record), a hello with role "node", then the hub's prepare/collect/cleanup/cancel requests
3
+ // answered in order of arrival. Reconnects with jittered exponential backoff; a hub that does not serve nodes (its
4
+ // welcome lacks "nodes", or it rejects the node hello before any welcome, as a hub older than nodes does) is
5
+ // retried only after a long wait, so an old hub is not hammered. A revoked machine stops for good. A bye carrying
6
+ // retryAfterMs (a hub pod draining, design §10) is followed by one short jittered reconnect (0–retryAfterMs) instead.
7
+ //
8
+ // The WebSocket is the runtime's global one with an Authorization header: Bun's accepts `{ headers }`, and so does
9
+ // Node's (undici, Node 22+), so this runs on both (bundled for node, or from source on bun).
10
+ import {
11
+ hubServesNodes,
12
+ MAX_FRAME,
13
+ NODE_ROLE,
14
+ parseNodeServerMessage,
15
+ PROTOCOL_VERSION,
16
+ type NodeCapacityFrame,
17
+ type NodeHello,
18
+ type NodeProgress,
19
+ type NodeRequest,
20
+ type Welcome,
21
+ } from "../../protocol/src/index.ts"
22
+
23
+ export type NodeConnectionState = "off" | "connecting" | "connected" | "unsupported" | "revoked" | "error"
24
+
25
+ export interface Backoff {
26
+ readonly minMs: number
27
+ readonly maxMs: number
28
+ readonly random?: () => number
29
+ }
30
+
31
+ /** Minimal WebSocket surface used here. */
32
+ export interface SocketLike {
33
+ readonly readyState: number
34
+ onopen: ((ev: unknown) => void) | null
35
+ onmessage: ((ev: { data: unknown }) => void) | null
36
+ onclose: ((ev: { code?: number; reason?: string }) => void) | null
37
+ onerror: ((ev: unknown) => void) | null
38
+ send(data: string): void
39
+ close(code?: number, reason?: string): void
40
+ }
41
+
42
+ export interface NodeConnectionOptions {
43
+ readonly server: string
44
+ readonly token: string
45
+ /** Hello fields, computed per attempt so capacity, use and launches are current. */
46
+ readonly hello: () => Omit<NodeHello, "t" | "v" | "role">
47
+ /** Serves a hub request; a thrown error becomes `{ ok: false, error }`. */
48
+ readonly handle: (req: NodeRequest) => Promise<unknown>
49
+ readonly onWelcome?: (welcome: Welcome) => void
50
+ readonly onState?: (state: NodeConnectionState, error?: string) => void
51
+ readonly backoff?: Backoff
52
+ /** Wait before retrying a hub that does not serve nodes (default 30 minutes). */
53
+ readonly unsupportedRetryMs?: number
54
+ readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
55
+ }
56
+
57
+ export const UNSUPPORTED_RETRY_MS = 30 * 60_000
58
+
59
+ export function backoffDelay(attempt: number, backoff: Backoff): number {
60
+ const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
61
+ return Math.round(base * (0.5 + 0.5 * (backoff.random ?? Math.random)()))
62
+ }
63
+
64
+ /** The reconnect delay a bye's retryAfterMs asks for: uniformly jittered over 0–retryAfterMs, so peers spread out. */
65
+ export function retryAfterDelay(retryAfterMs: number, random: () => number = Math.random): number {
66
+ return Math.round(retryAfterMs * random())
67
+ }
68
+
69
+ export function wsURL(server: string): string {
70
+ return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
71
+ }
72
+
73
+ const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
74
+ new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
75
+
76
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
77
+
78
+ function text(data: unknown): string {
79
+ if (typeof data === "string") return data
80
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8")
81
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8")
82
+ return String(data)
83
+ }
84
+
85
+ /** The id of a frame that is a request, valid or not (so an invalid request still gets an answer). */
86
+ function requestID(raw: string): string | undefined {
87
+ if (raw.length > MAX_FRAME) return undefined
88
+ try {
89
+ const v = JSON.parse(raw)
90
+ return v && typeof v === "object" && v.t === "req" && typeof v.id === "string" && v.id.length > 0 && v.id.length <= 300 ? v.id : undefined
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ export class NodeConnection {
97
+ state: NodeConnectionState = "off"
98
+ lastError?: string
99
+ machineID?: string
100
+ publicURL?: string
101
+
102
+ private socket?: SocketLike
103
+ private welcomed = false
104
+ private stopped = true
105
+ private attempt = 0
106
+ private retry?: ReturnType<typeof setTimeout>
107
+ /** The last bye's retryAfterMs (a draining hub): the next reconnect waits about this instead of the backoff. */
108
+ private retryAfterMs?: number
109
+ private readonly backoff: Backoff
110
+
111
+ constructor(private readonly options: NodeConnectionOptions) {
112
+ this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
113
+ }
114
+
115
+ start(): void {
116
+ if (!this.stopped) return
117
+ this.stopped = false
118
+ this.open()
119
+ }
120
+
121
+ stop(): void {
122
+ this.stopped = true
123
+ clearTimeout(this.retry)
124
+ this.drop(1000, "stop")
125
+ this.setState("off")
126
+ }
127
+
128
+ /** Sends a capacity or progress frame once the hub has welcomed us. Returns false when it could not be sent. */
129
+ send(frame: NodeCapacityFrame | NodeProgress): boolean {
130
+ return this.write(frame)
131
+ }
132
+
133
+ private write(frame: unknown): boolean {
134
+ if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
135
+ try {
136
+ this.socket.send(JSON.stringify(frame))
137
+ return true
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+
143
+ private open(): void {
144
+ if (this.stopped) return
145
+ this.setState(this.state === "error" || this.state === "unsupported" ? this.state : "connecting")
146
+ let socket: SocketLike
147
+ try {
148
+ socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), { Authorization: `Bearer ${this.options.token}` })
149
+ } catch (cause) {
150
+ return this.fail(message(cause))
151
+ }
152
+ this.socket = socket
153
+ this.welcomed = false
154
+ let opened = false
155
+ let rejected = false
156
+ socket.onopen = () => {
157
+ opened = true
158
+ try {
159
+ socket.send(JSON.stringify({ t: "hello", v: PROTOCOL_VERSION, role: NODE_ROLE, ...this.options.hello() }))
160
+ } catch (cause) {
161
+ this.lastError = message(cause)
162
+ }
163
+ }
164
+ socket.onmessage = (ev) => {
165
+ const raw = text(ev.data)
166
+ const frame = parseNodeServerMessage(raw)
167
+ if (!frame) {
168
+ const id = this.welcomed ? requestID(raw) : undefined
169
+ if (id) this.write({ t: "res", id, ok: false, error: "invalid request" })
170
+ return
171
+ }
172
+ switch (frame.t) {
173
+ case "welcome":
174
+ return this.welcome(frame)
175
+ case "bye":
176
+ if (frame.reason === "revoked") return this.revoked()
177
+ // A draining hub is no hub refusing nodes: come back soon (to another pod).
178
+ if (frame.retryAfterMs !== undefined) this.retryAfterMs = frame.retryAfterMs
179
+ else if (!this.welcomed) rejected = true
180
+ this.lastError = frame.reason
181
+ return
182
+ case "req":
183
+ if (this.welcomed) void this.answer(frame.id, frame.req)
184
+ return
185
+ case "res":
186
+ return // the node makes no requests of the hub
187
+ }
188
+ }
189
+ socket.onerror = () => {
190
+ this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
191
+ }
192
+ socket.onclose = (ev) => {
193
+ if (this.socket !== socket) return
194
+ this.socket = undefined
195
+ const wasWelcomed = this.welcomed
196
+ this.welcomed = false
197
+ if (this.stopped || this.state === "revoked") return
198
+ // Opened, then closed by the hub before any welcome: the hub refused the node hello.
199
+ if (!wasWelcomed && opened && (rejected || ev.code === 4000) && this.retryAfterMs === undefined) return this.unsupported(`The hub does not accept nodes${this.lastError ? ` (${this.lastError})` : ""}`)
200
+ this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
201
+ }
202
+ }
203
+
204
+ private welcome(frame: Welcome): void {
205
+ if (!hubServesNodes(frame)) {
206
+ this.drop(1000, "hub does not serve nodes")
207
+ return this.unsupported("The hub does not serve nodes (upgrade the hub)")
208
+ }
209
+ this.welcomed = true
210
+ this.attempt = 0
211
+ this.machineID = frame.machineID
212
+ this.publicURL = frame.publicURL
213
+ this.lastError = undefined
214
+ this.setState("connected")
215
+ try {
216
+ this.options.onWelcome?.(frame)
217
+ } catch (cause) {
218
+ console.error("[hub node] welcome handler failed", cause)
219
+ }
220
+ }
221
+
222
+ private async answer(id: string, req: NodeRequest): Promise<void> {
223
+ let reply: unknown
224
+ try {
225
+ reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
226
+ } catch (cause) {
227
+ reply = { t: "res", id, ok: false, error: message(cause) }
228
+ }
229
+ if (JSON.stringify(reply).length > MAX_FRAME) reply = { t: "res", id, ok: false, error: "result too large to send" }
230
+ this.write(reply)
231
+ }
232
+
233
+ private revoked(): void {
234
+ this.stopped = true
235
+ clearTimeout(this.retry)
236
+ this.lastError = "This machine was revoked on the hub"
237
+ this.setState("revoked")
238
+ this.drop(1000, "revoked")
239
+ }
240
+
241
+ private unsupported(error: string): void {
242
+ this.lastError = error
243
+ this.setState("unsupported")
244
+ this.schedule(this.options.unsupportedRetryMs ?? UNSUPPORTED_RETRY_MS)
245
+ }
246
+
247
+ private fail(error: string): void {
248
+ this.lastError = error
249
+ this.setState("error")
250
+ const retryAfterMs = this.retryAfterMs
251
+ this.retryAfterMs = undefined
252
+ this.schedule(retryAfterMs !== undefined ? retryAfterDelay(retryAfterMs, this.backoff.random) : backoffDelay(this.attempt++, this.backoff))
253
+ }
254
+
255
+ private schedule(ms: number): void {
256
+ if (this.stopped) return
257
+ clearTimeout(this.retry)
258
+ this.retry = setTimeout(() => this.open(), ms)
259
+ }
260
+
261
+ private drop(code: number, reason: string): void {
262
+ const socket = this.socket
263
+ this.socket = undefined
264
+ this.welcomed = false
265
+ if (!socket) return
266
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
267
+ try {
268
+ socket.close(code, reason)
269
+ } catch {
270
+ // already closed
271
+ }
272
+ }
273
+
274
+ private setState(state: NodeConnectionState): void {
275
+ if (state === this.state) return
276
+ this.state = state
277
+ try {
278
+ this.options.onState?.(state, this.lastError)
279
+ } catch {
280
+ // observers must not break the connection
281
+ }
282
+ }
283
+ }