heyquilt 0.0.1 → 0.3.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|