@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.
- package/README.md +4 -2
- package/package.json +4 -1
- package/src/index.js +56 -3
- package/src/node.js +10 -1
- package/vault/capabilities.js +24 -0
- package/vault/core.js +60 -1
- package/vault/index.html +5 -0
- package/vault/remote.js +128 -0
- package/vault/vault.js +5 -7
- package/vault/vendor/proxy-client/VERSION.txt +4 -0
- package/vault/vendor/proxy-client/canonical.js +15 -0
- package/vault/vendor/proxy-client/client.js +673 -0
- package/vault/vendor/proxy-client/index.js +16 -0
- package/vault/vendor/proxy-client/signature.js +92 -0
- package/vault/vendor/proxy-client/webrtc.js +246 -0
|
@@ -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
|
+
}
|