npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dotrino/proxy-client

v0.40.0

Published

Cliente WebSocket para el proxy de Dotrino (tokens, canales, firma ECDSA, Web Push)

Readme

@dotrino/proxy-client

Parte del ecosistema Dotrino. Dotrino es un ecosistema de aplicaciones centradas en la privacidad de los datos: tu información es tuya, y las decisiones sobre ella también — qué compartes, con quién, cuándo y por qué. Sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.

Cliente WebSocket para el proxy de Dotrino. Maneja la conexión, el token efímero, mensajes peer-to-peer y canales públicos firmados con ECDSA P-256.

Instalación

npm install @dotrino/proxy-client

Uso

import { WebSocketProxyClient } from '@dotrino/proxy-client'

const client = new WebSocketProxyClient({ url: 'wss://proxy.dotrino.com' })

client.on('token', (token) => console.log('mi token:', token))
client.on('message', (from, payload) => console.log('de', from, ':', payload))
client.on('channel_joined', (channel, token) => console.log(token, 'entró a', channel))
client.on('channel_left', (channel, token) => console.log(token, 'salió de', channel))

await client.connect()

// Publicar en un canal público (firmado con tu clave local)
await client.publish('chat_room_general')

// Listar miembros y canales
const tokens = await client.list('chat_room_general')
const channels = await client.listChannels({ prefix: 'chat_room_' })
const count = await client.channelCount('chat_room_general')

// Mensaje directo
client.send(['ABCD'], { type: 'hello', text: 'hi' })

// Cerrar pair lógico
await client.disconnectFrom('ABCD')

Transporte WebRTC (P2P) con fallback al proxy

Por defecto el cliente intenta abrir un RTCDataChannel con cada peer al que le envías mensajes. Si la negociación tiene éxito, los send() posteriores viajan directamente entre navegadores; si falla (NAT simétrico, etc.) se sigue usando el proxy de forma transparente.

const client = new WebSocketProxyClient({
  url: 'wss://proxy.dotrino.com',
  enableWebRTC: true,           // default
  // iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]  // override opcional
})

client.on('webrtc_open',  (token) => console.log('P2P abierto con', token))
client.on('webrtc_close', (token) => console.log('P2P cerrado con', token))

// Forzar handshake antes de enviar
await client.connectWebRTC('ABCD')
client.send('ABCD', { type: 'hello' })  // viaja P2P si está abierto

Notas:

  • Por defecto solo se usan servidores STUN públicos. Sin TURN, los pares en NAT simétrico se quedan en proxy; para atravesarlos activa TURN (sección siguiente).
  • La señalización (offer / answer / ICE) se transporta por el propio proxy como mensajes __cc_rtc__, así que no necesitas un canal extra.
  • Los handlers de 'message' reciben un tercer argumento con via: 'webrtc' | 'proxy' para distinguir el transporte si lo necesitas.
  • Pasa enableWebRTC: false para volver al comportamiento previo (todo por proxy).

Por token, con lo que la app ya cifró (0.29.0+)

sendByPubkey nunca sube a WebRTC: la cola es del proxio. Una app con su propio canal de sesión (cifra ella, como @dotrino/remote-agent) que quiera ir directo manda por token:

client.sendToOrQueue(tokenDelOtro, sobreYaCifrado, { peerPubkey })

Sube al canal directo si lo hay; si el token murió, el mismo mensaje sale por peerPubkey y salta token_gone. Con requireSealed se niega igual que send.

Mensajes grandes, en partes (0.33.1+; tope en vivo y routeTo, 0.34.0+)

sendSealed y sendSealedTo aceptan un mensaje de cualquier tamaño: lo que no cabe en un mensaje del camino que toca sale en partes selladas y la otra punta lo rearma antes de emitir message (con meta.parts). La app no trocea nada ni sabe por dónde fue.

| Camino | Parte | Tope del mensaje entero | |---|---|---| | por token, canal directo (WebRTC directo o por TURN) | 40 KB de JSON (libwebrtc rechaza > 64 KiB), esperando a que el búfer del canal baje | sin tope | | por token, proxio en vivo | 512 KB de JSON (cabe en la trama de 1 MB ya sellada) | 25 MB de archivo (RELAY_MAX_CHARS, ~34 MB de JSON) | | por pubkey (se queda esperando en el proxio) | 512 KB | 8 MB de archivo (QUEUE_MAX_CHARS, ~11 MB de JSON) |

Reglas del dueño (2026-10-15):

  • Por canal directo no hay tope.
  • Por el proxio en vivo, como mucho 25 MB: cada parte cruza el VPS dos veces. Más que eso lanza too-big-for-relay y pide el canal directo para el siguiente intento. Si el canal directo se cae a mitad de uno así, para con direct-lost.
  • Lo que pasa de 8 MB no se deja esperando en el proxio. Por pubkey lanza too-big-for-queue sin mandar nada; por token, si el otro se desconecta a mitad de uno así, no se manda el resto a la cola y se emite error con ese code.

routeTo(token) dice el camino de ahora (direct, turn, webrtc, proxy u offline; socket queda reservado para la otra punta en la misma máquina) para que la app enseñe el tope antes de que el usuario intente algo que no cabe. Los eventos webrtc_open y webrtc_close avisan cuando cambia.

  • Cada parte va sellada por su cuenta: el proxio no ve que es una parte ni de qué mensaje.
  • Se rearman agrupando por la llave que selló, en cualquier orden.
  • Una parte con forma mala emite error bad-part. Un mensaje al que le falta una parte cinco minutos emite error parts-incomplete: con ella se pierde el mensaje entero, y eso se dice.
  • Una versión anterior del pilar no entiende las partes: un mensaje que antes cabía en una sola trama sigue saliendo igual, así que solo lo grande necesita la versión nueva al otro lado.

Bytes que viajan como bytes (0.35.0+)

Un mensaje puede llevar Uint8Array dentro (un archivo, una foto). El pilar lo manda tal cual, sin base64:

await client.sendSealedTo(token, { type: 'DM', file: { name: 'foto.jpg', mime: 'image/jpeg', data: bytes } })
client.on('message', (from, msg) => msg.file.data instanceof Uint8Array) // true

Antes el archivo iba dos veces en base64 (en el JSON de la app y al sellar) y ocupaba 1,78 veces su tamaño en el cable. Ahora ocupa lo que pesa más unos cientos de bytes.

Cómo:

  • El paquete (pack/unpack, src/binpack.js): el objeto se escribe en JSON y cada Uint8Array se sustituye por una referencia a los bytes, que van detrás.
  • El sellado (sealing.js): el paquete se cifra como bytes con una CEK nueva (encryptBytesWithCek de @dotrino/identity ≥ 0.117.0), y lo que pasa por el sellado de siempre (el propio o la bóveda del navegador) es solo la CEK. Una CEK por mensaje y destinatario: la bóveda sella una vez aunque el mensaje vaya en cien partes.
  • La trama (encodeFrame/decodeFrame): por el proxio, una trama binaria con la cabecera JSON de siempre y el cuerpo en crudo; por el canal directo, los bytes del sobre.
  • Las partes y los topes son los de arriba, medidos en bytes del paquete: QUEUE_MAX_BYTES (8 MB de archivo), RELAY_MAX_BYTES (25 MB), partes de DIRECT_PART_BYTES (48 KB) y PROXY_PART_BYTES (768 KB).

Lo que no lleva bytes sale en texto, igual que antes: las piezas que no saben de esto (las apps nativas, versiones anteriores) siguen hablando con todos para lo de siempre. Un mensaje con bytes necesita un proxio que anuncie binary en sus caps (websocket-proxy ≥ 1.6.0); si no, se lanza no-binary-support antes de mandar nada.

Quien recibe dice si entiende bytes (0.36.0+): el saludo (helloTo) lleva speaks: ['binary'], y client.speaks(tokenOrPubkey) devuelve true, false o null (no ha saludado). A quien saludó sin decirlo (una versión anterior, una app nativa) no se le mandan bytes: se lanza peer-no-binary antes de mandar nada, para que la app lo diga. Si todavía no ha saludado no se sabe, y se manda. La idea del dueño es que todo pase a binario con el tiempo: esta lista es por donde se sabrá quién ya puede.

TURN temporal (0.7.0+)

El proxy actúa como administrador de credenciales TURN (Cloudflare): la llave de Cloudflare vive solo en el servidor y a los clientes se les entregan credenciales efímeras (TTL corto, cuota por pubkey/hora), únicamente en conexiones identificadas con el vault. Así el relay TURN solo lo usan apps Dotrino y no sirve como relay abierto para tráfico ajeno.

// 1) identify con el vault (igual que para la cola offline)
await client.identify({ data, signature })

// 2) activar TURN: pide credenciales, las inyecta como ICE servers y las renueva sola
const activo = await client.enableTurn({ publicKey, sign })   // false si el proxy no tiene TURN

// O manual, si quieres armar tu propio RTCPeerConnection:
const { enabled, iceServers, expiresAt } = await client.getTurnCredentials({ publicKey, sign })

Notas:

  • enableTurn() afecta a las conexiones P2P nuevas; los peers ya negociados conservan su configuración.
  • Si el proxy responde enabled: false (sin TURN configurado), todo sigue STUN-only con fallback al proxy: no hay que hacer nada.
  • El proxy exige identify previo en la misma conexión: tras una reconexión, vuelve a llamar a identify antes de que toque renovar.

Estadísticas de red (0.28.0+)

await client.stats() dice cuánto entró y salió, por conexión, y por qué camino:

const s = await client.stats()
// s.proxy  → { bytesIn, bytesOut, framesIn, framesOut }   el WebSocket del proxio entero
// s.peers  → [{ token, pubkey, route, bytesIn, bytesOut, msgsIn, msgsOut, lastAt }]
//   route:  'proxy' | 'connecting' | 'failed' | 'direct' | 'turn' | 'webrtc'
//   bytesIn / bytesOut: { proxy, direct, turn, webrtc }  — separados por el camino real
  • direct / turn sale del par de candidatos que eligió ICE (getStats): si una de las dos puntas es relay, hay TURN en medio. Si la implementación no lo dice, webrtc — no se adivina.
  • Son bytes de payload (UTF-8). Las cabeceras de TLS/DTLS/SCTP no se ven desde aquí.
  • La señalización de WebRTC y el saludo cuentan como proxy: es por donde van.

Lo enseña el topbar sin cablear nada (@dotrino/topbar ≥ 0.14.0): todo cliente que conecta se apunta en un registro de la página (globalThis[Symbol.for('dotrino.transports')], evento dotrino-transports) y sale al hacer close(). listTransports() lo lee. Va en globalThis y no en el módulo porque una app puede llevar dos copias del paquete.

En un servicio de Node: al log (0.30.0+)

Un daemon no tiene topbar. logStats() escribe lo mismo, una línea cada tanto, con el log que el servicio ya usa. Lee el mismo registro de clientes, así que no hay que pasarle ninguno:

import { logStats } from '@dotrino/proxy-client'

const net = logStats({ label: 'terminal-agent', log: console.log, everyMs: 5 * 60 * 1000 })
// al apagar: await net.stop()   (escribe una última vez)
[net] terminal-agent url=wss://proxy.dotrino.com connected=yes webrtc=on ws.in=5120 ws.out=4310 in.proxy=310 in.direct=48211 in.turn=0 in.webrtc=0 out.proxy=295 out.direct=910332 out.turn=0 out.webrtc=0 peers=1 routes=direct=1
[net] terminal-agent   peer=AB12 route=direct in.proxy=310 in.direct=48211 ... msgs.in=412 msgs.out=977
  • Solo escribe si algo cambió: un servicio quieto no llena el log.
  • Los contadores son acumulados desde que arrancó el cliente; para un tramo, se restan dos líneas. Que in.direct/out.direct suban es la confirmación de que el tráfico va por WebRTC; si solo sube *.proxy, sigue dando la vuelta.
  • De la otra punta sale solo el token (efímero), nunca la pubkey.

Para enseñárselo a una persona que lo pidió (una orden, una pantalla), netReport da el informe de texto que usan el modal del topbar y las apps nativas. Nombra cada aparato con lo que devuelva label (su id, su nombre); sin él sale ?, nunca un trozo de la llave:

import { listTransports, netReport } from '@dotrino/proxy-client'

const list = await Promise.all(listTransports().map((c) => c.stats()))
console.log(await netReport(list, { label: (pubkey) => nameOf(pubkey) }))

Cifrado extremo a extremo de mensajes dirigidos (0.13.0+)

El proxy no cifra el contenido. sendByPubkey enruta por pubkey y manda el payload tal cual, así que cualquier cosa sensible que viaje así la puede leer quien opere el proxy. Los canales públicos son públicos por diseño y esto no les aplica; los mensajes dirigidos sí deberían ir sellados.

import { makeEncKeypair } from '@dotrino/proxy-client'

// Un par de CIFRADO, aparte del de firma. Su pública se intercambia al emparejar.
const mio = await makeEncKeypair()          // { privateKey, encPub }

const client = new WebSocketProxyClient({
  url: 'wss://proxy.dotrino.com',
  requireSealed: true,                       // nada en claro, ni al enviar ni al recibir
  myEncPrivateKey: mio.privateKey,           // para abrir lo que me sellen
})

await client.sendSealed(['<pubkey-del-peer>'], { op: 'hola' }, { peerEncPub })

Con requireSealed: true:

  • sendByPubkey() con un payload sin sellar lanza (code: 'unsealed') en vez de enviarlo. Se usa sendSealed().
  • Lo que llega en claro se descarta y se emite error con { type: 'unsealed' }. Sellar solo de salida no basta: si la otra punta acepta texto plano, mandarlo así se salta el sellado entero, y alguien que nunca leyó nada podría colar un payload falso en la app.
  • Lo sellado a otro emite { type: 'undecipherable' } y no llega a la app.
  • Los message que sí llegan traen meta.sealed.

Está apagado por defecto para no romper las apps existentes, pero es lo que debería tener cualquier app que mande algo del usuario. La criptografía es la de @dotrino/identity/content (la misma de los secretos sellados del vault), declarada como peer dependency: empaquetarla aquí colaría una copia vieja del pilar en cada consumidor.

La llave del otro lado SE AVERIGUA (0.20.0+)

Hasta 0.19 sendSealed exigía peerEncPub y el pilar no daba ninguna forma de conseguirla. O sea que solo podían sellar dos puntas que se hubieran emparejado antes y se la hubieran intercambiado a mano — el gestor de contraseñas, y poco más. Una sala de desconocidos no puede hacer eso, así que seguía mandando en claro.

Desde 0.20.0 cada identidad anuncia su llave de cifrado con una frase firmada por la misma llave con la que se identifica en el cable:

{ v:1, op:'encpub', aud:'dotrino:encpub', publickey, encpub, ts }  + signature

El proxio se la queda y se la da a quien pregunte. Es un buzón, no una autoridad: devuelve el sobre entero y quien pregunta verifica la firma contra la pubkey a la que va a escribir. Si el proxio cambiara la llave por la suya para poder leer, la firma no cuadra y no sale nada — ni sellado ni en claro.

La receta, para migrar una app

1. Anunciar la propia llave. Una línea al construir el cliente; identify hace el resto solo, por detrás y sin bloquear.

// Navegador (la privada vive en la bóveda; la pública no es secreto).
import { getWebSocketProxyClient, identitySealing } from '@dotrino/proxy-client'

const client = getWebSocketProxyClient({
  url: 'wss://proxy.dotrino.com',
  requireSealed: true,
  myEncPub: await identity.getEncryptionPubkey(),
  sealing: identitySealing(identity, { app: 'mundial' }),   // 0.21.0+
})
await client.connect()
await client.identifyAs({ publickey: me.publickey, sign: (d) => identity.signData(d) })

identitySealing es el puente de la bóveda, y viene en el pilar desde 0.21.0: en el navegador la privada de cifrado no está en la app —vive dentro del iframe y no sale—, así que sellar y abrir se delegan en identity.encrypt / identity.decrypt, que es la misma cripto. Habla los dos dialectos de @dotrino/identity (la clase que habla con el iframe y el núcleo de dentro de un service worker) y comprueba algo que se cuela solo: si la bóveda envuelve el mensaje para nadie —la llave del otro no se pudo importar y el llavero sale vacío—, lanza con code: 'unsealed' en vez de mandar un sobre cifrado que no abre nadie.

Desde 0.25.0 el sobre llega a TODOS los aparatos del otro, y dice quién lo selló. sendSealed/sendSealedTo le pasan a la bóveda la publickey del destinatario, y encrypt expande el sobre a cada aparato de su tarjeta de perfil (antes solo lo abría el aparato que anunció la llave). Y lo que llega trae meta.senderEncPub: la llave de cifrado de quien selló. Solo quien tiene esa privada pudo armar el sobre, así que es lo que dice de quién viene — el token no lo dice, y el saludo no autentica. La app la compara con la que conoce de su contacto.

app es la marca del sobre: quien recibe lo que no es suyo lo descarta por ahí. Es estable por app y cambiarla es dejar de abrir lo de la versión anterior.

// Aparato headless (la privada es suya).
const mio = await makeEncKeypair()      // { privateKey, encPub }
const client = new WebSocketProxyClient({
  url, requireSealed: true,
  myEncPub: mio.encPub,
  myEncPrivateKey: mio.privateKey,
})

2. Mandar. Se quita el peerEncPub y ya está: el pilar lo averigua, lo verifica y lo cachea.

// Por pubkey (cola offline 24 h). UNA envoltura por destinatario.
await client.sendSealed([pubkeyDelOtro], { type: 'ROOM_INVITE', url })

// Por token, que es como hablan los de una sala y lo único que sube a WebRTC.
// El token NO dice de quién es: lo dice la app, con lo que ya sabe del canal,
// del saludo de la sala o de la invitación.
await client.sendSealedTo(token, { type: 'hit', enemyId }, { peerPubkey: pubkeyDelOtro })

3. Y si algo falla, mirar el code — nunca la frase (ver más abajo). Los tres significan cosas distintas y se arreglan de formas distintas:

| code | Qué pasó | Qué hacer | |---|---|---| | no-encpub | nadie ha anunciado llave para esa identidad | el otro lado tiene que actualizar; esperar no sirve | | encpub-unverified | llegó una llave que esa identidad no firmó | no se manda nada. Es el caso que importa | | no-encpub-support | el proxio es anterior a websocket-proxy 1.1.0 | actualizar el proxio | | unsealed | se intentó mandar en claro con requireSealed | usar sendSealed/sendSealedTo |

Nada de repliegues. Si no se puede sellar, no se manda en claro: se lanza. Con varios destinatarios se resuelven todas las llaves antes de mandar nada, para que no quede media sala con el mensaje y la otra media sin él.

Y DE QUIÉN ES ESTE TOKEN: el saludo (0.22.0+)

La llave se averigua a partir de la pubkey, y en una sala lo único que se tiene son tokens. Un token es una dirección del proxio y no dice de quién es, así que una sala de desconocidos se quedaba igual de muda: sabía a qué direcciones escribir y a ninguna identidad. Ese era el último tramo en claro.

helloTo lo cierra. Es una trama de control del transporte, hermana de la señalización de WebRTC: va en claro y requireSealed no la para, porque lo único que lleva es una llave pública que el proxio ya tiene atada a esa conexión desde identify. Nada del usuario pasa por ahí.

await client.identifyAs({ publickey: yo.publickey, sign })

client.on('peer_identity', (token, publickey) => {
  // Ya se le puede sellar. La llave de cifrado la resuelve el pilar solo.
  client.sendSealedTo(token, { type: 'JOIN', nick })
})

for (const t of await client.list('sala_general')) client.helloTo(t)   // saludar a los que ya estaban
client.on('channel_joined', (_ch, t) => client.helloTo(t))             // y a los que lleguen
  • Basta con que salude una punta: quien lo recibe contesta el suyo una vez, y peer_identity salta en los dos lados. La app no coreografía nada.
  • sendSealedTo ya no necesita peerPubkey para un solo token: lo saca del saludo. Si nadie ha saludado, no manda nada y lanza no-peer-identity — que es distinto de no-encpub (ése saludó, pero nunca anunció llave de cifrado).
  • Un token no cambia de dueño. Un segundo saludo con otra identidad se descarta y se avisa (hello_conflict): manda el primero.
  • Quien se va, se olvida (peer_disconnected). Los tokens no se reciclan.

No autentica, y no hace falta que lo haga. Mentir sobre la propia identidad solo consigue que te sellen a una llave que no puedes abrir: el embustero se queda sin leer y nadie se queda suplantado. Quién firma de verdad lo dice el reto de la app, o el from_publickey que pone el proxio al enrutar por pubkey.

Si ya sabes la llave, no preguntes

Los aparatos de un mismo dueño llevan su llave de cifrado escrita en el acta, firmada por el master — eso es más fuerte que el anuncio. Una app que tiene el acta enchufa:

import { memberEncPub } from '@dotrino/identity/acta'   // ≥ 0.90.0

const client = new WebSocketProxyClient({
  url, requireSealed: true, myEncPub, myEncPrivateKey,
  encPubResolver: (pub) => memberEncPub(acta, pub),      // null = «yo no sé» → pregunta al proxio
})

Lo que esto NO resuelve, dicho en voz alta

  • Sellar a una PERSONA (el profileId) y no a un aparato. Escribir a un profileId llega a todos sus aparatos, y cada uno tiene su propia llave de cifrado: eso es la tarjeta de perfil del acta (cardBody), no este directorio. Preguntar por un profileId que nunca se anunció da no-encpub, alto y claro.
  • Rotar la llave de cifrado. Si alguien la cambia, lo sellado a la anterior deja de abrirse y llega como { type: 'undecipherable' }. El anuncio lleva ts y el proxio nunca acepta uno más viejo que el que tiene, así que nadie puede hacerte retroceder; pero rotar sigue costando lo ya enviado.
  • Comparar pubkeys con ===. Un JWK serializado no es canónico. Para eso está samePubkey, que este paquete exporta y usa por dentro.

Identidad

Cada navegador genera y persiste un par ECDSA P-256 en localStorage (dotrino.proxy-client.keypair). La pública se incluye en cada operación de canal y sirve como identidad estable entre sesiones (no entre apps con orígenes distintos — para eso usa la librería de identidad).

En un service worker (0.12.0+) no hay localStorage, y hasta la 0.11.0 eso significaba que el par se regeneraba en cada llamada sin guardarse: el aparato cambiaba de identidad cada vez que el worker se dormía, y cualquier peer que lo conociera por su pubkey veía un desconocido. Ahora cae solo a IndexedDB, que sí existe en workers y guarda el CryptoKey tal cual — así la privada se queda no extraíble en vez de escribirse como JWK. No hay que configurar nada. Si necesitas otro almacén:

import { setKeypairStore } from '@dotrino/proxy-client'

// Un almacén que guarda el CryptoKey tal cual (IndexedDB): la privada se queda
// no extraíble.
setKeypairStore({ get: async () => saved, set: async (pair) => { saved = pair } })

// Un almacén que SERIALIZA (disco, texto, red) tiene que exportar la privada a JWK,
// y eso no se puede con una llave no extraíble. Hay que declararlo:
setKeypairStore(storeDeDisco, { extractable: true })

Si el almacén no puede guardar, el cliente lo dice por consola en vez de seguir en silencio: sin persistencia la identidad se regenera en cada arranque y los peers que conocen el aparato por su pubkey dejan de reconocerlo — un fallo que, callado, se descubre días después y desde el otro lado.

const pubkeyJwk = await client.getPublicKey()
const signature = await client.sign({ msg: 'hola' })

Cola offline + fan-out multi-instancia (0.4.0+)

Para mensajes que deben llegar aunque el destinatario esté offline, el proxy mantiene una cola por publickey. Para usarla, el cliente llama a identify() con un sobre firmado externamente (típicamente por el identity vault), y luego direcciona por to_publickey en lugar de to.

import { Identity } from '@dotrino/identity'

const id = await Identity.connect()
await client.connect()

// Bind: el proxy asocia mi pubkey con mi token actual
const data = { op: 'identify', publickey: id.me.publickey, token: client.token, ts: Date.now() }
const { signature } = await id.signData(data)
const result = await client.identify({ data, signature })
//   result = { publickey, queued_delivered: <N> }   ← N mensajes en cola que llegan al instante

// Enviar por pubkey: si el peer tiene 1+ instancias online, fan-out a todas;
// si no, queda en cola del proxy 24h y se entrega al primer reconnect.
client.sendByPubkey(['<peer-publickey-jwk>'], { type: 'dm', text: 'hola' })

En los handlers 'message', el tercer argumento incluye:

  • meta.via: 'webrtc' | 'proxy'
  • meta.fromPubkey: la pubkey del remitente (poblada cuando llegó por to_publickey).
  • meta.queued: true si venía de la cola offline.
  • meta.queuedAt: timestamp ISO de cuando se encoló.

Web Push — "timbre" para mensajes offline (0.5.0+)

Cuando un mensaje cae a la cola offline, el proxy puede mandar un Web Push (sin contenido de usuario) que despierta al Service Worker del destinatario para que reconecte y baje su cola cifrada. Usa Web Push estándar + VAPID — no el SDK de Firebase, ni JS de terceros, ni cookies. El push solo dice "despertá"; el contenido nunca pasa por el push service (en Android, FCM solo ve el metadato del timbre).

Requisitos: el proxy debe tener VAPID configurado, y la app debe tener un Service Worker.

Caso A — app sin SW propio: copiá node_modules/@dotrino/proxy-client/sw/dotrino-push-sw.js a tu carpeta pública y pasá swPath:

await client.enablePush({ publicKey, sign, swPath: '/dotrino-push-sw.js' })

Caso B — PWA con SW propio (vite-plugin-pwa/Workbox, etc.): NO registres un segundo SW (clobbearía el tuyo). En su lugar, inyecta los handlers de push en tu SW existente y llama a enablePush() sin swPath (usa el SW activo):

// vite.config.js → VitePWA({ workbox: { importScripts: ['dotrino-push-sw.js'] } })
// (copiá el SW a public/ para que importScripts lo encuentre)
await client.enablePush({ publicKey, sign })   // reutiliza navigator.serviceWorker.ready
import { Identity } from '@dotrino/identity'

const id = await Identity.connect()
await client.connect()

// (1) identify primero: el push se liga a la MISMA pubkey del vault.
const data = { op: 'identify', publickey: id.me.publickey, token: client.token, ts: Date.now() }
const { signature } = await id.signData(data)
await client.identify({ data, signature })

// (2) Activar push: crea la subscription y la registra (firmada por el vault)
//     en el proxy. La VAPID se pide sola si no la pasás. Para una PWA con SW
//     propio, omite swPath (usa el SW activo); ver "Caso B" arriba.
await client.enablePush({
  publicKey: id.me.publickey,
  sign: (d) => id.signData(d)        // mismo firmante que identify
})

// Desactivar (cancela local + borra del proxy):
await client.disablePush({ publicKey: id.me.publickey, sign: (d) => id.signData(d) })

El Service Worker, al recibir el timbre, hace postMessage({ type: 'cc-push-ring' }) a las ventanas abiertas (para que la app reconecte y drene la cola) y, si no hay ventana visible, muestra una notificación genérica. Al hacer click enfoca/abre la app. Personalizá título/cuerpo editando el archivo del SW.

Privacidad: vos nunca manejás la push-subscription de un contacto — solo su pubkey. "Mandarle un push" no es una acción aparte: es sendByPubkey(pubkeyDelAmigo, ...); si está offline, el proxy le toca el timbre solo.

Despertar a una bóveda que no vive conectada (0.37.0+)

Una bóveda en una función (AWS Lambda) no puede quedarse conectada: solo existe mientras trabaja. Registra una URL y el proxio la llama cuando llega algo para su cuenta y no hay nadie suyo conectado; el mensaje espera en la cola de siempre y se entrega al identificarse.

await client.registerWake({ publicKey: commPub, sign: commSign, url: 'https://…/wake?k=…', acta, channel })

Con channel (0.38.0+), listar ese canal vacío también la despierta, y list() devuelve el array con waking: true para que quien busca espere unos segundos.

  • Firma la llave de comunicación de la bóveda (no la maestra); el acta prueba que es miembro de la cuenta, y el timbre se guarda para la cuenta (profileId).
  • Necesita websocket-proxy ≥ 1.7.0. La URL lleva su propio secreto: el proxio es el único que la conoce además de la bóveda.

Push programado / auto-recordatorios (0.6.0+)

Además del timbre event-driven, puedes programar un push a tu PROPIA pubkey para una hora futura: el proxy lo dispara aunque la app esté cerrada (despierta el mismo SW). Es self-only — el target es siempre la pubkey que firma, así nadie puede programar pushes a terceros (sin vector de spam). Requiere haber activado push (enablePush) para que haya una subscription que timbrar.

// One-shot: dentro de 1 hora
const { jobId, nextFire } = await client.schedulePush({
  publicKey: id.me.publickey,
  sign: (d) => id.signData(d),
  when: Date.now() + 3600_000,
  payload: { title: 'Recordatorio', body: 'Revisá tus pronósticos' } // opcional
})

// Recurrente (cron + timezone IANA): lunes 08:30 hora de Buenos Aires
await client.schedulePush({
  publicKey: id.me.publickey,
  sign: (d) => id.signData(d),
  cron: '30 8 * * 1',
  tz: 'America/Argentina/Buenos_Aires'
})

const jobs = await client.listScheduledPushes({ publicKey: id.me.publickey, sign: (d) => id.signData(d) })
await client.cancelScheduledPush({ publicKey: id.me.publickey, sign: (d) => id.signData(d), jobId })

Notas:

  • One-shot vs recurrente: pasá when (Date|ms) o cron (+ tz). El cron es de 5 campos (estándar).
  • Catch-up: si el proxy estuvo caído cuando vencía un job, no lo dispara tarde — los one-shot vencidos se descartan y los recurrentes avanzan al próximo futuro.
  • Best-effort: un recordatorio es un timbre puro (no un mensaje); si al disparar no hay subscription activa, no pasa nada (no se encola).
  • Dirigido a la app (0.40.0+, proxio ≥ 1.9.0): con app en el cliente, el recordatorio suena solo en la suscripción de esa app, no en todas las apps del perfil que tengan push. listScheduledPushes devuelve el app de cada uno.

Eventos

| evento | argumentos | |---------------------|----------------------------------| | connect | — | | token | (token) | | disconnect | ({ code, reason }) | | error | (error) | | message | (from, payload, { raw, ts }) | | channel_joined | (channel, token) | | channel_left | (channel, token) | | peer_disconnected | (token, channel?) | | reconnecting | (attempt, maxAttempts) | | reconnect_failed | (attempts) | | abuse_notice | ({ from, operation, severity, timestamp }) — el proxy avisa que from está enviando demasiado. Las apps pueden penalizar el ranking de ese token. |

Errores: comprueba por code, nunca por la frase

Cuando una petición falla, el Error trae un code estable. Empareja por ahí: el texto es para leerlo un humano y puede cambiar o traducirse, y una comprobación por frase se rompe en silencio el día que eso pase.

| code | Cuándo | |---|---| | NOT_CONNECTED | se pidió algo sin conexión abierta | | CONNECTION_CLOSED | se llamó a close() con peticiones en vuelo: se cortan en el acto en vez de esperar el timeout | | REQUEST_TIMEOUT | el proxy no contestó en 10 s | | unsealed | se intentó mandar un mensaje dirigido en claro con requireSealed | | no-encpub | nadie ha anunciado la llave de cifrado de ese destinatario | | encpub-unverified | llegó una llave que esa identidad no firmó (un proxio hostil, un sobre manipulado) | | no-encpub-support | el proxio no sirve el directorio de llaves (anterior a websocket-proxy 1.1.0) | | no-signature | sign() no devolvió firma. No es un fallo de red: eso se arregla en la bóveda |

try {
  await client.publish('sala')
} catch (e) {
  if (e.code === 'NOT_CONNECTED') reconectar()
  else if (e.code !== 'CONNECTION_CLOSED') mostrarError(e)   // cerrar fue decisión nuestra
}

Diseño

  • Sin heartbeat ni polling de respaldo: cada app decide su política.
  • Reconexión simple con backoff fijo (configurable).
  • Las operaciones de canal devuelven Promise (timeout 10 s). close() las rechaza en el acto (CONNECTION_CLOSED): lo que estaba en vuelo ya no va a llegar, y esperar el timeout completo solo retrasa la mala noticia.
  • La firma usa JSON canónico (claves ordenadas) para que el proxy verifique con la misma representación.

Publicación (npm)

Paquete público en npm: @dotrino/proxy-client.

npm login                 # requerido (scope @dotrino, --access public)
npm version               # ya está en 0.5.0; usar `npm version patch|minor` para futuros bumps
npm publish --access public

Tras publicar, las apps actualizan con npm i @dotrino/proxy-client@latest y, para Web Push, copian el Service Worker a su carpeta pública:

cp node_modules/@dotrino/proxy-client/sw/dotrino-push-sw.js public/

Licencia

MIT

Deuda conocida

test/protocol.test.mjs tiene tests intermitentes: esperan frames con un timeout de 10 s y, con la máquina cargada, fallan ~1 de cada 5 pasadas (list devuelve los tokens, dos peticiones a la vez). No es una regresión — falla igual en cualquier commit — pero un test que a veces pasa enseña a ignorar el rojo, que es peor que no tenerlo.