@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/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
+ }
@@ -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
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Baton">
2
+ <rect width="64" height="64" rx="14" fill="#1f3b57"/>
3
+ <path d="M18 40l20-20 8 8-20 20z" fill="#f2b544"/>
4
+ <path d="M38 20l6-6 8 8-6 6z" fill="#f7d391"/>
5
+ </svg>