@ohmynpm/dsh-baton 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/README.zh-CN.md +135 -0
- package/client.js +901 -0
- package/connection.js +397 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +5 -0
- package/index.js +498 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +64 -4
- package/session.js +221 -0
package/connection.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side WebSocket client for baton-server.
|
|
3
|
+
*
|
|
4
|
+
* One connection per DSH install. Requests are serialized (the plan allows one
|
|
5
|
+
* in-flight request per connection), so a 1 MiB sync response can never stack
|
|
6
|
+
* behind UI actions. Reconnect resends hello and re-syncs; state.changed only
|
|
7
|
+
* triggers a sync, the DB on the server remains authoritative.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { isAbsolute, join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const PROTOCOL_VERSION = 1
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
16
|
+
const HEARTBEAT_MS = 30_000
|
|
17
|
+
const IDLE_LIMIT_MS = 60_000
|
|
18
|
+
const SYNC_INTERVAL_MS = 60_000
|
|
19
|
+
const MAX_REQUEST_BYTES = 32 * 1024
|
|
20
|
+
const FATAL_CODES = new Set(['auth_failed', 'protocol_mismatch'])
|
|
21
|
+
const connectionClosed = () => new BatonRequestError('connection-closed', 'connection closed')
|
|
22
|
+
const advertisedEnvironments = (environments) => environments.map(({ key, label }) => ({ key, label }))
|
|
23
|
+
|
|
24
|
+
export class BatonRequestError extends Error {
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.name = 'BatonRequestError'
|
|
28
|
+
this.code = code
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateServerUrl(value) {
|
|
33
|
+
const url = new URL(value)
|
|
34
|
+
if (url.username || url.password) throw new Error('serverUrl must not contain credentials')
|
|
35
|
+
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1' || url.hostname === '[::1]'
|
|
36
|
+
if (url.protocol === 'ws:' && !loopback) throw new Error('serverUrl must use wss:// outside localhost')
|
|
37
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw new Error('serverUrl must be ws:// or wss://')
|
|
38
|
+
return url.toString()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Env wins when non-empty; the file is read only when the env is absent or empty. */
|
|
42
|
+
export async function readSecret({ envName = 'BATON_SECRET', filePath } = {}) {
|
|
43
|
+
const fromEnv = process.env[envName]
|
|
44
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) return { secret: fromEnv, source: 'env' }
|
|
45
|
+
const path = filePath ?? join(homedir(), '.config', 'baton', 'secret')
|
|
46
|
+
if (!isAbsolute(path)) throw new Error('secretFile must be absolute')
|
|
47
|
+
const info = await stat(path)
|
|
48
|
+
if ((info.mode & 0o077) !== 0) throw new Error(`secretFile permissions are too open: ${(info.mode & 0o777).toString(8)}`)
|
|
49
|
+
const secret = (await readFile(path, 'utf8')).replace(/\r?\n$/, '')
|
|
50
|
+
if (secret.length === 0) throw new Error('secretFile is empty')
|
|
51
|
+
return { secret, source: 'file' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class BatonConnection {
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {string} options.serverUrl
|
|
58
|
+
* @param {string} options.secret
|
|
59
|
+
* @param {string} options.userId
|
|
60
|
+
* @param {string} options.displayName
|
|
61
|
+
* @param {(snapshot: object) => void} [options.onSnapshot]
|
|
62
|
+
* @param {(event: object) => void} [options.onEvent]
|
|
63
|
+
* @param {(status: {state: string, detail?: string}) => void} [options.onStatus]
|
|
64
|
+
*/
|
|
65
|
+
constructor({ serverUrl, secret, userId, displayName, environments = [], onSnapshot, onEvent, onStatus }) {
|
|
66
|
+
this.serverUrl = validateServerUrl(serverUrl)
|
|
67
|
+
if (typeof secret !== 'string' || [...secret].length < 32) throw new Error('secret must be at least 32 characters')
|
|
68
|
+
this.secret = secret
|
|
69
|
+
this.userId = userId
|
|
70
|
+
this.displayName = displayName
|
|
71
|
+
this.environments = advertisedEnvironments(environments)
|
|
72
|
+
this.onSnapshot = onSnapshot
|
|
73
|
+
this.onEvent = onEvent
|
|
74
|
+
this.onStatus = onStatus ?? (() => {})
|
|
75
|
+
this.socket = undefined
|
|
76
|
+
this.open = false
|
|
77
|
+
this.authenticated = false
|
|
78
|
+
this.stopped = true
|
|
79
|
+
this.attempts = 0
|
|
80
|
+
this.queue = []
|
|
81
|
+
this.inflight = undefined
|
|
82
|
+
this.lastMessageAt = 0
|
|
83
|
+
this.timers = new Set()
|
|
84
|
+
this.reconnectTimer = undefined
|
|
85
|
+
this.heartbeatTimer = undefined
|
|
86
|
+
this.syncTimer = undefined
|
|
87
|
+
this.syncing = undefined
|
|
88
|
+
this.syncAgain = false
|
|
89
|
+
this.generation = 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
start() {
|
|
93
|
+
if (!this.stopped) return
|
|
94
|
+
this.stopped = false
|
|
95
|
+
this.connect()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stop() {
|
|
99
|
+
this.stopped = true
|
|
100
|
+
this.closeSocket(1000, 'client stopped')
|
|
101
|
+
this.onStatus({ state: 'stopped' })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
closeSocket(code = 1001, reason = 'connection closed') {
|
|
105
|
+
// Fence both socket events and promises already resolved before teardown.
|
|
106
|
+
this.generation += 1
|
|
107
|
+
this.open = false
|
|
108
|
+
this.authenticated = false
|
|
109
|
+
this.clearTimers()
|
|
110
|
+
this.syncAgain = false
|
|
111
|
+
this.syncing = undefined
|
|
112
|
+
this.failAll(connectionClosed())
|
|
113
|
+
const socket = this.socket
|
|
114
|
+
this.socket = undefined
|
|
115
|
+
try { socket?.close(code, reason) } catch {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
clearTimers() {
|
|
119
|
+
for (const timer of this.timers) clearTimeout(timer)
|
|
120
|
+
this.timers.clear()
|
|
121
|
+
clearTimeout(this.reconnectTimer)
|
|
122
|
+
this.reconnectTimer = undefined
|
|
123
|
+
clearInterval(this.heartbeatTimer)
|
|
124
|
+
clearInterval(this.syncTimer)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
later(fn, delay) {
|
|
128
|
+
const timer = setTimeout(() => { this.timers.delete(timer); fn() }, delay)
|
|
129
|
+
this.timers.add(timer)
|
|
130
|
+
return timer
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
connect() {
|
|
134
|
+
if (this.stopped) return
|
|
135
|
+
const generation = ++this.generation
|
|
136
|
+
this.onStatus({ state: this.attempts === 0 ? 'connecting' : 'reconnecting' })
|
|
137
|
+
if (this.stopped || generation !== this.generation) return
|
|
138
|
+
let socket
|
|
139
|
+
try {
|
|
140
|
+
socket = new WebSocket(this.serverUrl)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
this.onStatus({ state: 'error', detail: error.message })
|
|
143
|
+
this.scheduleReconnect()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
this.socket = socket
|
|
147
|
+
const alive = () => !this.stopped && generation === this.generation
|
|
148
|
+
const down = (code) => {
|
|
149
|
+
if (!alive()) return
|
|
150
|
+
this.closeSocket()
|
|
151
|
+
this.onStatus({ state: 'disconnected', detail: `code ${code}` })
|
|
152
|
+
this.scheduleReconnect()
|
|
153
|
+
}
|
|
154
|
+
socket.addEventListener('open', () => {
|
|
155
|
+
if (!alive() || this.open) return
|
|
156
|
+
this.open = true
|
|
157
|
+
this.lastMessageAt = Date.now()
|
|
158
|
+
this.onStatus({ state: 'authenticating' })
|
|
159
|
+
if (!alive()) return
|
|
160
|
+
this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_MS)
|
|
161
|
+
this.authenticate(generation).catch((error) => { if (alive()) this.handleFatal(error) })
|
|
162
|
+
})
|
|
163
|
+
socket.addEventListener('message', (event) => {
|
|
164
|
+
if (!alive()) return
|
|
165
|
+
this.receive(event.data)
|
|
166
|
+
})
|
|
167
|
+
socket.addEventListener('error', () => {
|
|
168
|
+
if (!alive()) return
|
|
169
|
+
this.onStatus({ state: 'error', detail: 'socket error' })
|
|
170
|
+
// Some runtimes fire error without close; force the state machine forward.
|
|
171
|
+
down('error')
|
|
172
|
+
})
|
|
173
|
+
socket.addEventListener('close', (event) => down(event.code))
|
|
174
|
+
// A connect attempt that neither opens nor closes must not hang the client.
|
|
175
|
+
this.later(() => {
|
|
176
|
+
if (!alive() || this.open) return
|
|
177
|
+
down('timeout')
|
|
178
|
+
}, 10_000)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async authenticate(generation = this.generation) {
|
|
182
|
+
const environments = this.environments
|
|
183
|
+
await this.request('hello', {
|
|
184
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
185
|
+
secret: this.secret,
|
|
186
|
+
userId: this.userId,
|
|
187
|
+
displayName: this.displayName,
|
|
188
|
+
environments,
|
|
189
|
+
})
|
|
190
|
+
if (this.stopped || generation !== this.generation || !this.open) return
|
|
191
|
+
this.authenticated = true
|
|
192
|
+
// An edit made while hello was in flight was not advertised by that hello.
|
|
193
|
+
if (environments !== this.environments) await this.setEnvironments(this.environments)
|
|
194
|
+
if (this.stopped || generation !== this.generation || !this.open) return
|
|
195
|
+
this.attempts = 0
|
|
196
|
+
this.syncTimer = setInterval(() => {
|
|
197
|
+
this.sync().catch((error) => {
|
|
198
|
+
if (!this.stopped && generation === this.generation) this.handleFatal(error)
|
|
199
|
+
})
|
|
200
|
+
}, SYNC_INTERVAL_MS)
|
|
201
|
+
this.onStatus({ state: 'online' })
|
|
202
|
+
if (this.stopped || generation !== this.generation) return
|
|
203
|
+
await this.sync()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
scheduleReconnect(delay) {
|
|
207
|
+
if (this.stopped || this.reconnectTimer) return
|
|
208
|
+
const base = Math.min(30_000, 1000 * 2 ** Math.min(this.attempts, 5))
|
|
209
|
+
const wait = delay ?? base + Math.floor(Math.random() * 500)
|
|
210
|
+
this.attempts += 1
|
|
211
|
+
this.onStatus({ state: 'reconnecting', detail: `${Math.round(wait / 1000)}s` })
|
|
212
|
+
if (this.stopped) return
|
|
213
|
+
this.reconnectTimer = setTimeout(() => {
|
|
214
|
+
this.reconnectTimer = undefined
|
|
215
|
+
this.connect()
|
|
216
|
+
}, wait)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
handleFatal(error) {
|
|
220
|
+
if (this.stopped) return
|
|
221
|
+
if (FATAL_CODES.has(error.code)) {
|
|
222
|
+
this.stop()
|
|
223
|
+
this.onStatus({ state: 'fatal', detail: error.message })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
this.closeSocket()
|
|
227
|
+
if (error.code === 'rate_limited') {
|
|
228
|
+
this.onStatus({ state: 'rate-limited', detail: 'retry in 60s' })
|
|
229
|
+
this.scheduleReconnect(60_000)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
this.onStatus({ state: 'reconnecting', detail: error.message })
|
|
233
|
+
this.scheduleReconnect()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
heartbeat() {
|
|
237
|
+
if (!this.authenticated || this.stopped) return
|
|
238
|
+
if (Date.now() - this.lastMessageAt >= IDLE_LIMIT_MS) {
|
|
239
|
+
this.closeSocket(1001, 'idle')
|
|
240
|
+
this.onStatus({ state: 'idle-timeout' })
|
|
241
|
+
this.scheduleReconnect()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
this.request('ping', {}).catch(() => {})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
receive(raw) {
|
|
248
|
+
let message
|
|
249
|
+
try {
|
|
250
|
+
message = JSON.parse(typeof raw === 'string' ? raw : String(raw))
|
|
251
|
+
} catch {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
this.lastMessageAt = Date.now()
|
|
255
|
+
if (message?.type === 'state.changed') {
|
|
256
|
+
if (!this.authenticated) return
|
|
257
|
+
this.onEvent?.(message)
|
|
258
|
+
const generation = this.generation
|
|
259
|
+
this.sync().catch((error) => {
|
|
260
|
+
if (!this.stopped && generation === this.generation) this.handleFatal(error)
|
|
261
|
+
})
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (message?.id === undefined) return
|
|
265
|
+
const pending = this.inflight
|
|
266
|
+
if (!pending || pending.id !== message.id) return // late response after timeout
|
|
267
|
+
this.inflight = undefined
|
|
268
|
+
clearTimeout(pending.timer)
|
|
269
|
+
if (message.type === 'ok') pending.resolve(message.data ?? {})
|
|
270
|
+
else {
|
|
271
|
+
const error = new BatonRequestError(message.code ?? 'internal', message.message ?? 'request failed')
|
|
272
|
+
pending.reject(error)
|
|
273
|
+
if (FATAL_CODES.has(error.code) || error.code === 'rate_limited') {
|
|
274
|
+
this.handleFatal(error)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
this.pump()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
request(type, data = {}) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
if (this.stopped || !this.open || (!this.authenticated && type !== 'hello')) {
|
|
284
|
+
reject(connectionClosed())
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
const id = randomUUID()
|
|
288
|
+
let encoded
|
|
289
|
+
try {
|
|
290
|
+
encoded = JSON.stringify({ id, type, data })
|
|
291
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) throw new Error('too large')
|
|
292
|
+
} catch {
|
|
293
|
+
reject(new BatonRequestError('invalid_request', 'request must be valid JSON within 32 KiB'))
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const pending = { id, type, encoded, resolve, reject, deadline: Date.now() + REQUEST_TIMEOUT_MS }
|
|
297
|
+
pending.timer = setTimeout(() => this.expire(pending), REQUEST_TIMEOUT_MS)
|
|
298
|
+
this.queue.push(pending)
|
|
299
|
+
this.pump()
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
expire(pending) {
|
|
304
|
+
if (this.inflight === pending) this.inflight = undefined
|
|
305
|
+
else {
|
|
306
|
+
const index = this.queue.indexOf(pending)
|
|
307
|
+
if (index === -1) return
|
|
308
|
+
this.queue.splice(index, 1)
|
|
309
|
+
}
|
|
310
|
+
clearTimeout(pending.timer)
|
|
311
|
+
pending.reject(new BatonRequestError('timeout', `${pending.type} timed out`))
|
|
312
|
+
this.pump()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
pump() {
|
|
316
|
+
if (this.stopped || this.inflight || !this.open || this.queue.length === 0) return
|
|
317
|
+
const next = this.queue[0]
|
|
318
|
+
if (!this.authenticated && next.type !== 'hello') return
|
|
319
|
+
if (Date.now() >= next.deadline) { this.expire(next); return }
|
|
320
|
+
const pending = this.queue.shift()
|
|
321
|
+
this.inflight = pending
|
|
322
|
+
try {
|
|
323
|
+
this.socket.send(pending.encoded)
|
|
324
|
+
} catch {
|
|
325
|
+
this.closeSocket()
|
|
326
|
+
this.onStatus({ state: 'disconnected' })
|
|
327
|
+
this.scheduleReconnect()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
failAll(error) {
|
|
332
|
+
if (this.inflight) {
|
|
333
|
+
clearTimeout(this.inflight.timer)
|
|
334
|
+
this.inflight.reject(error)
|
|
335
|
+
this.inflight = undefined
|
|
336
|
+
}
|
|
337
|
+
for (const pending of this.queue.splice(0)) {
|
|
338
|
+
clearTimeout(pending.timer)
|
|
339
|
+
pending.reject(error)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async setEnvironments(environments) {
|
|
344
|
+
this.environments = advertisedEnvironments(environments)
|
|
345
|
+
if (this.authenticated && this.open) {
|
|
346
|
+
await this.request('env.set', { environments: this.environments })
|
|
347
|
+
return { published: true }
|
|
348
|
+
}
|
|
349
|
+
return { published: false }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async sync() {
|
|
353
|
+
if (this.stopped || !this.authenticated || !this.open) throw connectionClosed()
|
|
354
|
+
if (this.syncing) {
|
|
355
|
+
this.syncAgain = true
|
|
356
|
+
return this.syncing
|
|
357
|
+
}
|
|
358
|
+
const generation = this.generation
|
|
359
|
+
const syncing = this.request('sync', {}).then((snapshot) => {
|
|
360
|
+
if (this.stopped || generation !== this.generation) throw connectionClosed()
|
|
361
|
+
this.onSnapshot?.(snapshot)
|
|
362
|
+
return snapshot
|
|
363
|
+
}).finally(() => {
|
|
364
|
+
if (this.syncing !== syncing) return
|
|
365
|
+
this.syncing = undefined
|
|
366
|
+
if (this.syncAgain) {
|
|
367
|
+
this.syncAgain = false
|
|
368
|
+
this.sync().catch((error) => {
|
|
369
|
+
if (!this.stopped && generation === this.generation) this.handleFatal(error)
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
this.syncing = syncing
|
|
374
|
+
return syncing
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async retryTask(type, data) {
|
|
378
|
+
const payload = { ...data }
|
|
379
|
+
const generation = this.generation
|
|
380
|
+
try { return await this.request(type, payload) } catch (error) {
|
|
381
|
+
if (!['timeout', 'internal'].includes(error.code)) throw error
|
|
382
|
+
if (this.stopped || !this.authenticated || generation !== this.generation) throw connectionClosed()
|
|
383
|
+
return this.request(type, payload)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
roomCreate(name) { return this.request('room.create', { name }) }
|
|
388
|
+
roomJoin(code) { return this.request('room.join', { code }) }
|
|
389
|
+
roomLeave(code) { return this.request('room.leave', { code }) }
|
|
390
|
+
roomDisband(code) { return this.request('room.disband', { code }) }
|
|
391
|
+
taskSend(task) { return this.retryTask('task.send', task) }
|
|
392
|
+
taskGet(taskId) { return this.request('task.get', { taskId }) }
|
|
393
|
+
taskRespond(taskId, decision) { return this.retryTask('task.respond', { taskId, decision }) }
|
|
394
|
+
taskArchive(taskId) { return this.retryTask('task.archive', { taskId }) }
|
|
395
|
+
taskRestore(taskId) { return this.retryTask('task.restore', { taskId }) }
|
|
396
|
+
taskDelete(taskId) { return this.retryTask('task.delete', { taskId }) }
|
|
397
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Safe placeholder shipped with the plugin. It connects to no server.
|
|
2
|
+
# After installing, add a complete override row with the same id to your
|
|
3
|
+
# profile's cordis.patch.yml (serverUrl, displayName, environments).
|
|
4
|
+
- insert:
|
|
5
|
+
- id: baton
|
|
6
|
+
name: '@ohmynpm/dsh-baton'
|
|
7
|
+
config:
|
|
8
|
+
serverUrl: ''
|
package/icon.svg
ADDED