@dotrino/identity 0.10.0 → 0.12.0

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.
@@ -0,0 +1,675 @@
1
+ import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
2
+ import { WebRTCManager, RTC_TAG } from './webrtc.js'
3
+
4
+ /**
5
+ * Dotrino WebSocket proxy client.
6
+ * Minimal API: connection + token + messages + channels (publish/list/count/disconnect)
7
+ * with ECDSA P-256 signed envelopes.
8
+ *
9
+ * Events emitted:
10
+ * - 'connect' () : socket open
11
+ * - 'token' (token) : token assigned by proxy
12
+ * - 'disconnect' ({code, reason}) : socket closed
13
+ * - 'error' (errorObj) : transport or server error
14
+ * - 'message' (from, payload, raw) : incoming peer message
15
+ * - 'channel_joined' (channel, token) : new peer joined the channel
16
+ * - 'channel_left' (channel, token) : peer unpublished
17
+ * - 'peer_disconnected' (token, channel?) : peer dropped (with channel if it was published there)
18
+ * - 'reconnecting' (attempt, max)
19
+ * - 'reconnect_failed' (attempts)
20
+ */
21
+ export class WebSocketProxyClient {
22
+ constructor (options = {}) {
23
+ this.url = options.url || 'wss://proxy.dotrino.com'
24
+ this.autoReconnect = options.autoReconnect !== false
25
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5
26
+ this.reconnectDelay = options.reconnectDelay ?? 3000
27
+ this.enableWebRTC = options.enableWebRTC !== false
28
+ this.iceServers = options.iceServers || null
29
+
30
+ // Heartbeat de aplicación: el WebSocket del browser NO expone ping/pong de
31
+ // protocolo, así que mandamos `{type:'ping'}` y esperamos cualquier tráfico
32
+ // de vuelta (el server responde `pong`). Si no hay respuesta en
33
+ // `heartbeatTimeout`, la conexión está "half-open" (TCP muerto sin FIN) y
34
+ // forzamos la reconexión. Sin esto, una caída silenciosa pasa inadvertida y
35
+ // los `send` se pierden en el vacío.
36
+ this.enableHeartbeat = options.enableHeartbeat !== false
37
+ this.heartbeatInterval = options.heartbeatInterval ?? 20000
38
+ this.heartbeatTimeout = options.heartbeatTimeout ?? 8000
39
+ this._hbTimer = null
40
+ this._hbDeadTimer = null
41
+
42
+ this.ws = null
43
+ this.token = null
44
+ this._connected = false
45
+ this._reconnectAttempts = 0
46
+ this._reconnectTimer = null
47
+ this._handlers = new Map()
48
+ this._pending = new Map() // messageId -> { resolve, reject, timer }
49
+ this._nextId = 1
50
+
51
+ this._rtc = this.enableWebRTC ? new WebRTCManager({
52
+ getSelfToken: () => this.token,
53
+ signalSend: (to, payload) => this._proxySendOne(to, payload),
54
+ deliverMessage: (from, parsed, meta) => this._emit('message', from, parsed, meta),
55
+ emit: (event, ...args) => this._emit(event, ...args),
56
+ config: this.iceServers ? { iceServers: this.iceServers } : null
57
+ }) : null
58
+ }
59
+
60
+ // ---------- public API ----------
61
+
62
+ get isConnected () { return this._connected }
63
+
64
+ connect () {
65
+ return new Promise((resolve, reject) => {
66
+ if (this._connected) return resolve(this.token)
67
+ this._connectResolve = resolve
68
+ this._connectReject = reject
69
+ this._open()
70
+ })
71
+ }
72
+
73
+ close () {
74
+ this.autoReconnect = false
75
+ if (this._reconnectTimer) {
76
+ clearTimeout(this._reconnectTimer)
77
+ this._reconnectTimer = null
78
+ }
79
+ if (this._rtc) this._rtc.closeAll()
80
+ if (this.ws) {
81
+ try { this.ws.close(1000) } catch (_) {}
82
+ }
83
+ }
84
+
85
+ /** Alias for close() to ease migration from older clients. */
86
+ disconnect () { return this.close() }
87
+
88
+ /** Update connection options before (re)connecting. */
89
+ updateConfig (options = {}) {
90
+ if (options.url) this.url = options.url
91
+ if (typeof options.autoReconnect === 'boolean') this.autoReconnect = options.autoReconnect
92
+ if (typeof options.maxReconnectAttempts === 'number') this.maxReconnectAttempts = options.maxReconnectAttempts
93
+ if (typeof options.reconnectDelay === 'number') this.reconnectDelay = options.reconnectDelay
94
+ }
95
+
96
+ on (event, handler) {
97
+ if (!this._handlers.has(event)) this._handlers.set(event, new Set())
98
+ this._handlers.get(event).add(handler)
99
+ return () => this.off(event, handler)
100
+ }
101
+
102
+ off (event, handler) {
103
+ const set = this._handlers.get(event)
104
+ if (set) set.delete(handler)
105
+ }
106
+
107
+ /**
108
+ * Send a payload to one or many peer tokens.
109
+ * The payload is JSON-stringified into the envelope's `message` field.
110
+ */
111
+ send (to, payload) {
112
+ const tokens = Array.isArray(to) ? to : [to]
113
+ const messageStr = typeof payload === 'string' ? payload : JSON.stringify(payload)
114
+ if (!this._rtc) {
115
+ this._sendRaw({ to: tokens, message: messageStr })
116
+ return
117
+ }
118
+ const proxyTokens = []
119
+ for (const t of tokens) {
120
+ if (!this._rtc.trySend(t, messageStr)) proxyTokens.push(t)
121
+ }
122
+ if (proxyTokens.length) {
123
+ this._sendRaw({ to: proxyTokens, message: messageStr })
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Force opening (or reusing) a WebRTC DataChannel to a peer.
129
+ * Resolves once the channel is open. Rejects on failure.
130
+ */
131
+ connectWebRTC (token) {
132
+ if (!this._rtc) return Promise.reject(new Error('WebRTC disabled'))
133
+ return this._rtc.connect(token)
134
+ }
135
+
136
+ /** True if there is an open DataChannel to the given peer. */
137
+ isWebRTCOpen (token) {
138
+ return !!(this._rtc && this._rtc.isOpen(token))
139
+ }
140
+
141
+ _proxySendOne (to, payload) {
142
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
143
+ this._sendRaw({
144
+ to: [to],
145
+ message: typeof payload === 'string' ? payload : JSON.stringify(payload)
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Publish self into a public channel.
151
+ * @param {string} channelName
152
+ * @param {Object} [extraData] Extra fields baked into channel.data and signed.
153
+ */
154
+ async publish (channelName, extraData = {}) {
155
+ const channel = await buildSignedChannel(channelName, extraData)
156
+ return this._request({ type: 'publish', channel }, 'published', 'channel')
157
+ }
158
+
159
+ /** Unpublish self from a public channel. */
160
+ async unpublish (channelName) {
161
+ const channel = await buildSignedChannel(channelName)
162
+ return this._request({ type: 'unpublish', channel }, 'unpublished', 'channel')
163
+ }
164
+
165
+ /** List the tokens currently in a channel. */
166
+ async list (channelName) {
167
+ const channel = await buildSignedChannel(channelName)
168
+ const res = await this._request({ type: 'list', channel }, 'channel_list', 'channel')
169
+ return res.tokens || []
170
+ }
171
+
172
+ /** Alias for list() to ease migration from older clients. */
173
+ listChannel (channelName) { return this.list(channelName) }
174
+
175
+ /**
176
+ * Watch a channel read-only: receive its `channel_joined`/`channel_left`/
177
+ * `peer_disconnected` events live WITHOUT being listed as a member (you won't
178
+ * appear in others' list()). Ideal for a lobby that shows rooms in real time
179
+ * without publishing itself as a phantom room. Resolves with the current tokens.
180
+ */
181
+ async watch (channelName) {
182
+ const channel = await buildSignedChannel(channelName)
183
+ const res = await this._request({ type: 'watch', channel }, 'watched', 'channel')
184
+ return res.tokens || []
185
+ }
186
+
187
+ /** Stop watching a channel. */
188
+ async unwatch (channelName) {
189
+ const channel = await buildSignedChannel(channelName)
190
+ return this._request({ type: 'unwatch', channel }, 'unwatched', 'channel')
191
+ }
192
+
193
+ /** List public channel names (optionally filtered by prefix). */
194
+ async listChannels (options = {}) {
195
+ const msg = { type: 'list_channels' }
196
+ if (typeof options.prefix === 'string') msg.prefix = options.prefix
197
+ const res = await this._request(msg, 'channels_list')
198
+ return res.channels || []
199
+ }
200
+
201
+ /** How many tokens are in a channel right now (no listing). */
202
+ async channelCount (channelName) {
203
+ const res = await this._request(
204
+ { type: 'channel_count', channel: channelName },
205
+ 'channel_count', 'channel'
206
+ )
207
+ return res.count || 0
208
+ }
209
+
210
+ /**
211
+ * Direccionar uno o varios mensajes por **publickey** (con cola offline en
212
+ * el proxy hasta 24h). El destinatario debe haber llamado previamente a
213
+ * `identify` para que el proxy sepa qué token tiene asignado en cada momento.
214
+ *
215
+ * Si el destinatario está conectado, se entrega de inmediato; si no, queda
216
+ * en cola y se entrega cuando se reconecte e identifique. Los WebRTC peers
217
+ * NO se usan para esta ruta (el proxy debe ser el broker).
218
+ *
219
+ * @param {string|string[]} toPubkeys publickey JWK string o array
220
+ * @param {any} payload
221
+ */
222
+ sendByPubkey (toPubkeys, payload) {
223
+ const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
224
+ this._sendRaw({
225
+ to_publickey: list,
226
+ message: typeof payload === 'string' ? payload : JSON.stringify(payload)
227
+ })
228
+ }
229
+
230
+ /**
231
+ * Registrar la conexión actual bajo una publickey estable. Se requiere un
232
+ * sobre `{data:{op,publickey,token,ts}, signature}` firmado externamente
233
+ * (típicamente por el identity vault). Devuelve la respuesta del proxy con
234
+ * `queued_delivered` (mensajes offline despachados al instante).
235
+ */
236
+ identify ({ data, signature, cert }) {
237
+ if (!data || !signature) throw new Error('identify requires {data, signature}')
238
+ const msg = { type: 'identify', data, signature }
239
+ if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
240
+ return this._request(msg, 'identified')
241
+ }
242
+
243
+ /**
244
+ * Consultar la config de Web Push del proxy.
245
+ * @returns {Promise<{enabled:boolean, vapidPublicKey:string|null}>}
246
+ */
247
+ async getPushConfig () {
248
+ const res = await this._request({ type: 'push-config' }, 'push-config')
249
+ return { enabled: !!res.enabled, vapidPublicKey: res.vapidPublicKey || null }
250
+ }
251
+
252
+ /**
253
+ * Activar Web Push ("timbre" para mensajes offline). Registra el Service
254
+ * Worker, crea la PushSubscription (VAPID) y la registra en el proxy bajo la
255
+ * MISMA publickey usada en `identify` (la del vault), con un sobre firmado por
256
+ * el vault — igual patrón que identify.
257
+ *
258
+ * No usa el SDK de Firebase: solo Web Push estándar. El push no transporta
259
+ * contenido de usuario; despierta al SW para que reconecte y baje la cola.
260
+ *
261
+ * Resolución del Service Worker (en orden):
262
+ * - `registration`: usa esa ServiceWorkerRegistration directamente.
263
+ * - `swPath`: registra ese archivo (apps sin SW propio).
264
+ * - ninguno: usa el SW ya registrado por la app (`navigator.serviceWorker.ready`).
265
+ * Esto último es lo correcto para PWAs que ya tienen su propio SW (p.ej. con
266
+ * vite-plugin-pwa/Workbox): inyectá los handlers de push en ese SW con
267
+ * `importScripts` y llamá enablePush() sin swPath para no clobbear el scope.
268
+ *
269
+ * @param {Object} opts
270
+ * @param {string} opts.publicKey Pubkey JWK string del vault (la de identify).
271
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault (id.signData).
272
+ * @param {string} [opts.vapidPublicKey] VAPID pública; si falta se pide al proxy.
273
+ * @param {ServiceWorkerRegistration} [opts.registration] SW ya registrado a reutilizar.
274
+ * @param {string} [opts.swPath] Ruta de un SW a registrar (apps sin SW propio).
275
+ * @param {string} [opts.swScope] Scope del SW (solo con swPath).
276
+ * @returns {Promise<PushSubscription>}
277
+ */
278
+ async enablePush ({ publicKey, sign, vapidPublicKey, registration, swPath, swScope } = {}) {
279
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
280
+ throw new Error('Service Worker no soportado en este entorno')
281
+ }
282
+ if (typeof PushManager === 'undefined') {
283
+ throw new Error('Push API no soportada en este navegador')
284
+ }
285
+ if (!publicKey || typeof sign !== 'function') {
286
+ throw new Error('enablePush requires { publicKey, sign }')
287
+ }
288
+ if (!vapidPublicKey) {
289
+ const cfg = await this.getPushConfig()
290
+ if (!cfg.enabled || !cfg.vapidPublicKey) throw new Error('El proxy no tiene Web Push habilitado')
291
+ vapidPublicKey = cfg.vapidPublicKey
292
+ }
293
+ let reg
294
+ if (registration) {
295
+ reg = registration
296
+ } else if (swPath) {
297
+ await navigator.serviceWorker.register(swPath, swScope ? { scope: swScope } : undefined)
298
+ reg = await navigator.serviceWorker.ready
299
+ } else {
300
+ // PWA con SW propio: reutilizar el registrado por la app.
301
+ reg = await navigator.serviceWorker.ready
302
+ }
303
+ let sub = await reg.pushManager.getSubscription()
304
+ if (!sub) {
305
+ sub = await reg.pushManager.subscribe({
306
+ userVisibleOnly: true,
307
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
308
+ })
309
+ }
310
+ const subJson = typeof sub.toJSON === 'function' ? sub.toJSON() : sub
311
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now() }
312
+ const signature = await normalizeSignature(sign, data)
313
+ await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
314
+ return sub
315
+ }
316
+
317
+ /**
318
+ * Desactivar Web Push: cancela la PushSubscription local y la borra del proxy.
319
+ * @param {Object} opts
320
+ * @param {string} opts.publicKey Pubkey JWK string del vault.
321
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault.
322
+ * @param {ServiceWorkerRegistration} [opts.registration] SW a usar (default: el activo).
323
+ * @param {string} [opts.swPath] Ruta del SW si se registró uno propio.
324
+ */
325
+ async disablePush ({ publicKey, sign, registration, swPath } = {}) {
326
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
327
+ try {
328
+ const reg = registration ||
329
+ (swPath ? await navigator.serviceWorker.getRegistration(swPath)
330
+ : await navigator.serviceWorker.ready)
331
+ const sub = reg && await reg.pushManager.getSubscription()
332
+ if (sub) await sub.unsubscribe()
333
+ } catch (_) { /* best-effort local */ }
334
+ }
335
+ if (publicKey && typeof sign === 'function') {
336
+ const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now() }
337
+ const signature = await normalizeSignature(sign, data)
338
+ await this._request({ type: 'push-unsubscribe', data, signature }, 'push-unsubscribed')
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Programar un push a la PROPIA pubkey (auto-recordatorio). El proxy lo
344
+ * dispara a la hora indicada, aunque la app esté cerrada (vía el SW). Es
345
+ * self-only: el target es siempre la pubkey que firma (no se puede programar
346
+ * a terceros). One-shot (`when`) o recurrente (`cron` + `tz`).
347
+ *
348
+ * @param {Object} opts
349
+ * @param {string} opts.publicKey Pubkey JWK string del vault.
350
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault.
351
+ * @param {Date|number} [opts.when] One-shot: instante futuro (Date o epoch ms).
352
+ * @param {string} [opts.cron] Recurrente: expresión cron (5 campos).
353
+ * @param {string} [opts.tz] Timezone IANA para el cron (ej. 'America/Argentina/Buenos_Aires').
354
+ * @param {object} [opts.payload] Datos extra opcionales para la notificación (ej. { title }).
355
+ * @returns {Promise<{ jobId:number, nextFire:number }>}
356
+ */
357
+ async schedulePush ({ publicKey, sign, when, cron, tz, payload } = {}) {
358
+ if (!publicKey || typeof sign !== 'function') throw new Error('schedulePush requires { publicKey, sign }')
359
+ const spec = {}
360
+ if (cron) {
361
+ spec.cron = cron
362
+ if (tz) spec.tz = tz
363
+ } else {
364
+ const fireAt = when instanceof Date ? when.getTime() : Number(when)
365
+ if (!Number.isFinite(fireAt)) throw new Error('schedulePush requires { when } (Date|ms) or { cron }')
366
+ spec.fireAt = fireAt
367
+ }
368
+ if (payload) spec.payload = payload
369
+ const data = { op: 'schedule-push', publickey: publicKey, spec: JSON.stringify(spec), ts: Date.now() }
370
+ const signature = await normalizeSignature(sign, data)
371
+ const res = await this._request({ type: 'schedule-push', data, signature }, 'push-scheduled')
372
+ return { jobId: res.jobId, nextFire: res.nextFire }
373
+ }
374
+
375
+ /**
376
+ * Cancelar un push programado propio.
377
+ * @param {Object} opts
378
+ * @param {string} opts.publicKey
379
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign
380
+ * @param {number} opts.jobId
381
+ */
382
+ async cancelScheduledPush ({ publicKey, sign, jobId } = {}) {
383
+ if (!publicKey || typeof sign !== 'function') throw new Error('cancelScheduledPush requires { publicKey, sign }')
384
+ const data = { op: 'cancel-push', publickey: publicKey, jobId, ts: Date.now() }
385
+ const signature = await normalizeSignature(sign, data)
386
+ const res = await this._request({ type: 'cancel-push', data, signature }, 'push-canceled')
387
+ return res.jobId
388
+ }
389
+
390
+ /**
391
+ * Listar los push programados propios.
392
+ * @returns {Promise<Array<{ jobId:number, nextFire:number, cron:string|null, tz:string|null, payload:object|null }>>}
393
+ */
394
+ async listScheduledPushes ({ publicKey, sign } = {}) {
395
+ if (!publicKey || typeof sign !== 'function') throw new Error('listScheduledPushes requires { publicKey, sign }')
396
+ const data = { op: 'list-pushes', publickey: publicKey, ts: Date.now() }
397
+ const signature = await normalizeSignature(sign, data)
398
+ const res = await this._request({ type: 'list-pushes', data, signature }, 'push-list')
399
+ return res.jobs || []
400
+ }
401
+
402
+ /** Tear down the logical pair with a peer (both sides get notified). */
403
+ async disconnectFrom (targetToken) {
404
+ return this._request(
405
+ { type: 'disconnect', target: targetToken },
406
+ 'disconnect_confirmation', 'target'
407
+ )
408
+ }
409
+
410
+ /** Public key in JWK string form, useful as a stable identity. */
411
+ getPublicKey () {
412
+ return getPublicKeyJwk()
413
+ }
414
+
415
+ /** Sign arbitrary data with the local private key (base64 signature). */
416
+ sign (data) {
417
+ return signData(data)
418
+ }
419
+
420
+ // ---------- internals ----------
421
+
422
+ _open () {
423
+ const ws = new WebSocket(this.url)
424
+ this.ws = ws
425
+ // `ws !== this.ws` ⇒ es un socket que ya abandonamos (p.ej. por heartbeat
426
+ // muerto). Ignoramos sus eventos tardíos para no disparar reconexiones dobles.
427
+ ws.addEventListener('open', () => {
428
+ if (ws !== this.ws) return
429
+ this._connected = true
430
+ this._reconnectAttempts = 0
431
+ this._emit('connect')
432
+ this._startHeartbeat()
433
+ })
434
+ ws.addEventListener('message', (ev) => {
435
+ if (ws !== this.ws) return
436
+ this._noteActivity() // cualquier frame entrante prueba que está vivo
437
+ this._handleFrame(ev.data)
438
+ })
439
+ ws.addEventListener('error', (err) => {
440
+ if (ws !== this.ws) return
441
+ this._emit('error', { type: 'transport', error: err })
442
+ if (this._connectReject) {
443
+ this._connectReject(err)
444
+ this._connectResolve = null
445
+ this._connectReject = null
446
+ }
447
+ })
448
+ ws.addEventListener('close', (ev) => {
449
+ if (ws !== this.ws) return
450
+ this._stopHeartbeat()
451
+ const wasConnected = this._connected
452
+ this._connected = false
453
+ this._emit('disconnect', { code: ev.code, reason: ev.reason })
454
+ if (wasConnected && this.autoReconnect && ev.code !== 1000) {
455
+ this._scheduleReconnect()
456
+ }
457
+ })
458
+ }
459
+
460
+ // ---------- heartbeat ----------
461
+
462
+ _startHeartbeat () {
463
+ if (!this.enableHeartbeat) return
464
+ this._stopHeartbeat()
465
+ this._hbTimer = setInterval(() => this._heartbeatTick(), this.heartbeatInterval)
466
+ }
467
+
468
+ _stopHeartbeat () {
469
+ if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null }
470
+ if (this._hbDeadTimer) { clearTimeout(this._hbDeadTimer); this._hbDeadTimer = null }
471
+ }
472
+
473
+ // Llamado en cada frame entrante: si estábamos esperando un pong, llegó algo ⇒ vivo.
474
+ _noteActivity () {
475
+ if (this._hbDeadTimer) { clearTimeout(this._hbDeadTimer); this._hbDeadTimer = null }
476
+ }
477
+
478
+ _heartbeatTick () {
479
+ if (!this._connected || !this.ws) return
480
+ if (this._hbDeadTimer) return // ya hay un ping en vuelo esperando respuesta
481
+ try { this.ws.send(JSON.stringify({ type: 'ping' })) }
482
+ catch (_) { this._onHeartbeatDead(); return }
483
+ this._hbDeadTimer = setTimeout(() => this._onHeartbeatDead(), this.heartbeatTimeout)
484
+ }
485
+
486
+ // No llegó respuesta al ping ⇒ conexión half-open. Abandonamos el socket y
487
+ // forzamos la reconexión sin esperar el `close` (que en half-open puede no llegar).
488
+ _onHeartbeatDead () {
489
+ this._stopHeartbeat()
490
+ const dead = this.ws
491
+ this.ws = null // a partir de acá, los eventos tardíos de `dead` se ignoran
492
+ const wasConnected = this._connected
493
+ this._connected = false
494
+ this._emit('error', { type: 'heartbeat_timeout' })
495
+ this._emit('disconnect', { code: 4000, reason: 'heartbeat timeout' })
496
+ try { if (dead) dead.close() } catch (_) {}
497
+ if (wasConnected && this.autoReconnect) this._scheduleReconnect()
498
+ }
499
+
500
+ _scheduleReconnect () {
501
+ if (this._reconnectAttempts >= this.maxReconnectAttempts) {
502
+ this._emit('reconnect_failed', this._reconnectAttempts)
503
+ return
504
+ }
505
+ this._reconnectAttempts++
506
+ this._emit('reconnecting', this._reconnectAttempts, this.maxReconnectAttempts)
507
+ this._reconnectTimer = setTimeout(() => this._open(), this.reconnectDelay)
508
+ }
509
+
510
+ _handleFrame (raw) {
511
+ let data
512
+ try { data = JSON.parse(raw) } catch (e) {
513
+ this._emit('error', { type: 'parse_error', error: e })
514
+ return
515
+ }
516
+ const { type } = data
517
+ switch (type) {
518
+ case 'connected':
519
+ this.token = data.token
520
+ this._emit('token', this.token)
521
+ if (this._connectResolve) {
522
+ this._connectResolve(this.token)
523
+ this._connectResolve = null
524
+ this._connectReject = null
525
+ }
526
+ break
527
+ case 'message': {
528
+ const { from, message, timestamp, from_publickey, queued, queued_at } = data
529
+ let parsed = null
530
+ if (typeof message === 'string') {
531
+ try { parsed = JSON.parse(message) } catch (_) { parsed = null }
532
+ }
533
+ if (this._rtc && parsed && parsed.t === RTC_TAG) {
534
+ this._rtc.handleIncoming(from, parsed)
535
+ break
536
+ }
537
+ this._emit('message', from, parsed ?? message, {
538
+ raw: message, timestamp, via: 'proxy',
539
+ fromPubkey: from_publickey || null,
540
+ queued: !!queued,
541
+ queuedAt: queued_at || null
542
+ })
543
+ break
544
+ }
545
+ case 'disconnected':
546
+ this._emit('peer_disconnected', data.token, data.channel || null)
547
+ if (this._rtc && data.token) this._rtc.closePeer(data.token)
548
+ this._resolvePending(data, 'token')
549
+ break
550
+ case 'joined':
551
+ this._emit('channel_joined', data.channel, data.token)
552
+ break
553
+ case 'left':
554
+ this._emit('channel_left', data.channel, data.token)
555
+ break
556
+ case 'published':
557
+ case 'unpublished':
558
+ case 'watched':
559
+ case 'unwatched':
560
+ case 'channel_list':
561
+ case 'channels_list':
562
+ case 'channel_count':
563
+ case 'disconnect_confirmation':
564
+ case 'identified':
565
+ case 'message_sent':
566
+ case 'push-config':
567
+ case 'push-subscribed':
568
+ case 'push-unsubscribed':
569
+ case 'push-scheduled':
570
+ case 'push-canceled':
571
+ case 'push-list':
572
+ this._resolvePending(data, type)
573
+ break
574
+ case 'error':
575
+ this._emit('error', {
576
+ type: 'server',
577
+ error: data.error,
578
+ id: data.id,
579
+ messageId: data.messageId,
580
+ limit_level: data.limit_level,
581
+ limit_type: data.limit_type,
582
+ retry_after_ms: data.retry_after_ms,
583
+ operation: data.operation
584
+ })
585
+ this._rejectPending(data)
586
+ break
587
+ case 'abuse_notice':
588
+ this._emit('abuse_notice', {
589
+ from: data.from,
590
+ operation: data.operation,
591
+ severity: data.severity,
592
+ timestamp: data.timestamp
593
+ })
594
+ break
595
+ case 'pong':
596
+ // keepalive: la actividad ya se registró en _noteActivity; nada más que hacer.
597
+ break
598
+ default:
599
+ this._emit('unknown', data)
600
+ }
601
+ }
602
+
603
+ _sendRaw (frame) {
604
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
605
+ throw new Error('WebSocket not connected')
606
+ }
607
+ this.ws.send(JSON.stringify(frame))
608
+ }
609
+
610
+ _request (frame, expectedType, channelKey) {
611
+ return new Promise((resolve, reject) => {
612
+ const id = `req_${this._nextId++}`
613
+ const out = { ...frame, id }
614
+ const timer = setTimeout(() => {
615
+ this._pending.delete(id)
616
+ reject(new Error(`Timeout waiting for ${expectedType}`))
617
+ }, 10000)
618
+ this._pending.set(id, { resolve, reject, timer, expectedType, channelKey })
619
+ try {
620
+ this._sendRaw(out)
621
+ } catch (e) {
622
+ clearTimeout(timer)
623
+ this._pending.delete(id)
624
+ reject(e)
625
+ }
626
+ })
627
+ }
628
+
629
+ _resolvePending (data, actualType) {
630
+ const id = data.id
631
+ if (!id || !this._pending.has(id)) return
632
+ const entry = this._pending.get(id)
633
+ if (entry.expectedType && entry.expectedType !== actualType) return
634
+ clearTimeout(entry.timer)
635
+ this._pending.delete(id)
636
+ entry.resolve(data)
637
+ }
638
+
639
+ _rejectPending (data) {
640
+ const id = data.id
641
+ if (!id || !this._pending.has(id)) return
642
+ const entry = this._pending.get(id)
643
+ clearTimeout(entry.timer)
644
+ this._pending.delete(id)
645
+ entry.reject(new Error(data.error || 'Server error'))
646
+ }
647
+
648
+ _emit (event, ...args) {
649
+ const set = this._handlers.get(event)
650
+ if (!set) return
651
+ for (const h of set) {
652
+ try { h(...args) } catch (e) { console.error('handler error', e) }
653
+ }
654
+ }
655
+ }
656
+
657
+ // El callback de firma del vault devuelve `string` o `{ signature }` (id.signData
658
+ // devuelve un objeto). Normalizamos a string base64.
659
+ async function normalizeSignature (sign, data) {
660
+ const out = await sign(data)
661
+ const sig = typeof out === 'string' ? out : (out && out.signature)
662
+ if (!sig) throw new Error('sign() debe devolver una firma base64 (string o {signature})')
663
+ return sig
664
+ }
665
+
666
+ // Convierte la VAPID pública (base64url) al Uint8Array que espera
667
+ // pushManager.subscribe({ applicationServerKey }).
668
+ function urlBase64ToUint8Array (base64String) {
669
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
670
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
671
+ const raw = atob(base64)
672
+ const output = new Uint8Array(raw.length)
673
+ for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i)
674
+ return output
675
+ }
@@ -0,0 +1,16 @@
1
+ export { WebSocketProxyClient } from './client.js'
2
+ export { canonicalStringify } from './canonical.js'
3
+ export { getPublicKeyJwk, signData, buildSignedChannel } from './signature.js'
4
+
5
+ import { WebSocketProxyClient } from './client.js'
6
+
7
+ let _singleton = null
8
+ /**
9
+ * Singleton helper. Returns the same instance across calls.
10
+ * Useful in apps that want a single global client.
11
+ */
12
+ export function getWebSocketProxyClient (options) {
13
+ if (!_singleton) _singleton = new WebSocketProxyClient(options)
14
+ else if (options) _singleton.updateConfig(options)
15
+ return _singleton
16
+ }