@dotrino/lobby 0.1.5

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,156 @@
1
+ // Transporte de lobby: una sola conexión al proxy Dotrino, reutilizable por
2
+ // todas las salas/juegos de la app. Encapsula connect + identify (firmado por el
3
+ // vault), canales, envío y el demux de mensajes namespaced por gameId.
4
+ //
5
+ // NO abre una conexión nueva si ya hay una: usa el singleton del proxy-client,
6
+ // igual que el messenger. La identidad de red coincide con la de firma (identify
7
+ // con sobre firmado por id.signData), lo que habilita la cola offline.
8
+
9
+ import { Emitter, clock } from './util.js'
10
+ import { parseEnvelope } from './protocol.js'
11
+
12
+ let _defaultGetClient = null
13
+ let _defaultIdentityConnect = null
14
+
15
+ // Carga perezosa de los paquetes del ecosistema (peer deps). Permite testear con
16
+ // transportes inyectados sin requerir los paquetes instalados.
17
+ async function loadProxyFactory () {
18
+ if (_defaultGetClient) return _defaultGetClient
19
+ const mod = await import('@dotrino/proxy-client')
20
+ _defaultGetClient = mod.getWebSocketProxyClient
21
+ return _defaultGetClient
22
+ }
23
+ async function loadIdentity () {
24
+ if (_defaultIdentityConnect) return _defaultIdentityConnect
25
+ const mod = await import('@dotrino/identity')
26
+ _defaultIdentityConnect = mod.Identity.connect.bind(mod.Identity)
27
+ return _defaultIdentityConnect
28
+ }
29
+
30
+ export class Transport extends Emitter {
31
+ /**
32
+ * @param {object} opts
33
+ * @param {object} [opts.proxy] cliente proxy ya creado (si no, se usa el singleton)
34
+ * @param {object} [opts.identity] instancia de Identity ya conectada (para identify/firmas)
35
+ * @param {string} [opts.url] URL del proxy
36
+ */
37
+ constructor (opts = {}) {
38
+ super()
39
+ this.proxy = opts.proxy || null
40
+ this.identity = opts.identity || null
41
+ this.url = opts.url || null
42
+ this._ready = false
43
+ this._wired = false
44
+ this._connecting = null
45
+ this._subs = new Map() // gameId → Set<fn(from, env, meta)>
46
+ }
47
+
48
+ get token () { return this.proxy ? this.proxy.token : null }
49
+ get isReady () { return this._ready && !!this.token }
50
+
51
+ /** Conecta (si hace falta) e identifica con el vault. Idempotente. */
52
+ async connect () {
53
+ if (this.isReady) return this.token
54
+ if (this._connecting) return this._connecting
55
+ this._connecting = (async () => {
56
+ if (!this.proxy) {
57
+ const getClient = await loadProxyFactory()
58
+ this.proxy = getClient(this.url ? { url: this.url } : undefined)
59
+ }
60
+ if (this.url && this.proxy.updateConfig) this.proxy.updateConfig({ url: this.url })
61
+ if (!this.identity) {
62
+ try { const connect = await loadIdentity(); this.identity = await connect() } catch (_) { this.identity = null }
63
+ }
64
+ this._wire()
65
+ await this.proxy.connect()
66
+ await this._identify()
67
+ this._ready = true
68
+ this.emit('ready', this.token)
69
+ return this.token
70
+ })()
71
+ try { return await this._connecting } finally { this._connecting = null }
72
+ }
73
+
74
+ _wire () {
75
+ if (this._wired) return
76
+ this._wired = true
77
+
78
+ // Reconexión: el proxy reasigna token; reidentificamos y avisamos para que
79
+ // las salas vuelvan a anunciarse / re-saludar.
80
+ let firstToken = false
81
+ this.proxy.on('token', async () => {
82
+ if (!firstToken) { firstToken = true; return } // el primer token lo maneja connect()
83
+ try { await this._identify() } catch (e) { console.warn('[lobby] re-identify falló:', e) }
84
+ this.emit('reconnect', this.token)
85
+ this.emit('ready', this.token)
86
+ })
87
+
88
+ this.proxy.on('message', (from, payload, meta) => {
89
+ const env = parseEnvelope(payload)
90
+ if (!env) return
91
+ const subs = this._subs.get(env.g)
92
+ if (!subs || !subs.size) return
93
+ for (const fn of [...subs]) {
94
+ try { fn(from, env, meta) } catch (e) { console.error('[lobby] sub handler error:', e) }
95
+ }
96
+ })
97
+
98
+ // Eventos de presencia (no namespaced; las salas filtran por canal).
99
+ this.proxy.on('peer_disconnected', (token, channel) => this.emit('peer_disconnected', token, channel || null))
100
+ this.proxy.on('channel_joined', (channel, token) => this.emit('channel_joined', channel, token))
101
+ this.proxy.on('channel_left', (channel, token) => this.emit('channel_left', channel, token))
102
+ this.proxy.on('disconnect', (d) => this.emit('disconnect', d))
103
+ }
104
+
105
+ async _identify () {
106
+ if (!this.identity || !this.proxy || !this.proxy.token) return
107
+ const me = this.identity.me
108
+ const publickey = me && me.publickey
109
+ if (!publickey) return
110
+ const data = { op: 'identify', publickey, token: this.proxy.token, ts: clock.now() }
111
+ const signed = await this.identity.signData(data)
112
+ const signature = typeof signed === 'string' ? signed : signed.signature
113
+ await this.proxy.identify({ data, signature })
114
+ }
115
+
116
+ /** Demultiplexor: registra un handler para un gameId. Devuelve desuscriptor. */
117
+ subscribe (gameId, fn) {
118
+ let set = this._subs.get(gameId)
119
+ if (!set) { set = new Set(); this._subs.set(gameId, set) }
120
+ set.add(fn)
121
+ return () => { const s = this._subs.get(gameId); if (s) s.delete(fn) }
122
+ }
123
+
124
+ /** Envío directo por token (intenta WebRTC, cae a proxy). */
125
+ send (to, env) { this.proxy.send(to, env) }
126
+
127
+ /** Envío por pubkey estable (cola offline 24 h). Para invitaciones a contactos. */
128
+ sendByPubkey (pubkeys, env) { this.proxy.sendByPubkey(pubkeys, env) }
129
+
130
+ // ── Canales ────────────────────────────────────────────────────
131
+ publish (channel, extra) { return this.proxy.publish(channel, extra) }
132
+ unpublish (channel) { return this.proxy.unpublish(channel) }
133
+ list (channel) { return this.proxy.list(channel) }
134
+ listChannels (options) { return this.proxy.listChannels(options) }
135
+ channelCount (channel) { return this.proxy.channelCount(channel) }
136
+
137
+ // Observación read-only de un canal (proxy ≥ 0.6.2). Degrada elegante: si el
138
+ // cliente/proxy no lo soporta o no responde, resuelve sin colgar (la app cae a
139
+ // polling). Nunca bloquea ni rechaza hacia arriba.
140
+ watch (channel) {
141
+ if (!this.proxy || typeof this.proxy.watch !== 'function') return Promise.resolve(null)
142
+ return Promise.race([
143
+ this.proxy.watch(channel).catch(() => null),
144
+ new Promise(res => { const t = setTimeout(() => res(null), 4000); if (t.unref) t.unref() })
145
+ ])
146
+ }
147
+
148
+ unwatch (channel) {
149
+ if (!this.proxy || typeof this.proxy.unwatch !== 'function') return Promise.resolve(null)
150
+ return this.proxy.unwatch(channel).catch(() => null)
151
+ }
152
+
153
+ // ── WebRTC (opcional, para partidas de baja latencia) ─────────
154
+ connectWebRTC (token) { return this.proxy.connectWebRTC(token) }
155
+ isWebRTCOpen (token) { return this.proxy.isWebRTCOpen(token) }
156
+ }
package/src/util.js ADDED
@@ -0,0 +1,127 @@
1
+ // Utilidades internas de @dotrino/lobby.
2
+ // Sin dependencias: EventEmitter mínimo, RNG determinista sembrado,
3
+ // normalización de asientos y helpers varios.
4
+
5
+ /**
6
+ * EventEmitter mínimo (navegador + node). on() devuelve un desuscriptor.
7
+ * Los errores en un handler no rompen al resto de handlers.
8
+ */
9
+ export class Emitter {
10
+ constructor () { this._h = new Map() }
11
+
12
+ on (event, handler) {
13
+ if (typeof handler !== 'function') return () => {}
14
+ let set = this._h.get(event)
15
+ if (!set) { set = new Set(); this._h.set(event, set) }
16
+ set.add(handler)
17
+ return () => this.off(event, handler)
18
+ }
19
+
20
+ once (event, handler) {
21
+ const off = this.on(event, (...args) => { off(); handler(...args) })
22
+ return off
23
+ }
24
+
25
+ off (event, handler) {
26
+ const set = this._h.get(event)
27
+ if (set) set.delete(handler)
28
+ }
29
+
30
+ emit (event, ...args) {
31
+ const set = this._h.get(event)
32
+ if (!set) return
33
+ for (const h of [...set]) {
34
+ try { h(...args) } catch (e) { console.error(`[lobby] handler error on "${event}":`, e) }
35
+ }
36
+ }
37
+
38
+ removeAllListeners () { this._h.clear() }
39
+ }
40
+
41
+ /** Reloj inyectable (los tests pueden sustituirlo). */
42
+ export const clock = { now: () => Date.now() }
43
+
44
+ /**
45
+ * PRNG determinista (mulberry32). Devuelve floats en [0,1).
46
+ * Misma semilla ⇒ misma secuencia, en cualquier máquina. Esto permite que el
47
+ * host genere azar (barajar/dados) de forma reproducible y verificable.
48
+ * @param {number} seed entero de 32 bits
49
+ */
50
+ export function mulberry32 (seed) {
51
+ let a = seed >>> 0
52
+ return function rng () {
53
+ a |= 0; a = (a + 0x6D2B79F5) | 0
54
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
55
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
56
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
57
+ }
58
+ }
59
+
60
+ /** Hash de string → entero de 32 bits (FNV-1a). Para derivar semillas de seeds string. */
61
+ export function hashSeed (str) {
62
+ let h = 0x811c9dc5
63
+ const s = String(str)
64
+ for (let i = 0; i < s.length; i++) {
65
+ h ^= s.charCodeAt(i)
66
+ h = Math.imul(h, 0x01000193)
67
+ }
68
+ return h >>> 0
69
+ }
70
+
71
+ /** Baraja Fisher–Yates in-place usando un rng() dado (no muta el original si se pasa copia). */
72
+ export function shuffle (array, rng) {
73
+ for (let i = array.length - 1; i > 0; i--) {
74
+ const j = Math.floor(rng() * (i + 1))
75
+ const tmp = array[i]; array[i] = array[j]; array[j] = tmp
76
+ }
77
+ return array
78
+ }
79
+
80
+ /** Clon profundo (structuredClone si existe; si no, JSON). */
81
+ export function clone (value) {
82
+ if (value == null) return value
83
+ try {
84
+ if (typeof structuredClone === 'function') return structuredClone(value)
85
+ } catch (_) { /* fall through */ }
86
+ return JSON.parse(JSON.stringify(value))
87
+ }
88
+
89
+ /**
90
+ * Normaliza la especificación de asientos a una forma canónica.
91
+ * - Array de ids → asientos nombrados (p.ej. ['white','black']).
92
+ * - { min, max } → genera ids 's1'..'sN'.
93
+ * @returns {{ ids: string[], min: number, max: number, named: boolean }}
94
+ */
95
+ export function normalizeSeats (spec) {
96
+ if (Array.isArray(spec)) {
97
+ if (!spec.length) throw new Error('[lobby] seats: el array de asientos no puede estar vacío')
98
+ const ids = spec.map(String)
99
+ return { ids, min: ids.length, max: ids.length, named: true }
100
+ }
101
+ const max = Math.max(1, (spec && (spec.max ?? spec.min)) || 2)
102
+ const min = Math.max(1, Math.min(max, (spec && spec.min) || max))
103
+ const ids = Array.from({ length: max }, (_, i) => 's' + (i + 1))
104
+ return { ids, min, max, named: false }
105
+ }
106
+
107
+ /** Igualdad de pubkeys tolerante a diferencias de formato JWK (orden de claves). */
108
+ export function samePubkey (a, b) {
109
+ if (!a || !b) return false
110
+ if (a === b) return true
111
+ try {
112
+ const pa = typeof a === 'string' ? JSON.parse(a) : a
113
+ const pb = typeof b === 'string' ? JSON.parse(b) : b
114
+ return pa && pb && pa.x === pb.x && pa.y === pb.y && pa.crv === pb.crv
115
+ } catch (_) { return false }
116
+ }
117
+
118
+ /** Promesa que resuelve tras `ms`. */
119
+ export function delay (ms) { return new Promise(r => setTimeout(r, ms)) }
120
+
121
+ /** Id corto aleatorio (no criptográfico) para correlación de mensajes. */
122
+ export function shortId () {
123
+ try {
124
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID().slice(0, 8)
125
+ } catch (_) {}
126
+ return Math.floor(Math.random() * 0xffffffff).toString(36)
127
+ }