@dotrino/identity 0.10.0 → 0.11.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,673 @@
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 }) {
237
+ if (!data || !signature) throw new Error('identify requires {data, signature}')
238
+ return this._request({ type: 'identify', data, signature }, 'identified')
239
+ }
240
+
241
+ /**
242
+ * Consultar la config de Web Push del proxy.
243
+ * @returns {Promise<{enabled:boolean, vapidPublicKey:string|null}>}
244
+ */
245
+ async getPushConfig () {
246
+ const res = await this._request({ type: 'push-config' }, 'push-config')
247
+ return { enabled: !!res.enabled, vapidPublicKey: res.vapidPublicKey || null }
248
+ }
249
+
250
+ /**
251
+ * Activar Web Push ("timbre" para mensajes offline). Registra el Service
252
+ * Worker, crea la PushSubscription (VAPID) y la registra en el proxy bajo la
253
+ * MISMA publickey usada en `identify` (la del vault), con un sobre firmado por
254
+ * el vault — igual patrón que identify.
255
+ *
256
+ * No usa el SDK de Firebase: solo Web Push estándar. El push no transporta
257
+ * contenido de usuario; despierta al SW para que reconecte y baje la cola.
258
+ *
259
+ * Resolución del Service Worker (en orden):
260
+ * - `registration`: usa esa ServiceWorkerRegistration directamente.
261
+ * - `swPath`: registra ese archivo (apps sin SW propio).
262
+ * - ninguno: usa el SW ya registrado por la app (`navigator.serviceWorker.ready`).
263
+ * Esto último es lo correcto para PWAs que ya tienen su propio SW (p.ej. con
264
+ * vite-plugin-pwa/Workbox): inyectá los handlers de push en ese SW con
265
+ * `importScripts` y llamá enablePush() sin swPath para no clobbear el scope.
266
+ *
267
+ * @param {Object} opts
268
+ * @param {string} opts.publicKey Pubkey JWK string del vault (la de identify).
269
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault (id.signData).
270
+ * @param {string} [opts.vapidPublicKey] VAPID pública; si falta se pide al proxy.
271
+ * @param {ServiceWorkerRegistration} [opts.registration] SW ya registrado a reutilizar.
272
+ * @param {string} [opts.swPath] Ruta de un SW a registrar (apps sin SW propio).
273
+ * @param {string} [opts.swScope] Scope del SW (solo con swPath).
274
+ * @returns {Promise<PushSubscription>}
275
+ */
276
+ async enablePush ({ publicKey, sign, vapidPublicKey, registration, swPath, swScope } = {}) {
277
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
278
+ throw new Error('Service Worker no soportado en este entorno')
279
+ }
280
+ if (typeof PushManager === 'undefined') {
281
+ throw new Error('Push API no soportada en este navegador')
282
+ }
283
+ if (!publicKey || typeof sign !== 'function') {
284
+ throw new Error('enablePush requires { publicKey, sign }')
285
+ }
286
+ if (!vapidPublicKey) {
287
+ const cfg = await this.getPushConfig()
288
+ if (!cfg.enabled || !cfg.vapidPublicKey) throw new Error('El proxy no tiene Web Push habilitado')
289
+ vapidPublicKey = cfg.vapidPublicKey
290
+ }
291
+ let reg
292
+ if (registration) {
293
+ reg = registration
294
+ } else if (swPath) {
295
+ await navigator.serviceWorker.register(swPath, swScope ? { scope: swScope } : undefined)
296
+ reg = await navigator.serviceWorker.ready
297
+ } else {
298
+ // PWA con SW propio: reutilizar el registrado por la app.
299
+ reg = await navigator.serviceWorker.ready
300
+ }
301
+ let sub = await reg.pushManager.getSubscription()
302
+ if (!sub) {
303
+ sub = await reg.pushManager.subscribe({
304
+ userVisibleOnly: true,
305
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
306
+ })
307
+ }
308
+ const subJson = typeof sub.toJSON === 'function' ? sub.toJSON() : sub
309
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now() }
310
+ const signature = await normalizeSignature(sign, data)
311
+ await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
312
+ return sub
313
+ }
314
+
315
+ /**
316
+ * Desactivar Web Push: cancela la PushSubscription local y la borra del proxy.
317
+ * @param {Object} opts
318
+ * @param {string} opts.publicKey Pubkey JWK string del vault.
319
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault.
320
+ * @param {ServiceWorkerRegistration} [opts.registration] SW a usar (default: el activo).
321
+ * @param {string} [opts.swPath] Ruta del SW si se registró uno propio.
322
+ */
323
+ async disablePush ({ publicKey, sign, registration, swPath } = {}) {
324
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
325
+ try {
326
+ const reg = registration ||
327
+ (swPath ? await navigator.serviceWorker.getRegistration(swPath)
328
+ : await navigator.serviceWorker.ready)
329
+ const sub = reg && await reg.pushManager.getSubscription()
330
+ if (sub) await sub.unsubscribe()
331
+ } catch (_) { /* best-effort local */ }
332
+ }
333
+ if (publicKey && typeof sign === 'function') {
334
+ const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now() }
335
+ const signature = await normalizeSignature(sign, data)
336
+ await this._request({ type: 'push-unsubscribe', data, signature }, 'push-unsubscribed')
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Programar un push a la PROPIA pubkey (auto-recordatorio). El proxy lo
342
+ * dispara a la hora indicada, aunque la app esté cerrada (vía el SW). Es
343
+ * self-only: el target es siempre la pubkey que firma (no se puede programar
344
+ * a terceros). One-shot (`when`) o recurrente (`cron` + `tz`).
345
+ *
346
+ * @param {Object} opts
347
+ * @param {string} opts.publicKey Pubkey JWK string del vault.
348
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign Firma del vault.
349
+ * @param {Date|number} [opts.when] One-shot: instante futuro (Date o epoch ms).
350
+ * @param {string} [opts.cron] Recurrente: expresión cron (5 campos).
351
+ * @param {string} [opts.tz] Timezone IANA para el cron (ej. 'America/Argentina/Buenos_Aires').
352
+ * @param {object} [opts.payload] Datos extra opcionales para la notificación (ej. { title }).
353
+ * @returns {Promise<{ jobId:number, nextFire:number }>}
354
+ */
355
+ async schedulePush ({ publicKey, sign, when, cron, tz, payload } = {}) {
356
+ if (!publicKey || typeof sign !== 'function') throw new Error('schedulePush requires { publicKey, sign }')
357
+ const spec = {}
358
+ if (cron) {
359
+ spec.cron = cron
360
+ if (tz) spec.tz = tz
361
+ } else {
362
+ const fireAt = when instanceof Date ? when.getTime() : Number(when)
363
+ if (!Number.isFinite(fireAt)) throw new Error('schedulePush requires { when } (Date|ms) or { cron }')
364
+ spec.fireAt = fireAt
365
+ }
366
+ if (payload) spec.payload = payload
367
+ const data = { op: 'schedule-push', publickey: publicKey, spec: JSON.stringify(spec), ts: Date.now() }
368
+ const signature = await normalizeSignature(sign, data)
369
+ const res = await this._request({ type: 'schedule-push', data, signature }, 'push-scheduled')
370
+ return { jobId: res.jobId, nextFire: res.nextFire }
371
+ }
372
+
373
+ /**
374
+ * Cancelar un push programado propio.
375
+ * @param {Object} opts
376
+ * @param {string} opts.publicKey
377
+ * @param {(data:any)=>Promise<string|{signature:string}>} opts.sign
378
+ * @param {number} opts.jobId
379
+ */
380
+ async cancelScheduledPush ({ publicKey, sign, jobId } = {}) {
381
+ if (!publicKey || typeof sign !== 'function') throw new Error('cancelScheduledPush requires { publicKey, sign }')
382
+ const data = { op: 'cancel-push', publickey: publicKey, jobId, ts: Date.now() }
383
+ const signature = await normalizeSignature(sign, data)
384
+ const res = await this._request({ type: 'cancel-push', data, signature }, 'push-canceled')
385
+ return res.jobId
386
+ }
387
+
388
+ /**
389
+ * Listar los push programados propios.
390
+ * @returns {Promise<Array<{ jobId:number, nextFire:number, cron:string|null, tz:string|null, payload:object|null }>>}
391
+ */
392
+ async listScheduledPushes ({ publicKey, sign } = {}) {
393
+ if (!publicKey || typeof sign !== 'function') throw new Error('listScheduledPushes requires { publicKey, sign }')
394
+ const data = { op: 'list-pushes', publickey: publicKey, ts: Date.now() }
395
+ const signature = await normalizeSignature(sign, data)
396
+ const res = await this._request({ type: 'list-pushes', data, signature }, 'push-list')
397
+ return res.jobs || []
398
+ }
399
+
400
+ /** Tear down the logical pair with a peer (both sides get notified). */
401
+ async disconnectFrom (targetToken) {
402
+ return this._request(
403
+ { type: 'disconnect', target: targetToken },
404
+ 'disconnect_confirmation', 'target'
405
+ )
406
+ }
407
+
408
+ /** Public key in JWK string form, useful as a stable identity. */
409
+ getPublicKey () {
410
+ return getPublicKeyJwk()
411
+ }
412
+
413
+ /** Sign arbitrary data with the local private key (base64 signature). */
414
+ sign (data) {
415
+ return signData(data)
416
+ }
417
+
418
+ // ---------- internals ----------
419
+
420
+ _open () {
421
+ const ws = new WebSocket(this.url)
422
+ this.ws = ws
423
+ // `ws !== this.ws` ⇒ es un socket que ya abandonamos (p.ej. por heartbeat
424
+ // muerto). Ignoramos sus eventos tardíos para no disparar reconexiones dobles.
425
+ ws.addEventListener('open', () => {
426
+ if (ws !== this.ws) return
427
+ this._connected = true
428
+ this._reconnectAttempts = 0
429
+ this._emit('connect')
430
+ this._startHeartbeat()
431
+ })
432
+ ws.addEventListener('message', (ev) => {
433
+ if (ws !== this.ws) return
434
+ this._noteActivity() // cualquier frame entrante prueba que está vivo
435
+ this._handleFrame(ev.data)
436
+ })
437
+ ws.addEventListener('error', (err) => {
438
+ if (ws !== this.ws) return
439
+ this._emit('error', { type: 'transport', error: err })
440
+ if (this._connectReject) {
441
+ this._connectReject(err)
442
+ this._connectResolve = null
443
+ this._connectReject = null
444
+ }
445
+ })
446
+ ws.addEventListener('close', (ev) => {
447
+ if (ws !== this.ws) return
448
+ this._stopHeartbeat()
449
+ const wasConnected = this._connected
450
+ this._connected = false
451
+ this._emit('disconnect', { code: ev.code, reason: ev.reason })
452
+ if (wasConnected && this.autoReconnect && ev.code !== 1000) {
453
+ this._scheduleReconnect()
454
+ }
455
+ })
456
+ }
457
+
458
+ // ---------- heartbeat ----------
459
+
460
+ _startHeartbeat () {
461
+ if (!this.enableHeartbeat) return
462
+ this._stopHeartbeat()
463
+ this._hbTimer = setInterval(() => this._heartbeatTick(), this.heartbeatInterval)
464
+ }
465
+
466
+ _stopHeartbeat () {
467
+ if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null }
468
+ if (this._hbDeadTimer) { clearTimeout(this._hbDeadTimer); this._hbDeadTimer = null }
469
+ }
470
+
471
+ // Llamado en cada frame entrante: si estábamos esperando un pong, llegó algo ⇒ vivo.
472
+ _noteActivity () {
473
+ if (this._hbDeadTimer) { clearTimeout(this._hbDeadTimer); this._hbDeadTimer = null }
474
+ }
475
+
476
+ _heartbeatTick () {
477
+ if (!this._connected || !this.ws) return
478
+ if (this._hbDeadTimer) return // ya hay un ping en vuelo esperando respuesta
479
+ try { this.ws.send(JSON.stringify({ type: 'ping' })) }
480
+ catch (_) { this._onHeartbeatDead(); return }
481
+ this._hbDeadTimer = setTimeout(() => this._onHeartbeatDead(), this.heartbeatTimeout)
482
+ }
483
+
484
+ // No llegó respuesta al ping ⇒ conexión half-open. Abandonamos el socket y
485
+ // forzamos la reconexión sin esperar el `close` (que en half-open puede no llegar).
486
+ _onHeartbeatDead () {
487
+ this._stopHeartbeat()
488
+ const dead = this.ws
489
+ this.ws = null // a partir de acá, los eventos tardíos de `dead` se ignoran
490
+ const wasConnected = this._connected
491
+ this._connected = false
492
+ this._emit('error', { type: 'heartbeat_timeout' })
493
+ this._emit('disconnect', { code: 4000, reason: 'heartbeat timeout' })
494
+ try { if (dead) dead.close() } catch (_) {}
495
+ if (wasConnected && this.autoReconnect) this._scheduleReconnect()
496
+ }
497
+
498
+ _scheduleReconnect () {
499
+ if (this._reconnectAttempts >= this.maxReconnectAttempts) {
500
+ this._emit('reconnect_failed', this._reconnectAttempts)
501
+ return
502
+ }
503
+ this._reconnectAttempts++
504
+ this._emit('reconnecting', this._reconnectAttempts, this.maxReconnectAttempts)
505
+ this._reconnectTimer = setTimeout(() => this._open(), this.reconnectDelay)
506
+ }
507
+
508
+ _handleFrame (raw) {
509
+ let data
510
+ try { data = JSON.parse(raw) } catch (e) {
511
+ this._emit('error', { type: 'parse_error', error: e })
512
+ return
513
+ }
514
+ const { type } = data
515
+ switch (type) {
516
+ case 'connected':
517
+ this.token = data.token
518
+ this._emit('token', this.token)
519
+ if (this._connectResolve) {
520
+ this._connectResolve(this.token)
521
+ this._connectResolve = null
522
+ this._connectReject = null
523
+ }
524
+ break
525
+ case 'message': {
526
+ const { from, message, timestamp, from_publickey, queued, queued_at } = data
527
+ let parsed = null
528
+ if (typeof message === 'string') {
529
+ try { parsed = JSON.parse(message) } catch (_) { parsed = null }
530
+ }
531
+ if (this._rtc && parsed && parsed.t === RTC_TAG) {
532
+ this._rtc.handleIncoming(from, parsed)
533
+ break
534
+ }
535
+ this._emit('message', from, parsed ?? message, {
536
+ raw: message, timestamp, via: 'proxy',
537
+ fromPubkey: from_publickey || null,
538
+ queued: !!queued,
539
+ queuedAt: queued_at || null
540
+ })
541
+ break
542
+ }
543
+ case 'disconnected':
544
+ this._emit('peer_disconnected', data.token, data.channel || null)
545
+ if (this._rtc && data.token) this._rtc.closePeer(data.token)
546
+ this._resolvePending(data, 'token')
547
+ break
548
+ case 'joined':
549
+ this._emit('channel_joined', data.channel, data.token)
550
+ break
551
+ case 'left':
552
+ this._emit('channel_left', data.channel, data.token)
553
+ break
554
+ case 'published':
555
+ case 'unpublished':
556
+ case 'watched':
557
+ case 'unwatched':
558
+ case 'channel_list':
559
+ case 'channels_list':
560
+ case 'channel_count':
561
+ case 'disconnect_confirmation':
562
+ case 'identified':
563
+ case 'message_sent':
564
+ case 'push-config':
565
+ case 'push-subscribed':
566
+ case 'push-unsubscribed':
567
+ case 'push-scheduled':
568
+ case 'push-canceled':
569
+ case 'push-list':
570
+ this._resolvePending(data, type)
571
+ break
572
+ case 'error':
573
+ this._emit('error', {
574
+ type: 'server',
575
+ error: data.error,
576
+ id: data.id,
577
+ messageId: data.messageId,
578
+ limit_level: data.limit_level,
579
+ limit_type: data.limit_type,
580
+ retry_after_ms: data.retry_after_ms,
581
+ operation: data.operation
582
+ })
583
+ this._rejectPending(data)
584
+ break
585
+ case 'abuse_notice':
586
+ this._emit('abuse_notice', {
587
+ from: data.from,
588
+ operation: data.operation,
589
+ severity: data.severity,
590
+ timestamp: data.timestamp
591
+ })
592
+ break
593
+ case 'pong':
594
+ // keepalive: la actividad ya se registró en _noteActivity; nada más que hacer.
595
+ break
596
+ default:
597
+ this._emit('unknown', data)
598
+ }
599
+ }
600
+
601
+ _sendRaw (frame) {
602
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
603
+ throw new Error('WebSocket not connected')
604
+ }
605
+ this.ws.send(JSON.stringify(frame))
606
+ }
607
+
608
+ _request (frame, expectedType, channelKey) {
609
+ return new Promise((resolve, reject) => {
610
+ const id = `req_${this._nextId++}`
611
+ const out = { ...frame, id }
612
+ const timer = setTimeout(() => {
613
+ this._pending.delete(id)
614
+ reject(new Error(`Timeout waiting for ${expectedType}`))
615
+ }, 10000)
616
+ this._pending.set(id, { resolve, reject, timer, expectedType, channelKey })
617
+ try {
618
+ this._sendRaw(out)
619
+ } catch (e) {
620
+ clearTimeout(timer)
621
+ this._pending.delete(id)
622
+ reject(e)
623
+ }
624
+ })
625
+ }
626
+
627
+ _resolvePending (data, actualType) {
628
+ const id = data.id
629
+ if (!id || !this._pending.has(id)) return
630
+ const entry = this._pending.get(id)
631
+ if (entry.expectedType && entry.expectedType !== actualType) return
632
+ clearTimeout(entry.timer)
633
+ this._pending.delete(id)
634
+ entry.resolve(data)
635
+ }
636
+
637
+ _rejectPending (data) {
638
+ const id = data.id
639
+ if (!id || !this._pending.has(id)) return
640
+ const entry = this._pending.get(id)
641
+ clearTimeout(entry.timer)
642
+ this._pending.delete(id)
643
+ entry.reject(new Error(data.error || 'Server error'))
644
+ }
645
+
646
+ _emit (event, ...args) {
647
+ const set = this._handlers.get(event)
648
+ if (!set) return
649
+ for (const h of set) {
650
+ try { h(...args) } catch (e) { console.error('handler error', e) }
651
+ }
652
+ }
653
+ }
654
+
655
+ // El callback de firma del vault devuelve `string` o `{ signature }` (id.signData
656
+ // devuelve un objeto). Normalizamos a string base64.
657
+ async function normalizeSignature (sign, data) {
658
+ const out = await sign(data)
659
+ const sig = typeof out === 'string' ? out : (out && out.signature)
660
+ if (!sig) throw new Error('sign() debe devolver una firma base64 (string o {signature})')
661
+ return sig
662
+ }
663
+
664
+ // Convierte la VAPID pública (base64url) al Uint8Array que espera
665
+ // pushManager.subscribe({ applicationServerKey }).
666
+ function urlBase64ToUint8Array (base64String) {
667
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
668
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
669
+ const raw = atob(base64)
670
+ const output = new Uint8Array(raw.length)
671
+ for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i)
672
+ return output
673
+ }
@@ -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
+ }