heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,569 @@
1
+ // Client connection to the relay: keeps a Y.Doc and presence in sync over a
2
+ // WebSocket, reconnecting with backoff. Edits made while offline are merged on
3
+ // reconnect by the CRDT.
4
+ import { EventEmitter } from 'node:events'
5
+ import WebSocket from 'ws'
6
+ import crypto from 'node:crypto'
7
+ import * as Y from 'yjs'
8
+ import {
9
+ MSG_SYNC, MSG_AWARENESS, MSG_QUERY_AWARENESS, MSG_AUTH, MSG_CLAIM, MSG_CLAIMS,
10
+ MSG_ACCESS, MSG_ADMIN, MSG_MEMBERS, MSG_PASS, MSG_BRANCH, MSG_BRANCHES,
11
+ CLOSE_AUTH_FAILED, CLOSE_NAME_TAKEN, CLOSE_ROOM_FULL, CLOSE_DENIED, CLOSE_ENDED, CLOSE_PASS_EXPIRED,
12
+ encoding, decoding, syncProtocol, awarenessProtocol,
13
+ ROOM_DOC, FEATURES, syncHeader,
14
+ syncStep1Message, updateMessage, awarenessMessage, bytesMessage, jsonMessage
15
+ } from './protocol.js'
16
+ import { signChallenge, RESERVED_ROOM } from './identity.js'
17
+ import { PASS_REFRESH_MS } from './pass-source.js'
18
+
19
+ const ROOM_FULL_MESSAGE = 'This session is over the relay\'s size limit, so new changes can\'t be saved there. Start a new session, or host your own relay with a higher limit.'
20
+
21
+ const REQUEST_TIMEOUT_MS = 10000
22
+ // A request the relay answers after pushing to GitHub: pushes to one repository go at least ten seconds apart
23
+ // (relay-git.js PUSH_GAP_MS), so the answer can take longer than an ordinary one.
24
+ export const PUSH_REQUEST_TIMEOUT_MS = 60 * 1000
25
+ const IDENTITY_CHANGED = "This computer's Quilt identity changed. Sign out and sign in again."
26
+ // Never quote the relay URL in errors: it names the room, and older relays may log it.
27
+ const BAD_ADDRESS = "Couldn't connect to the relay: the address isn't valid."
28
+ // A pass that lapses this soon after connecting wasn't really valid: after a few in a
29
+ // row, the clock is the likely culprit, and reconnecting won't help.
30
+ const QUICK_EXPIRY_MS = 60 * 1000
31
+ const MAX_QUICK_EXPIRIES = 5
32
+ // The relay pings every 30s. A connection that hears nothing for this long is dead
33
+ // (a laptop that slept, a network that silently dropped): the socket would otherwise
34
+ // look open for minutes while partners vanish and nothing syncs.
35
+ export const LIVENESS_MS = 75 * 1000
36
+ const HANDSHAKE_MS = 20 * 1000
37
+ const CLOCK_WRONG = "Your computer's clock looks wrong, so Quilt can't stay signed in. Check the date and time."
38
+
39
+ export const REMOTE = Symbol('remote')
40
+
41
+ /** Whether a saved copy of document `epoch` is of another document than the relay's (the branch was started again since). */
42
+ const staleEpoch = (epoch, reply) => !!epoch && !!reply.epoch && epoch !== reply.epoch
43
+
44
+ export class Connection extends EventEmitter {
45
+ /**
46
+ * @param {object} opts
47
+ * @param {string} opts.server ws:// or wss:// base URL of the relay
48
+ * @param {string} opts.room
49
+ * @param {string} opts.secret
50
+ * @param {string} opts.name who we are in the room
51
+ * @param {{ publicKey: string, privateKey: string }} opts.identity proves the name is ours
52
+ * @param {string} [opts.viewSecret] when creating a room: the secret for view-only invites
53
+ * @param {'human'|'agent'} [opts.kind]
54
+ * @param {import('yjs').Doc} opts.doc
55
+ * @param {() => void} [opts.beforeRemote] called before remote changes are applied
56
+ * @param {import('./pass-source.js').PassSource} [opts.passes] signs in to a relay that requires passes
57
+ * @param {number} [opts.passRefreshMs] how often to send the relay a fresh pass while connected
58
+ * @param {number} [opts.livenessMs] give up on a connection that stays silent this long
59
+ * @param {string} [opts.tool] the app or AI tool this is, for the session's audit trail
60
+ */
61
+ constructor ({ server, room, secret, key, viewSecret, kind = 'human', name, identity, doc, branch = null, onStaleBranch = null, beforeRemote, features = FEATURES, passes = null, passRefreshMs = PASS_REFRESH_MS, livenessMs = LIVENESS_MS, tool = '' }) {
62
+ super()
63
+ if (room === RESERVED_ROOM) throw new Error(`"${RESERVED_ROOM}" is not a session name`)
64
+ // Secrets travel in headers, never in the URL: proxies log URLs, and Fly's did (issue 011).
65
+ // Only who we are stays in the query string. `key` (the relay key) is only needed to
66
+ // create a room on a relay that requires one.
67
+ const q = new URLSearchParams({ name, key: identity.publicKey, kind })
68
+ if (features) q.set('features', features)
69
+ this.headers = { 'x-quilt-secret': secret || '' }
70
+ if (key) this.headers['x-quilt-key'] = key
71
+ if (viewSecret) this.headers['x-quilt-view-secret'] = viewSecret
72
+ // Header values must be plain ASCII.
73
+ const label = String(tool || '').replace(/[^\x20-\x7e]/g, '').trim().slice(0, 40)
74
+ if (label && label !== 'unknown') this.headers['x-quilt-tool'] = label
75
+ this.access = null // what the relay says we may do: { state, role, scopes, owner, controlled }
76
+ this.url = `${server.replace(/\/+$/, '')}/${encodeURIComponent(room)}?${q}`
77
+ this.passes = passes
78
+ this.passRefreshMs = passRefreshMs
79
+ this.passTimer = null
80
+ this.livenessMs = livenessMs
81
+ this.livenessTimer = null
82
+ this.passStale = false // after a 4419 close (or a refused pass): get a new pass, not the cached one
83
+ this.passRetried = false // a refused pass gets one retry with a fresh pass before it's fatal
84
+ this.connectedAt = 0
85
+ this.quickExpiries = 0 // 4419 closes in a row that came soon after connecting
86
+ this.room = room
87
+ this.identity = identity
88
+ this.requests = new Map() // id -> { resolve, reject, timer }
89
+ this.doc = doc
90
+ this.beforeRemote = beforeRemote || (() => {})
91
+ this.awareness = new awarenessProtocol.Awareness(doc)
92
+ this.ws = null
93
+ this.connected = false
94
+ this.synced = false
95
+ this.closed = false
96
+ this.backoff = 500
97
+ // Besides the room's document, the one branch this connection syncs (see setBranch). The
98
+ // relay forgets it on every disconnect, so each (re)connection joins it again (startSync).
99
+ this.branchKey = null
100
+ this.branchDoc = null
101
+ this.joinExtra = {} // sent with the first automatic join: adopt (the branch an older app's saved document is), base (HEAD, for a new branch)
102
+ this.joining = null // { id, key, doc } while joinBranch waits for the relay
103
+ this.autoJoin = null // the id of the automatic join, whose refusal is said (branch-refused)
104
+ this.roomSynced = false
105
+ this.branchSynced = false
106
+ // Which document of the branch this app's copy belongs to (the relay starts a new one when a
107
+ // branch is started again after being removed). While the relay hasn't said, a saved copy is
108
+ // held back (branchHeld); a copy of an older one is never merged in: onStaleBranch(reply)
109
+ // gives the document to sync instead.
110
+ this.branchEpoch = (branch && branch.epoch) || null
111
+ this.branchHeld = false
112
+ this.onStaleBranch = onStaleBranch
113
+ this._onBranchUpdate = (update, origin) => {
114
+ if (origin !== REMOTE && this.branchKey !== null && !this.branchHeld) this.send(updateMessage(update, this.branchKey))
115
+ }
116
+ if (branch) {
117
+ this.setBranch(branch.key, branch.doc)
118
+ this.joinExtra = { ...(branch.adopt ? { adopt: branch.adopt } : {}), ...(branch.base ? { base: branch.base } : {}) }
119
+ }
120
+
121
+ this._onUpdate = (update, origin) => {
122
+ if (origin !== REMOTE) this.send(updateMessage(update))
123
+ }
124
+ this._onAwareness = ({ added, updated, removed }, origin) => {
125
+ if (origin === 'local') this.send(awarenessMessage(this.awareness, added.concat(updated, removed)))
126
+ }
127
+ doc.on('update', this._onUpdate)
128
+ this.awareness.on('update', this._onAwareness)
129
+ this.connect()
130
+ }
131
+
132
+ connect () {
133
+ if (this.closed) return
134
+ if (!this.passes) return this.open(this.headers)
135
+ const getting = this.passStale ? this.passes.fresh() : this.passes.get()
136
+ getting.then((pass) => {
137
+ if (this.closed) return
138
+ // Only now: if fetching failed, the retry must still ask for a fresh pass.
139
+ this.passStale = false
140
+ // The relay would refuse a pass for another key with "update and sign in", which isn't the
141
+ // fix: ~/.quilt/identity.json changed since this computer signed in.
142
+ const payload = this.passes.payload
143
+ if (payload && payload.key && payload.key !== this.identity.publicKey) {
144
+ this.emit('fatal', new Error(IDENTITY_CHANGED))
145
+ return this.close()
146
+ }
147
+ this.emit('pass', this.passes.payload)
148
+ this.emit('problem', null)
149
+ this.open({ ...this.headers, 'x-quilt-pass': pass })
150
+ }, (err) => {
151
+ if (this.closed) return
152
+ if (err.signedOut) {
153
+ this.emit('fatal', err)
154
+ return this.close()
155
+ }
156
+ this.emit('warn', `couldn't get a session pass: ${err.message}; retrying`)
157
+ // Without a pass nothing connects, so say why where people can see it (not only in debug).
158
+ this.emit('problem', `Couldn't get a session pass: ${err.message}`)
159
+ setTimeout(() => this.connect(), this.backoff)
160
+ this.backoff = Math.min(this.backoff * 2, 10000)
161
+ })
162
+ }
163
+
164
+ /** Opens the relay connection, sending the secrets (and the pass) as `headers`. */
165
+ open (headers) {
166
+ let ws
167
+ try {
168
+ // A handshake that never answers (a network gone away) must end too, so the reconnect runs.
169
+ ws = new WebSocket(this.url, { headers, handshakeTimeout: HANDSHAKE_MS })
170
+ } catch {
171
+ // Later, so whoever made this connection is listening (and never with ws's error, which quotes the URL).
172
+ setImmediate(() => {
173
+ if (this.closed) return
174
+ this.emit('fatal', new Error(BAD_ADDRESS))
175
+ this.close()
176
+ })
177
+ return
178
+ }
179
+ ws.binaryType = 'arraybuffer'
180
+ this.ws = ws
181
+
182
+ // The relay first asks us to sign a challenge; sync starts once we have.
183
+ ws.on('open', () => { this.authed = false })
184
+
185
+ ws.on('message', (data) => {
186
+ try {
187
+ if (this.authed) this.handle(new Uint8Array(data))
188
+ else this.authenticate(new Uint8Array(data))
189
+ } catch (err) { this.emit('warn', `error handling message from relay: ${err.stack}`) }
190
+ })
191
+
192
+ // Anything from the relay (its pings included) proves the connection is alive.
193
+ const heard = () => {
194
+ clearTimeout(this.livenessTimer)
195
+ this.livenessTimer = setTimeout(() => {
196
+ if (this.ws !== ws || this.closed) return
197
+ this.emit('warn', 'the relay went quiet; reconnecting')
198
+ ws.retrying = true
199
+ ws.terminate() // 'close' fires and the reconnect with backoff runs
200
+ }, this.livenessMs)
201
+ this.livenessTimer.unref?.()
202
+ }
203
+ ws.on('open', heard)
204
+ ws.on('ping', heard)
205
+ ws.on('message', heard)
206
+
207
+ ws.on('unexpected-response', (req, res) => {
208
+ const reason = res.statusMessage || `HTTP ${res.statusCode}`
209
+ if (res.statusCode === 401 && this.passes && !this.passRetried) {
210
+ // The cached pass may have run out by the relay's clock: try once more with a fresh one.
211
+ this.passRetried = true
212
+ this.passStale = true
213
+ this.passes.forget()
214
+ this.emit('warn', 'the relay turned the session pass away; trying a fresh one')
215
+ ws.retrying = true // we closed it on purpose: no "closed before the connection was established" warning
216
+ ws.terminate()
217
+ } else if (res.statusCode === 403 && /relay key/i.test(reason)) {
218
+ this.emit('fatal', new Error('This relay needs a relay key to start new sessions. Join a session someone started there with their invite link instead.'))
219
+ this.close()
220
+ } else if (res.statusCode === 401 || res.statusCode === 400 || res.statusCode === 403) {
221
+ this.emit('fatal', new Error(`Relay refused connection: ${reason}`))
222
+ this.close()
223
+ } else if (res.statusCode === 413) {
224
+ this.emit('fatal', new Error(ROOM_FULL_MESSAGE))
225
+ this.close()
226
+ } else if (res.statusCode === 410) {
227
+ this.emit('fatal', Object.assign(new Error(reason), { ended: true }))
228
+ this.close()
229
+ } else {
230
+ // Not final (429, or a proxy's 502/503 while the relay restarts): end the handshake
231
+ // ourselves so `close` fires and the reconnect with backoff runs. With a listener on
232
+ // this event, ws leaves the request open otherwise, and nothing would ever retry.
233
+ this.emit('warn', res.statusCode === 429 ? 'relay says there are too many connections from this network; retrying' : `relay responded ${reason}; retrying`)
234
+ ws.retrying = true
235
+ ws.terminate()
236
+ }
237
+ })
238
+
239
+ ws.on('error', (err) => { if (!ws.retrying) this.emit('warn', `connection error: ${err.message}`) })
240
+
241
+ ws.on('close', (code, reason) => {
242
+ clearInterval(this.passTimer)
243
+ if (this.ws === ws) clearTimeout(this.livenessTimer)
244
+ const upSince = this.connectedAt
245
+ this.connectedAt = 0
246
+ // Count 4419s that came soon after connecting (or before signing in finished); any other
247
+ // end to a connection that got going resets the count.
248
+ if (code === CLOSE_PASS_EXPIRED) this.quickExpiries = !upSince || Date.now() - upSince < QUICK_EXPIRY_MS ? this.quickExpiries + 1 : 0
249
+ else if (upSince) this.quickExpiries = 0
250
+ if (code === CLOSE_PASS_EXPIRED && this.quickExpiries >= MAX_QUICK_EXPIRIES) {
251
+ this.emit('fatal', new Error(CLOCK_WRONG))
252
+ this.close()
253
+ } else if (code === CLOSE_PASS_EXPIRED) {
254
+ // Not fatal: reconnect (below) with a fresh pass.
255
+ this.passStale = true
256
+ this.emit('warn', String(reason) || 'session pass expired; reconnecting')
257
+ } else if (code === CLOSE_ENDED) {
258
+ this.emit('fatal', Object.assign(new Error(String(reason) || 'The owner ended this session'), { ended: true }))
259
+ this.close()
260
+ } else if (code === CLOSE_DENIED) {
261
+ this.emit('fatal', Object.assign(new Error(String(reason) || 'The session owner did not let you in'), { denied: true }))
262
+ this.close()
263
+ } else if (code === CLOSE_AUTH_FAILED || code === CLOSE_NAME_TAKEN) {
264
+ this.emit('fatal', new Error(`Relay refused connection: ${String(reason) || 'identity check failed'}`))
265
+ this.close()
266
+ } else if (code === CLOSE_ROOM_FULL) {
267
+ this.emit('fatal', new Error(ROOM_FULL_MESSAGE))
268
+ this.closed = true
269
+ }
270
+ const wasConnected = this.connected
271
+ this.connected = false
272
+ this.synced = false
273
+ this.roomSynced = false
274
+ this.branchSynced = false
275
+ this.branchHeld = false
276
+ this.joining = null
277
+ this.authed = false
278
+ if (this.ws === ws) this.ws = null
279
+ for (const [id, r] of this.requests) { clearTimeout(r.timer); r.reject(new Error('disconnected from relay')); this.requests.delete(id) }
280
+ // Peers' presence is stale once we're disconnected.
281
+ const others = [...this.awareness.getStates().keys()].filter((id) => id !== this.doc.clientID)
282
+ awarenessProtocol.removeAwarenessStates(this.awareness, others, 'connection')
283
+ // Forget how far their clocks had got, or the states the relay sends back on reconnect
284
+ // (the same clocks, nothing changed) are ignored and partners stay invisible until
285
+ // their next renewal, up to 15s later. A clock of -1 keeps them as known clients, so
286
+ // their return is an update, not a fresh "joined".
287
+ for (const id of others) {
288
+ const m = this.awareness.meta.get(id)
289
+ if (m) this.awareness.meta.set(id, { ...m, clock: -1 })
290
+ }
291
+ if (wasConnected && !this.closed) this.emit('status', 'disconnected')
292
+ if (!this.closed) {
293
+ setTimeout(() => this.connect(), this.backoff)
294
+ this.backoff = Math.min(this.backoff * 2, 10000)
295
+ }
296
+ })
297
+ }
298
+
299
+ authenticate (buf) {
300
+ const dec = decoding.createDecoder(buf)
301
+ if (decoding.readVarUint(dec) !== MSG_AUTH) {
302
+ this.emit('fatal', new Error('The relay runs an older quilt that cannot check identities; update it'))
303
+ return this.close()
304
+ }
305
+ const nonce = decoding.readVarUint8Array(dec)
306
+ this.ws.send(bytesMessage(MSG_AUTH, signChallenge(this.identity, this.room, nonce)))
307
+ // The relay handles messages in order, so we can start syncing right away.
308
+ // (If we have to wait for the owner, it ignores this and we start again once let in.)
309
+ this.authed = true
310
+ this.connected = true
311
+ this.connectedAt = Date.now()
312
+ this.passRetried = false
313
+ // After a pass that lapsed right away, keep backing off rather than reconnecting at full speed.
314
+ if (!this.quickExpiries) this.backoff = 500
315
+ this.emit('status', 'connected')
316
+ this.startSync()
317
+ this.startPassRefresh()
318
+ }
319
+
320
+ /** While connected, send the relay a fresh pass every few minutes so the connection's never runs out. */
321
+ startPassRefresh () {
322
+ clearInterval(this.passTimer)
323
+ if (!this.passes) return
324
+ this.passTimer = setInterval(() => this.refreshPass(), this.passRefreshMs)
325
+ this.passTimer.unref?.()
326
+ }
327
+
328
+ /** Sends the relay a fresh pass. `newer`: one issued after now (the relay asked: our access changed). */
329
+ async refreshPass ({ newer = false } = {}) {
330
+ try {
331
+ const pass = await (newer ? this.passes.newer() : this.passes.fresh())
332
+ this.emit('pass', this.passes.payload)
333
+ this.send(jsonMessage(MSG_PASS, { pass }))
334
+ } catch (err) {
335
+ if (this.closed) return
336
+ if (err.signedOut) {
337
+ this.emit('fatal', err)
338
+ return this.close()
339
+ }
340
+ this.emit('warn', `couldn't refresh the session pass: ${err.message}`)
341
+ }
342
+ }
343
+
344
+ startSync () {
345
+ this.send(syncStep1Message(this.doc))
346
+ if (this.branchKey !== null) {
347
+ // The relay handles messages in order: the join lands before the branch's sync step 1.
348
+ const id = crypto.randomBytes(8).toString('hex')
349
+ this.autoJoin = id
350
+ this.send(jsonMessage(MSG_BRANCH, { id, op: 'join', branch: this.branchKey, ...this.joinExtra }))
351
+ this.joinExtra = {}
352
+ // A copy of a known document waits for the relay to say it is still that document (handle, MSG_BRANCHES).
353
+ if (this.branchEpoch) this.branchHeld = true
354
+ else this.send(syncStep1Message(this.branchDoc, this.branchKey))
355
+ }
356
+ // Set again rather than resent: that moves our clock on, so the relay takes the state
357
+ // even when it still holds the clock from before a drop (it would ignore a repeat).
358
+ const mine = this.awareness.getLocalState()
359
+ if (mine !== null) this.awareness.setLocalState(mine)
360
+ const q = encoding.createEncoder()
361
+ encoding.writeVarUint(q, MSG_QUERY_AWARENESS)
362
+ this.send(encoding.toUint8Array(q))
363
+ }
364
+
365
+ handle (buf) {
366
+ const dec = decoding.createDecoder(buf)
367
+ const type = decoding.readVarUint(dec)
368
+ if (type === MSG_SYNC) {
369
+ const docId = decoding.readVarString(dec)
370
+ const doc = docId === ROOM_DOC ? this.doc : docId === this.branchKey && !this.branchHeld ? this.branchDoc : null
371
+ if (!doc) return // a branch this connection has left (or whose document the relay hasn't confirmed yet)
372
+ // Give the owner a chance to capture unsaved local edits so remote
373
+ // changes merge with them instead of overwriting them.
374
+ this.beforeRemote()
375
+ const enc = syncHeader(docId)
376
+ const header = encoding.length(enc)
377
+ const msgType = syncProtocol.readSyncMessage(dec, enc, doc, REMOTE)
378
+ if (encoding.length(enc) > header) this.send(encoding.toUint8Array(enc))
379
+ if (msgType === syncProtocol.messageYjsSyncStep2) {
380
+ if (doc === this.doc) this.roomSynced = true
381
+ else if (!this.branchSynced) { this.branchSynced = true; this.emit('branch-synced') }
382
+ this.noteSynced()
383
+ }
384
+ } else if (type === MSG_BRANCHES) {
385
+ const { branches, reply, pushed } = JSON.parse(decoding.readVarString(dec))
386
+ // GitHub moved a branch the session follows (the relay heard it from GitHub): look now.
387
+ if (pushed && typeof pushed.branch === 'string') this.emit('pushed', pushed)
388
+ if (reply && this.joining && reply.id === this.joining.id) {
389
+ const j = this.joining
390
+ this.joining = null
391
+ if (reply.ok) {
392
+ // A saved copy of an older document of this branch: a fresh one is synced instead.
393
+ const doc = staleEpoch(j.epoch, reply) ? (j.onStale ? j.onStale(reply) : new Y.Doc()) : j.doc
394
+ this.setBranch(j.key, doc)
395
+ this.send(syncStep1Message(doc, j.key))
396
+ }
397
+ }
398
+ if (reply && reply.id === this.autoJoin && reply.ok && this.branchHeld) {
399
+ this.branchHeld = false
400
+ if (staleEpoch(this.branchEpoch, reply)) {
401
+ const fresh = this.onStaleBranch ? this.onStaleBranch(reply) : new Y.Doc()
402
+ this.setBranch(this.branchKey, fresh)
403
+ }
404
+ this.send(syncStep1Message(this.branchDoc, this.branchKey))
405
+ }
406
+ if (reply && reply.op === 'join' && reply.ok) {
407
+ if (reply.epoch) this.branchEpoch = reply.epoch
408
+ this.emit('branch-joined', reply)
409
+ }
410
+ if (reply && reply.id === this.autoJoin && !reply.ok) this.failBranch(reply.error || 'refused')
411
+ if (Array.isArray(branches)) this.emit('branches', branches)
412
+ this.settle(reply, 'the relay refused that branch change')
413
+ } else if (type === MSG_AWARENESS) {
414
+ awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(dec), REMOTE)
415
+ } else if (type === MSG_QUERY_AWARENESS) {
416
+ this.send(awarenessMessage(this.awareness, [...this.awareness.getStates().keys()]))
417
+ } else if (type === MSG_ACCESS) {
418
+ const was = this.access
419
+ this.access = JSON.parse(decoding.readVarString(dec))
420
+ if (was && was.state === 'pending' && this.access.state === 'approved') this.startSync()
421
+ this.emit('access', this.access)
422
+ // The owner just changed what we may do: a fresh pass carries what the API now says.
423
+ if (this.access.refresh && this.passes) this.refreshPass({ newer: true })
424
+ } else if (type === MSG_MEMBERS) {
425
+ const msg = JSON.parse(decoding.readVarString(dec))
426
+ this.emit('members', msg)
427
+ this.settle(msg.reply, 'request refused')
428
+ } else if (type === MSG_CLAIMS) {
429
+ const { claims, reply } = JSON.parse(decoding.readVarString(dec))
430
+ this.emit('claims', claims)
431
+ this.settle(reply, 'claim refused')
432
+ }
433
+ }
434
+
435
+ /** Room and branch documents both synced: 'synced' (once per connection). */
436
+ noteSynced () {
437
+ const was = this.synced
438
+ this.synced = this.roomSynced && (this.branchKey === null || this.branchSynced)
439
+ if (this.synced && !was) this.emit('synced')
440
+ }
441
+
442
+ /** Syncs `doc` as branch `key` from now on; the previous branch document is let go. */
443
+ setBranch (key, doc) {
444
+ if (this.branchDoc) this.branchDoc.off('update', this._onBranchUpdate)
445
+ this.branchKey = key
446
+ this.branchDoc = doc
447
+ this.branchSynced = false
448
+ doc.on('update', this._onBranchUpdate)
449
+ }
450
+
451
+ /**
452
+ * The automatic join (the `branch` constructor option) was refused: there is no branch to
453
+ * sync, so nothing more is sent for it (`_onBranchUpdate` checks `branchKey`), and `synced`
454
+ * can complete without it. `branch-refused` is emitted first, so anyone in `waitForSync` /
455
+ * `waitForBranchSync` rejects instead of resolving on the `synced` this unblocks.
456
+ */
457
+ failBranch (error) {
458
+ if (this.branchDoc) this.branchDoc.off('update', this._onBranchUpdate)
459
+ this.branchHeld = false
460
+ this.branchKey = null
461
+ this.branchDoc = null
462
+ this.branchSynced = false
463
+ this.joining = null
464
+ this.autoJoin = null
465
+ this.emit('branch-refused', error)
466
+ this.noteSynced()
467
+ }
468
+
469
+ /**
470
+ * Moves this connection to branch `key`, syncing `doc` as it once the relay agrees: { branch, created, base, epoch }.
471
+ * `epoch`: which document of the branch `doc` holds a saved copy of; when the relay's is another, `onStale(reply)`
472
+ * gives the document synced instead (a new empty one by default), and `doc` is never sent.
473
+ */
474
+ joinBranch (key, doc, { epoch = null, onStale = null, ...extra } = {}) {
475
+ return this.request(MSG_BRANCH, { op: 'join', branch: key, ...extra }, 'branch switches', (id) => { this.joining = { id, key, doc, epoch, onStale } })
476
+ }
477
+
478
+ /** Resolves once the relay holds every change this app has on its branch (before a switch clears the folder). */
479
+ confirmBranch () {
480
+ if (this.branchKey === null) return Promise.resolve({ ok: true })
481
+ const sv = Buffer.from(Y.encodeStateVector(this.branchDoc)).toString('base64')
482
+ return this.request(MSG_BRANCH, { op: 'confirm', branch: this.branchKey, sv }, 'branch switches')
483
+ }
484
+
485
+ /** Any other branch request (remove). */
486
+ branchRequest (req) { return this.request(MSG_BRANCH, req, 'branch changes') }
487
+
488
+ waitForBranchSync () {
489
+ if (this.branchSynced) return Promise.resolve()
490
+ return new Promise((resolve, reject) => {
491
+ const onSynced = () => { cleanup(); resolve() }
492
+ const onFatal = (err) => { cleanup(); reject(err) }
493
+ const onRefused = (err) => { cleanup(); reject(err instanceof Error ? err : new Error(String(err))) }
494
+ const onDown = (s) => { if (s === 'disconnected') { cleanup(); reject(new Error('disconnected from relay')) } }
495
+ const cleanup = () => { this.off('branch-synced', onSynced); this.off('fatal', onFatal); this.off('status', onDown); this.off('branch-refused', onRefused) }
496
+ this.on('branch-synced', onSynced)
497
+ this.on('fatal', onFatal)
498
+ this.on('status', onDown)
499
+ this.on('branch-refused', onRefused)
500
+ })
501
+ }
502
+
503
+ settle (reply, fallback) {
504
+ const r = reply && this.requests.get(reply.id)
505
+ if (!r) return
506
+ clearTimeout(r.timer)
507
+ this.requests.delete(reply.id)
508
+ if (reply.ok) r.resolve(reply)
509
+ else r.reject(new Error(reply.error || fallback))
510
+ }
511
+
512
+ request (type, req, what, onId = null, timeoutMs = REQUEST_TIMEOUT_MS) {
513
+ if (!this.authed || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
514
+ return Promise.reject(new Error(`not connected to the relay; ${what} need a connection`))
515
+ }
516
+ const id = crypto.randomBytes(8).toString('hex')
517
+ if (onId) onId(id)
518
+ return new Promise((resolve, reject) => {
519
+ const timer = setTimeout(() => { this.requests.delete(id); reject(new Error('the relay did not answer')) }, timeoutMs)
520
+ this.requests.set(id, { resolve, reject, timer })
521
+ this.send(jsonMessage(type, { id, ...req }))
522
+ })
523
+ }
524
+
525
+ /** Asks the relay to claim or release; resolves with its reply. Claims need a live connection. */
526
+ claimRequest (req) { return this.request(MSG_CLAIM, req, 'claims') }
527
+
528
+ /** Approve / deny / change / remove someone, or set who may admit (relay enforces who may). */
529
+ adminRequest (req, { timeoutMs } = {}) { return this.request(MSG_ADMIN, req, 'changes to who is in the session', null, timeoutMs) }
530
+
531
+ send (msg) {
532
+ if (this.authed && this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(msg)
533
+ }
534
+
535
+ waitForSync () {
536
+ if (this.synced) return Promise.resolve()
537
+ return new Promise((resolve, reject) => {
538
+ const onSynced = () => { cleanup(); resolve() }
539
+ const onFatal = (err) => { cleanup(); reject(err) }
540
+ const onRefused = (err) => { cleanup(); reject(err instanceof Error ? err : new Error(String(err))) }
541
+ const cleanup = () => { this.off('synced', onSynced); this.off('fatal', onFatal); this.off('branch-refused', onRefused) }
542
+ this.on('synced', onSynced)
543
+ this.on('fatal', onFatal)
544
+ this.on('branch-refused', onRefused)
545
+ })
546
+ }
547
+
548
+ close () {
549
+ this.closed = true
550
+ clearInterval(this.passTimer)
551
+ clearTimeout(this.livenessTimer)
552
+ this.doc.off('update', this._onUpdate)
553
+ if (this.branchDoc) this.branchDoc.off('update', this._onBranchUpdate)
554
+ this.awareness.off('update', this._onAwareness)
555
+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'local')
556
+ if (this.ws) {
557
+ const ws = this.ws
558
+ // Flush the presence removal before closing. If the relay doesn't ack the close
559
+ // handshake (it's gone, or this socket is in the middle of being refused), don't
560
+ // leave it half-closed for the 'ws' library's ~30s default: finish it off.
561
+ setTimeout(() => {
562
+ ws.close()
563
+ const t = setTimeout(() => { if (ws.readyState !== ws.CLOSED) ws.terminate() }, 2000)
564
+ ws.once('close', () => clearTimeout(t))
565
+ }, 50)
566
+ }
567
+ this.awareness.destroy()
568
+ }
569
+ }