gitspace 0.2.0-rc.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 (318) hide show
  1. package/.claude/settings.local.json +21 -0
  2. package/.gitspace/bundle.json +50 -0
  3. package/.gitspace/select/01-status.sh +40 -0
  4. package/.gitspace/setup/01-install-deps.sh +12 -0
  5. package/.gitspace/setup/02-typecheck.sh +16 -0
  6. package/AGENTS.md +439 -0
  7. package/CLAUDE.md +1 -0
  8. package/LICENSE +25 -0
  9. package/README.md +607 -0
  10. package/bin/gssh +62 -0
  11. package/bun.lock +647 -0
  12. package/docs/CONNECTION.md +623 -0
  13. package/docs/GATEWAY-WORKER.md +319 -0
  14. package/docs/GETTING-STARTED.md +448 -0
  15. package/docs/GITSPACE-PLATFORM.md +1819 -0
  16. package/docs/INFRASTRUCTURE.md +1347 -0
  17. package/docs/PROTOCOL.md +619 -0
  18. package/docs/QUICKSTART.md +174 -0
  19. package/docs/RELAY.md +327 -0
  20. package/docs/REMOTE-DESIGN.md +549 -0
  21. package/docs/ROADMAP.md +564 -0
  22. package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
  23. package/docs/STACK-DESIGN.md +588 -0
  24. package/docs/UNIFIED_ARCHITECTURE.md +292 -0
  25. package/experiments/pty-benchmark.ts +148 -0
  26. package/experiments/pty-latency.ts +100 -0
  27. package/experiments/router/client.ts +199 -0
  28. package/experiments/router/protocol.ts +74 -0
  29. package/experiments/router/router.ts +217 -0
  30. package/experiments/router/session.ts +180 -0
  31. package/experiments/router/test.ts +133 -0
  32. package/experiments/socket-bandwidth.ts +77 -0
  33. package/homebrew/gitspace.rb +45 -0
  34. package/landing-page/ATTRIBUTIONS.md +3 -0
  35. package/landing-page/README.md +11 -0
  36. package/landing-page/bun.lock +801 -0
  37. package/landing-page/guidelines/Guidelines.md +61 -0
  38. package/landing-page/index.html +37 -0
  39. package/landing-page/package.json +90 -0
  40. package/landing-page/postcss.config.mjs +15 -0
  41. package/landing-page/public/_redirects +1 -0
  42. package/landing-page/public/favicon.png +0 -0
  43. package/landing-page/src/app/App.tsx +53 -0
  44. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
  45. package/landing-page/src/app/components/ui/accordion.tsx +66 -0
  46. package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
  47. package/landing-page/src/app/components/ui/alert.tsx +66 -0
  48. package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
  49. package/landing-page/src/app/components/ui/avatar.tsx +53 -0
  50. package/landing-page/src/app/components/ui/badge.tsx +46 -0
  51. package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
  52. package/landing-page/src/app/components/ui/button.tsx +57 -0
  53. package/landing-page/src/app/components/ui/calendar.tsx +75 -0
  54. package/landing-page/src/app/components/ui/card.tsx +92 -0
  55. package/landing-page/src/app/components/ui/carousel.tsx +241 -0
  56. package/landing-page/src/app/components/ui/chart.tsx +353 -0
  57. package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
  58. package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
  59. package/landing-page/src/app/components/ui/command.tsx +177 -0
  60. package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
  61. package/landing-page/src/app/components/ui/dialog.tsx +135 -0
  62. package/landing-page/src/app/components/ui/drawer.tsx +132 -0
  63. package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
  64. package/landing-page/src/app/components/ui/form.tsx +168 -0
  65. package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
  66. package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
  67. package/landing-page/src/app/components/ui/input.tsx +21 -0
  68. package/landing-page/src/app/components/ui/label.tsx +24 -0
  69. package/landing-page/src/app/components/ui/menubar.tsx +276 -0
  70. package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
  71. package/landing-page/src/app/components/ui/pagination.tsx +127 -0
  72. package/landing-page/src/app/components/ui/popover.tsx +48 -0
  73. package/landing-page/src/app/components/ui/progress.tsx +31 -0
  74. package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
  75. package/landing-page/src/app/components/ui/resizable.tsx +56 -0
  76. package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
  77. package/landing-page/src/app/components/ui/select.tsx +189 -0
  78. package/landing-page/src/app/components/ui/separator.tsx +28 -0
  79. package/landing-page/src/app/components/ui/sheet.tsx +139 -0
  80. package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
  81. package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
  82. package/landing-page/src/app/components/ui/slider.tsx +63 -0
  83. package/landing-page/src/app/components/ui/sonner.tsx +25 -0
  84. package/landing-page/src/app/components/ui/switch.tsx +31 -0
  85. package/landing-page/src/app/components/ui/table.tsx +116 -0
  86. package/landing-page/src/app/components/ui/tabs.tsx +66 -0
  87. package/landing-page/src/app/components/ui/textarea.tsx +18 -0
  88. package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
  89. package/landing-page/src/app/components/ui/toggle.tsx +47 -0
  90. package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
  91. package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
  92. package/landing-page/src/app/components/ui/utils.ts +6 -0
  93. package/landing-page/src/components/docs/DocsContent.tsx +718 -0
  94. package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
  95. package/landing-page/src/components/landing/CTA.tsx +59 -0
  96. package/landing-page/src/components/landing/Comparison.tsx +84 -0
  97. package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
  98. package/landing-page/src/components/landing/Features.tsx +201 -0
  99. package/landing-page/src/components/landing/Hero.tsx +142 -0
  100. package/landing-page/src/components/landing/Pricing.tsx +140 -0
  101. package/landing-page/src/components/landing/Roadmap.tsx +86 -0
  102. package/landing-page/src/components/landing/Security.tsx +81 -0
  103. package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
  104. package/landing-page/src/components/landing/UseCases.tsx +55 -0
  105. package/landing-page/src/components/landing/Workflow.tsx +101 -0
  106. package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
  107. package/landing-page/src/components/layout/Footer.tsx +55 -0
  108. package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
  109. package/landing-page/src/components/ui/badge.tsx +39 -0
  110. package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
  111. package/landing-page/src/components/ui/button.tsx +57 -0
  112. package/landing-page/src/components/ui/card.tsx +79 -0
  113. package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
  114. package/landing-page/src/components/ui/separator.tsx +28 -0
  115. package/landing-page/src/lib/utils.ts +6 -0
  116. package/landing-page/src/main.tsx +10 -0
  117. package/landing-page/src/pages/Dashboard.tsx +133 -0
  118. package/landing-page/src/pages/DocsPage.tsx +79 -0
  119. package/landing-page/src/pages/LandingPage.tsx +31 -0
  120. package/landing-page/src/pages/TerminalView.tsx +106 -0
  121. package/landing-page/src/styles/fonts.css +0 -0
  122. package/landing-page/src/styles/index.css +3 -0
  123. package/landing-page/src/styles/tailwind.css +4 -0
  124. package/landing-page/src/styles/theme.css +181 -0
  125. package/landing-page/vite.config.ts +19 -0
  126. package/npm/darwin-arm64/bin/gssh +0 -0
  127. package/npm/darwin-arm64/package.json +20 -0
  128. package/package.json +74 -0
  129. package/scripts/build.ts +284 -0
  130. package/scripts/release.ts +140 -0
  131. package/src/__tests__/test-utils.ts +298 -0
  132. package/src/commands/__tests__/serve-messages.test.ts +190 -0
  133. package/src/commands/access.ts +298 -0
  134. package/src/commands/add.ts +452 -0
  135. package/src/commands/auth.ts +364 -0
  136. package/src/commands/connect.ts +287 -0
  137. package/src/commands/directory.ts +16 -0
  138. package/src/commands/host.ts +396 -0
  139. package/src/commands/identity.ts +184 -0
  140. package/src/commands/list.ts +200 -0
  141. package/src/commands/relay.ts +315 -0
  142. package/src/commands/remove.ts +241 -0
  143. package/src/commands/serve.ts +1493 -0
  144. package/src/commands/share.ts +456 -0
  145. package/src/commands/status.ts +125 -0
  146. package/src/commands/switch.ts +353 -0
  147. package/src/commands/tmux.ts +317 -0
  148. package/src/core/__tests__/access.test.ts +240 -0
  149. package/src/core/access.ts +277 -0
  150. package/src/core/bundle.ts +342 -0
  151. package/src/core/config.ts +510 -0
  152. package/src/core/git.ts +317 -0
  153. package/src/core/github.ts +151 -0
  154. package/src/core/identity.ts +631 -0
  155. package/src/core/linear.ts +225 -0
  156. package/src/core/shell.ts +161 -0
  157. package/src/core/trusted-relays.ts +315 -0
  158. package/src/index.ts +821 -0
  159. package/src/lib/remote-session/index.ts +7 -0
  160. package/src/lib/remote-session/protocol.ts +267 -0
  161. package/src/lib/remote-session/session-handler.ts +581 -0
  162. package/src/lib/remote-session/workspace-scanner.ts +167 -0
  163. package/src/lib/tmux-lite/README.md +81 -0
  164. package/src/lib/tmux-lite/cli.ts +796 -0
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
  166. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
  167. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
  171. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
  172. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
  173. package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
  174. package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
  175. package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
  176. package/src/lib/tmux-lite/crypto/frames.ts +141 -0
  177. package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
  178. package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
  179. package/src/lib/tmux-lite/crypto/identity.ts +286 -0
  180. package/src/lib/tmux-lite/crypto/index.ts +51 -0
  181. package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
  182. package/src/lib/tmux-lite/crypto/invites.ts +215 -0
  183. package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
  184. package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
  185. package/src/lib/tmux-lite/crypto/keys.ts +47 -0
  186. package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
  187. package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
  188. package/src/lib/tmux-lite/handshake-handler.ts +451 -0
  189. package/src/lib/tmux-lite/protocol.test.ts +307 -0
  190. package/src/lib/tmux-lite/protocol.ts +266 -0
  191. package/src/lib/tmux-lite/relay-client.ts +506 -0
  192. package/src/lib/tmux-lite/server.ts +1250 -0
  193. package/src/lib/tmux-lite/shell-integration.sh +37 -0
  194. package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
  195. package/src/lib/tmux-lite/terminal-queries.ts +49 -0
  196. package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
  197. package/src/relay/__tests__/helpers/auth.ts +354 -0
  198. package/src/relay/__tests__/helpers/ports.ts +51 -0
  199. package/src/relay/__tests__/protocol-validation.test.ts +265 -0
  200. package/src/relay/authorization.ts +303 -0
  201. package/src/relay/embedded-assets.generated.d.ts +15 -0
  202. package/src/relay/identity.ts +352 -0
  203. package/src/relay/index.ts +57 -0
  204. package/src/relay/pipes.test.ts +427 -0
  205. package/src/relay/pipes.ts +195 -0
  206. package/src/relay/protocol.ts +804 -0
  207. package/src/relay/registries.test.ts +437 -0
  208. package/src/relay/registries.ts +593 -0
  209. package/src/relay/server.test.ts +1323 -0
  210. package/src/relay/server.ts +1092 -0
  211. package/src/relay/signing.ts +238 -0
  212. package/src/relay/types.ts +69 -0
  213. package/src/serve/client-session-manager.ts +622 -0
  214. package/src/serve/daemon.ts +497 -0
  215. package/src/serve/pty-session.ts +236 -0
  216. package/src/serve/types.ts +169 -0
  217. package/src/shared/components/Flow.tsx +453 -0
  218. package/src/shared/components/Flow.tui.tsx +343 -0
  219. package/src/shared/components/Flow.web.tsx +442 -0
  220. package/src/shared/components/Inbox.tsx +446 -0
  221. package/src/shared/components/Inbox.tui.tsx +262 -0
  222. package/src/shared/components/Inbox.web.tsx +329 -0
  223. package/src/shared/components/MachineList.tsx +187 -0
  224. package/src/shared/components/MachineList.tui.tsx +161 -0
  225. package/src/shared/components/MachineList.web.tsx +210 -0
  226. package/src/shared/components/ProjectList.tsx +176 -0
  227. package/src/shared/components/ProjectList.tui.tsx +109 -0
  228. package/src/shared/components/ProjectList.web.tsx +143 -0
  229. package/src/shared/components/SpacesBrowser.tsx +332 -0
  230. package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
  231. package/src/shared/components/SpacesBrowser.web.tsx +221 -0
  232. package/src/shared/components/index.ts +103 -0
  233. package/src/shared/hooks/index.ts +16 -0
  234. package/src/shared/hooks/useNavigation.ts +226 -0
  235. package/src/shared/index.ts +122 -0
  236. package/src/shared/providers/LocalMachineProvider.ts +425 -0
  237. package/src/shared/providers/MachineProvider.ts +165 -0
  238. package/src/shared/providers/RemoteMachineProvider.ts +444 -0
  239. package/src/shared/providers/index.ts +26 -0
  240. package/src/shared/types.ts +145 -0
  241. package/src/tui/adapters.ts +120 -0
  242. package/src/tui/app.tsx +1816 -0
  243. package/src/tui/components/Terminal.tsx +580 -0
  244. package/src/tui/hooks/index.ts +35 -0
  245. package/src/tui/hooks/useAppState.ts +314 -0
  246. package/src/tui/hooks/useDaemonStatus.ts +174 -0
  247. package/src/tui/hooks/useInboxTUI.ts +113 -0
  248. package/src/tui/hooks/useRemoteMachines.ts +209 -0
  249. package/src/tui/index.ts +24 -0
  250. package/src/tui/state.ts +299 -0
  251. package/src/tui/terminal-bracketed-paste.test.ts +45 -0
  252. package/src/tui/terminal-bracketed-paste.ts +47 -0
  253. package/src/types/bundle.ts +112 -0
  254. package/src/types/config.ts +89 -0
  255. package/src/types/errors.ts +206 -0
  256. package/src/types/identity.ts +284 -0
  257. package/src/types/workspace-fuzzy.ts +49 -0
  258. package/src/types/workspace.ts +151 -0
  259. package/src/utils/bun-socket-writer.ts +80 -0
  260. package/src/utils/deps.ts +127 -0
  261. package/src/utils/fuzzy-match.ts +125 -0
  262. package/src/utils/logger.ts +127 -0
  263. package/src/utils/markdown.ts +254 -0
  264. package/src/utils/onboarding.ts +229 -0
  265. package/src/utils/prompts.ts +114 -0
  266. package/src/utils/run-commands.ts +112 -0
  267. package/src/utils/run-scripts.ts +142 -0
  268. package/src/utils/sanitize.ts +98 -0
  269. package/src/utils/secrets.ts +122 -0
  270. package/src/utils/shell-escape.ts +40 -0
  271. package/src/utils/utf8.ts +79 -0
  272. package/src/utils/workspace-state.ts +47 -0
  273. package/src/web/README.md +73 -0
  274. package/src/web/bun.lock +575 -0
  275. package/src/web/eslint.config.js +23 -0
  276. package/src/web/index.html +16 -0
  277. package/src/web/package.json +37 -0
  278. package/src/web/public/vite.svg +1 -0
  279. package/src/web/src/App.tsx +604 -0
  280. package/src/web/src/assets/react.svg +1 -0
  281. package/src/web/src/components/Terminal.tsx +207 -0
  282. package/src/web/src/hooks/useRelayConnection.ts +224 -0
  283. package/src/web/src/hooks/useTerminal.ts +699 -0
  284. package/src/web/src/index.css +55 -0
  285. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
  286. package/src/web/src/lib/crypto/frames.ts +205 -0
  287. package/src/web/src/lib/crypto/handshake.ts +396 -0
  288. package/src/web/src/lib/crypto/identity.ts +128 -0
  289. package/src/web/src/lib/crypto/keyexchange.ts +246 -0
  290. package/src/web/src/lib/crypto/relay-signing.ts +53 -0
  291. package/src/web/src/lib/invite.ts +58 -0
  292. package/src/web/src/lib/storage/identity-store.ts +94 -0
  293. package/src/web/src/main.tsx +10 -0
  294. package/src/web/src/types/identity.ts +45 -0
  295. package/src/web/tsconfig.app.json +28 -0
  296. package/src/web/tsconfig.json +7 -0
  297. package/src/web/tsconfig.node.json +26 -0
  298. package/src/web/vite.config.ts +31 -0
  299. package/todo-security.md +92 -0
  300. package/tsconfig.json +23 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  305. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  306. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  307. package/worker/bun.lock +237 -0
  308. package/worker/package.json +22 -0
  309. package/worker/schema.sql +96 -0
  310. package/worker/src/handlers/auth.ts +451 -0
  311. package/worker/src/handlers/subdomains.ts +376 -0
  312. package/worker/src/handlers/user.ts +98 -0
  313. package/worker/src/index.ts +70 -0
  314. package/worker/src/middleware/auth.ts +152 -0
  315. package/worker/src/services/cloudflare.ts +609 -0
  316. package/worker/src/types.ts +96 -0
  317. package/worker/tsconfig.json +15 -0
  318. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Relay client - WebSocket connection from client to relay
3
+ *
4
+ * Handles:
5
+ * - Connection to relay server
6
+ * - X3DH handshake for mutual authentication and key exchange
7
+ * - Reconnection with exponential backoff
8
+ * - Sending/receiving encrypted frames
9
+ */
10
+
11
+ import { createHash } from "crypto";
12
+ import {
13
+ createFrame,
14
+ openFrame,
15
+ MASTER_STREAM_ID,
16
+ } from "./crypto";
17
+ import {
18
+ createClientHello,
19
+ processServerHello,
20
+ createClientAuth,
21
+ processServerAuth,
22
+ type X3DHClientState,
23
+ } from "./crypto/handshake.js";
24
+ import type {
25
+ Identity,
26
+ SessionKeys,
27
+ AccessType,
28
+ X3DHResponseMessage,
29
+ X3DHAuthMessage,
30
+ X3DHResultMessage,
31
+ } from "../../types/identity.js";
32
+ import { signMessage, type SignatureBlock } from "../../relay/signing.js";
33
+
34
+ /** Relay client configuration (identity/X3DH handshake) */
35
+ export interface RelayClientConfig {
36
+ /** Relay WebSocket URL (e.g., wss://relay.example.com/ws) */
37
+ relayUrl: string;
38
+ /** Machine ID hint for relay routing */
39
+ machineId?: string;
40
+ /** Client's identity for authentication */
41
+ identity: Identity;
42
+ /** Invite token (if connecting via invite) */
43
+ inviteToken?: string;
44
+ }
45
+
46
+ /** Connection state */
47
+ export type ConnectionState =
48
+ | "disconnected"
49
+ | "connecting"
50
+ | "handshaking"
51
+ | "connected"
52
+ | "reconnecting";
53
+
54
+ /** Relay client events */
55
+ export interface RelayClientEvents {
56
+ /** Called when connected to relay */
57
+ onConnect?: () => void;
58
+ /** Called when disconnected from relay */
59
+ onDisconnect?: (code: number, reason: string) => void;
60
+ /** Called when a message is received (already decrypted) */
61
+ onMessage?: (streamId: number, data: Buffer) => void;
62
+ /** Called on connection error */
63
+ onError?: (error: Error) => void;
64
+ /** Called when connection state changes */
65
+ onStateChange?: (state: ConnectionState) => void;
66
+ /** Called when handshake completes */
67
+ onHandshakeComplete?: (peerIdentityId: string, accessType: AccessType, sessionId?: string) => void;
68
+ }
69
+
70
+ /**
71
+ * Relay client for identity-based connections
72
+ *
73
+ * Uses X3DH handshake with Ed25519/X25519 keys.
74
+ */
75
+ export class RelayClient {
76
+ private ws: WebSocket | null = null;
77
+ private config: RelayClientConfig;
78
+ private events: RelayClientEvents;
79
+ private state: ConnectionState = "disconnected";
80
+ private reconnectAttempts = 0;
81
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
82
+ private readKey: Buffer | null = null;
83
+ private writeKey: Buffer | null = null;
84
+
85
+ // X3DH handshake state (identity mode)
86
+ private handshakeState: X3DHClientState | null = null;
87
+ private sessionKeys: SessionKeys | null = null;
88
+ private peerIdentityId: string | null = null;
89
+ private accessType: AccessType | null = null;
90
+ private sessionId: string | undefined = undefined;
91
+
92
+ /** Maximum reconnect attempts */
93
+ private readonly maxReconnectAttempts = 10;
94
+ /** Base reconnect delay in ms */
95
+ private readonly baseReconnectDelay = 1000;
96
+ /** Maximum reconnect delay in ms */
97
+ private readonly maxReconnectDelay = 30000;
98
+
99
+ /**
100
+ * Create a new relay client
101
+ *
102
+ * @param config - Client configuration
103
+ * @param events - Event handlers
104
+ */
105
+ constructor(config: RelayClientConfig, events: RelayClientEvents = {}) {
106
+ this.config = config;
107
+ this.events = events;
108
+ }
109
+
110
+ /** Get current connection state */
111
+ getState(): ConnectionState {
112
+ return this.state;
113
+ }
114
+
115
+ /** Check if connected */
116
+ isConnected(): boolean {
117
+ return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
118
+ }
119
+
120
+ /** Get peer identity ID */
121
+ getPeerIdentityId(): string | null {
122
+ return this.peerIdentityId;
123
+ }
124
+
125
+ /** Get current access type */
126
+ getAccessType(): AccessType | null {
127
+ return this.accessType;
128
+ }
129
+
130
+ /** Get current session ID */
131
+ getSessionId(): string | undefined {
132
+ return this.sessionId;
133
+ }
134
+
135
+ /**
136
+ * Connect to the relay server
137
+ */
138
+ async connect(): Promise<void> {
139
+ if (this.state === "connecting" || this.state === "connected" || this.state === "handshaking") {
140
+ return;
141
+ }
142
+
143
+ this.setState("connecting");
144
+ this.doConnect();
145
+ }
146
+
147
+ /**
148
+ * Disconnect from the relay server
149
+ */
150
+ disconnect(): void {
151
+ this.cancelReconnect();
152
+ if (this.ws) {
153
+ this.ws.close(1000, "Client disconnect");
154
+ this.ws = null;
155
+ }
156
+ this.setState("disconnected");
157
+ }
158
+
159
+ /**
160
+ * Send data to all connected clients (via relay)
161
+ *
162
+ * @param data - Plaintext data to send (will be encrypted)
163
+ * @param streamId - Stream ID (default: master stream)
164
+ */
165
+ send(data: Buffer | Uint8Array, streamId = MASTER_STREAM_ID): boolean {
166
+ if (!this.isConnected() || !this.writeKey) {
167
+ return false;
168
+ }
169
+
170
+ try {
171
+ const frame = createFrame(streamId, data, this.writeKey);
172
+
173
+ // Relay mode: wrap encrypted frame in JSON data message
174
+ this.ws!.send(JSON.stringify({
175
+ type: "data",
176
+ data: Buffer.from(frame).toString("base64"),
177
+ }));
178
+ return true;
179
+ } catch (e) {
180
+ console.error("[relay-client] Send error:", e);
181
+ return false;
182
+ }
183
+ }
184
+
185
+ private setState(state: ConnectionState): void {
186
+ if (this.state !== state) {
187
+ this.state = state;
188
+ this.events.onStateChange?.(state);
189
+ }
190
+ }
191
+
192
+ private doConnect(): void {
193
+ const { relayUrl, machineId } = this.config;
194
+
195
+ // Build WebSocket URL
196
+ const url = new URL(relayUrl);
197
+ if (machineId) {
198
+ url.searchParams.set("m", machineId);
199
+ }
200
+ url.searchParams.set("role", "client");
201
+
202
+ try {
203
+ this.ws = new WebSocket(url.toString());
204
+ this.ws.binaryType = "arraybuffer";
205
+
206
+ this.ws.onopen = () => {
207
+ console.log("[relay-client] Connected to relay");
208
+ this.reconnectAttempts = 0;
209
+
210
+ // Need to send protocol message for routing
211
+ // Then wait for connection_established before starting handshake
212
+ if (this.config.inviteToken) {
213
+ // Client connecting via invite - send connect_with_invite
214
+ this.sendConnectWithInvite();
215
+ } else {
216
+ // Direct connection to machine - send connect_to_machine
217
+ this.sendConnectToMachine();
218
+ }
219
+ // Handshake will start when we receive connection_established
220
+ };
221
+
222
+ this.ws.onclose = (event) => {
223
+ console.log(
224
+ `[relay-client] Disconnected: ${event.code} ${event.reason}`
225
+ );
226
+ this.ws = null;
227
+ this.handshakeState = null;
228
+ this.events.onDisconnect?.(event.code, event.reason);
229
+
230
+ // Auto-reconnect unless explicitly disconnected
231
+ if (this.state !== "disconnected") {
232
+ this.scheduleReconnect();
233
+ }
234
+ };
235
+
236
+ this.ws.onerror = (event) => {
237
+ console.error("[relay-client] WebSocket error:", event);
238
+ this.events.onError?.(new Error("WebSocket error"));
239
+ };
240
+
241
+ this.ws.onmessage = (event) => {
242
+ this.handleMessage(event.data);
243
+ };
244
+ } catch (e) {
245
+ console.error("[relay-client] Connection error:", e);
246
+ this.events.onError?.(e as Error);
247
+ this.scheduleReconnect();
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Start X3DH handshake (identity mode)
253
+ */
254
+ private startHandshake(): void {
255
+ if (!this.ws) {
256
+ return;
257
+ }
258
+
259
+ console.log("[relay-client] Starting X3DH handshake");
260
+
261
+ // Create ClientHello
262
+ const { state, message } = createClientHello(this.config.machineId);
263
+ this.handshakeState = state;
264
+
265
+ // Send as JSON (handshake messages are not encrypted)
266
+ this.ws.send(JSON.stringify({ type: "handshake", phase: "client_hello", data: message }));
267
+ }
268
+
269
+ private signClientMessage<T extends object>(message: T): T & { signature: SignatureBlock } {
270
+ const privateKey = this.config.identity.signing.secretKey.slice(0, 32);
271
+ const publicKey = this.config.identity.signing.publicKey;
272
+ return signMessage(message, privateKey, publicKey);
273
+ }
274
+
275
+ /**
276
+ * Send connect_with_invite protocol message for relay routing
277
+ */
278
+ private sendConnectWithInvite(): void {
279
+ if (!this.ws || !this.config.inviteToken) {
280
+ return;
281
+ }
282
+
283
+ // Generate inviteId from token hash (same as share.ts)
284
+ const inviteId = createHash("sha256")
285
+ .update(this.config.inviteToken)
286
+ .digest("hex")
287
+ .substring(0, 16);
288
+
289
+ const clientIdentityId = this.config.identity.id;
290
+
291
+ console.log("[relay-client] Sending connect_with_invite");
292
+ const signed = this.signClientMessage({
293
+ type: "connect_with_invite",
294
+ inviteId,
295
+ clientIdentityId,
296
+ });
297
+ this.ws.send(JSON.stringify(signed));
298
+ }
299
+
300
+ /**
301
+ * Send connect_to_machine protocol message for direct connection
302
+ */
303
+ private sendConnectToMachine(): void {
304
+ if (!this.ws) {
305
+ return;
306
+ }
307
+
308
+ const clientIdentityId = this.config.identity.id;
309
+ const machineId = this.config.machineId;
310
+
311
+ console.log("[relay-client] Sending connect_to_machine");
312
+ const signed = this.signClientMessage({
313
+ type: "connect_to_machine",
314
+ machineId,
315
+ clientIdentityId,
316
+ });
317
+ this.ws.send(JSON.stringify(signed));
318
+ }
319
+
320
+ private handleMessage(data: ArrayBuffer | string): void {
321
+ try {
322
+ const jsonData = data instanceof ArrayBuffer
323
+ ? new TextDecoder().decode(data)
324
+ : data;
325
+
326
+ const msg = JSON.parse(jsonData);
327
+
328
+ // Handle relay protocol messages
329
+ if (msg.type === "connection_established") {
330
+ console.log("[relay-client] Connection established to machine:", msg.machineId);
331
+ // Now start X3DH handshake - relay has set up the connection
332
+ this.setState("handshaking");
333
+ this.startHandshake();
334
+ return;
335
+ }
336
+
337
+ if (msg.type === "error") {
338
+ console.error("[relay-client] Relay error:", msg.message);
339
+ this.events.onError?.(new Error(msg.message || "Relay error"));
340
+ this.disconnect();
341
+ return;
342
+ }
343
+
344
+ if (msg.type === "handshake" && this.state === "handshaking") {
345
+ this.handleHandshakeMessage(msg);
346
+ return;
347
+ }
348
+
349
+ if (msg.type === "data" && msg.data && this.state === "handshaking") {
350
+ // Handle handshake wrapped in data message from relay
351
+ try {
352
+ const decodedData = Buffer.from(msg.data, "base64").toString("utf-8");
353
+ const innerMsg = JSON.parse(decodedData);
354
+ if (innerMsg.type === "handshake") {
355
+ this.handleHandshakeMessage(innerMsg);
356
+ }
357
+ } catch {
358
+ // Ignore non-handshake data during handshaking
359
+ }
360
+ return;
361
+ }
362
+
363
+ if (msg.type === "data" && msg.data && this.state === "connected" && this.readKey) {
364
+ const frameBuffer = Buffer.from(msg.data, "base64");
365
+ const result = openFrame(frameBuffer, this.readKey);
366
+ if (result) {
367
+ this.events.onMessage?.(result.streamId, result.data);
368
+ } else {
369
+ console.warn("[relay-client] Failed to decrypt frame");
370
+ }
371
+ }
372
+ } catch (e) {
373
+ console.error("[relay-client] Message handling error:", e);
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Handle X3DH handshake messages
379
+ */
380
+ private handleHandshakeMessage(msg: { type: string; phase: string; data: unknown }): void {
381
+ if (!this.handshakeState || !this.ws) {
382
+ return;
383
+ }
384
+
385
+ try {
386
+ switch (msg.phase) {
387
+ case "server_hello": {
388
+ console.log("[relay-client] Received ServerHello");
389
+
390
+ // Process ServerHello
391
+ const serverHello = msg.data as X3DHResponseMessage;
392
+ const newState = processServerHello(this.handshakeState, serverHello);
393
+
394
+ if (!newState) {
395
+ console.error("[relay-client] Invalid ServerHello");
396
+ this.events.onError?.(new Error("Handshake failed: invalid ServerHello"));
397
+ this.disconnect();
398
+ return;
399
+ }
400
+
401
+ // Create and send ClientAuth
402
+ const authorization: X3DHAuthMessage["authorization"] = this.config.inviteToken
403
+ ? { type: "invite", inviteToken: this.config.inviteToken }
404
+ : { type: "access_list" };
405
+
406
+ const { state, message, sessionKeys } = createClientAuth(
407
+ newState,
408
+ this.config.identity,
409
+ authorization
410
+ );
411
+
412
+ this.handshakeState = state;
413
+ this.sessionKeys = sessionKeys;
414
+
415
+ console.log("[relay-client] Sending ClientAuth");
416
+ this.ws.send(JSON.stringify({ type: "handshake", phase: "client_auth", data: message }));
417
+ break;
418
+ }
419
+
420
+ case "server_auth": {
421
+ console.log("[relay-client] Received ServerAuth");
422
+
423
+ if (!this.sessionKeys) {
424
+ console.error("[relay-client] Missing session keys");
425
+ this.disconnect();
426
+ return;
427
+ }
428
+
429
+ // Process ServerAuth
430
+ const serverAuth = msg.data as X3DHResultMessage;
431
+ const result = processServerAuth(this.handshakeState, serverAuth, this.sessionKeys);
432
+
433
+ if (!result) {
434
+ console.error("[relay-client] Handshake failed: invalid ServerAuth");
435
+ this.events.onError?.(new Error("Handshake failed: authentication rejected"));
436
+ this.disconnect();
437
+ return;
438
+ }
439
+
440
+ // Check if accepted
441
+ if (result.authResult.type === "rejected") {
442
+ console.error("[relay-client] Access denied:", result.authResult.reason);
443
+ this.events.onError?.(new Error(`Access denied: ${result.authResult.reason}`));
444
+ this.disconnect();
445
+ return;
446
+ }
447
+
448
+ // Handshake complete - set up encryption keys
449
+ this.peerIdentityId = result.peerIdentityId;
450
+ this.accessType = result.authResult.accessType;
451
+ this.sessionId = result.authResult.sessionId;
452
+
453
+ // Convert session keys to Buffers for frame encryption
454
+ this.writeKey = Buffer.from(result.sessionKeys.sendKey);
455
+ this.readKey = Buffer.from(result.sessionKeys.receiveKey);
456
+
457
+ console.log("[relay-client] Handshake complete, session established");
458
+ this.setState("connected");
459
+ this.events.onConnect?.();
460
+ this.events.onHandshakeComplete?.(this.peerIdentityId, this.accessType, this.sessionId);
461
+ break;
462
+ }
463
+
464
+ default:
465
+ console.warn("[relay-client] Unknown handshake phase:", msg.phase);
466
+ }
467
+ } catch (e) {
468
+ console.error("[relay-client] Handshake error:", e);
469
+ this.events.onError?.(new Error(`Handshake error: ${e instanceof Error ? e.message : String(e)}`));
470
+ this.disconnect();
471
+ }
472
+ }
473
+
474
+ private scheduleReconnect(): void {
475
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
476
+ console.error("[relay-client] Max reconnect attempts reached");
477
+ this.setState("disconnected");
478
+ return;
479
+ }
480
+
481
+ this.setState("reconnecting");
482
+ this.reconnectAttempts++;
483
+
484
+ // Exponential backoff with jitter
485
+ const delay = Math.min(
486
+ this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1) +
487
+ Math.random() * 1000,
488
+ this.maxReconnectDelay
489
+ );
490
+
491
+ console.log(
492
+ `[relay-client] Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`
493
+ );
494
+
495
+ this.reconnectTimer = setTimeout(() => {
496
+ this.doConnect();
497
+ }, delay);
498
+ }
499
+
500
+ private cancelReconnect(): void {
501
+ if (this.reconnectTimer) {
502
+ clearTimeout(this.reconnectTimer);
503
+ this.reconnectTimer = null;
504
+ }
505
+ }
506
+ }