xonecode 0.34.2 → 0.36.3
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/THIRD_PARTY_NOTICES.md +30 -0
- package/apps/web/dist/assets/{EditorDeFichero-Cj0knNmP.js → EditorDeFichero-B9EyiuM6.js} +1 -1
- package/apps/web/dist/assets/index-C6MsC7A5.js +506 -0
- package/apps/web/dist/assets/index-DRNhZyYF.css +1 -0
- package/apps/web/dist/assets/{php-MVUx_K96.js → php-DCP5mtTp.js} +1 -1
- package/apps/web/dist/assets/{ruby-n-r-AXdi.js → ruby-B41ayseU.js} +1 -1
- package/apps/web/dist/index.html +2 -2
- package/dist/agent/config/settingsEnDisco.js +30 -0
- package/dist/agent/config/settingsEnDisco.js.map +1 -1
- package/dist/agent/dispositivos/instalacionEnMaquina.js +13 -0
- package/dist/agent/dispositivos/instalacionEnMaquina.js.map +1 -1
- package/dist/agent/motores/trueforge/sesionTrueforge.js +94 -5
- package/dist/agent/motores/trueforge/sesionTrueforge.js.map +1 -1
- package/dist/agent/remoto/ngrokEnMaquina.js +113 -0
- package/dist/agent/remoto/ngrokEnMaquina.js.map +1 -0
- package/dist/agent/remoto/puenteWebSocket.js +175 -0
- package/dist/agent/remoto/puenteWebSocket.js.map +1 -0
- package/dist/agent/remoto/tunelNgrok.js +245 -0
- package/dist/agent/remoto/tunelNgrok.js.map +1 -0
- package/dist/agent/tareas/juezDeTarea.js +53 -1
- package/dist/agent/tareas/juezDeTarea.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/core/dispositivos.js +53 -0
- package/dist/core/dispositivos.js.map +1 -1
- package/dist/core/entrega.js +31 -2
- package/dist/core/entrega.js.map +1 -1
- package/dist/core/idiomaDeLaRespuesta.js +38 -0
- package/dist/core/idiomaDeLaRespuesta.js.map +1 -0
- package/dist/core/modoDeEscritura.js +4 -1
- package/dist/core/modoDeEscritura.js.map +1 -1
- package/dist/core/panorama.js +93 -0
- package/dist/core/panorama.js.map +1 -0
- package/dist/core/ports.js +100 -0
- package/dist/core/ports.js.map +1 -1
- package/dist/core/remoto.js +117 -0
- package/dist/core/remoto.js.map +1 -0
- package/dist/core/repartoDeEncargo.js +23 -0
- package/dist/core/repartoDeEncargo.js.map +1 -1
- package/dist/core/settings.js +83 -0
- package/dist/core/settings.js.map +1 -1
- package/dist/core/tareas.js +55 -1
- package/dist/core/tareas.js.map +1 -1
- package/dist/core/tunel.js +92 -0
- package/dist/core/tunel.js.map +1 -0
- package/dist/web/servidor/arranque.js +644 -66
- package/dist/web/servidor/arranque.js.map +1 -1
- package/dist/web/servidor/consolaWeb.js +52 -2
- package/dist/web/servidor/consolaWeb.js.map +1 -1
- package/dist/web/servidor/corredorDeTareas.js +15 -2
- package/dist/web/servidor/corredorDeTareas.js.map +1 -1
- package/dist/web/servidor/puenteRemoto.js +272 -0
- package/dist/web/servidor/puenteRemoto.js.map +1 -0
- package/dist/web/servidor/transporte.js.map +1 -1
- package/dist/web/servidor/vestibulo.js +52 -1
- package/dist/web/servidor/vestibulo.js.map +1 -1
- package/node_modules/@xone/xonecode-remoto/dist/base64url.d.ts +2 -0
- package/node_modules/@xone/xonecode-remoto/dist/base64url.js +19 -0
- package/node_modules/@xone/xonecode-remoto/dist/cifrado.d.ts +25 -0
- package/node_modules/@xone/xonecode-remoto/dist/cifrado.js +75 -0
- package/node_modules/@xone/xonecode-remoto/dist/contenido.d.ts +121 -0
- package/node_modules/@xone/xonecode-remoto/dist/contenido.js +191 -0
- package/node_modules/@xone/xonecode-remoto/dist/index.d.ts +6 -0
- package/node_modules/@xone/xonecode-remoto/dist/index.js +6 -0
- package/node_modules/@xone/xonecode-remoto/dist/servidor/salas.d.ts +46 -0
- package/node_modules/@xone/xonecode-remoto/dist/servidor/salas.js +128 -0
- package/node_modules/@xone/xonecode-remoto/dist/servidor/servidor.d.ts +31 -0
- package/node_modules/@xone/xonecode-remoto/dist/servidor/servidor.js +198 -0
- package/node_modules/@xone/xonecode-remoto/dist/sobre.d.ts +53 -0
- package/node_modules/@xone/xonecode-remoto/dist/sobre.js +69 -0
- package/node_modules/@xone/xonecode-remoto/dist/url.d.ts +12 -0
- package/node_modules/@xone/xonecode-remoto/dist/url.js +35 -0
- package/node_modules/@xone/xonecode-remoto/dist/version.d.ts +2 -0
- package/node_modules/@xone/xonecode-remoto/dist/version.js +2 -0
- package/node_modules/@xone/xonecode-remoto/package.json +41 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/index-Bfa2gK3J.css +1 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/index-qBo9eRDN.js +77 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/node_modules/@xone/xonecode-remoto/web/index.html +14 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/node_modules/ws/lib/receiver.js +743 -0
- package/node_modules/ws/lib/sender.js +607 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +562 -0
- package/node_modules/ws/lib/websocket.js +1407 -0
- package/node_modules/ws/package.json +74 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/package.json +6 -1
- package/apps/web/dist/assets/index-Ga8fTlWp.js +0 -499
- package/apps/web/dist/assets/index-R3kXj69E.css +0 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El contenido cifrado: un SUBCONJUNTO en lista blanca del cable de la consola web de xonecode.
|
|
3
|
+
* Las formas copian las de xonecode (`apps/web/src/tipos.ts`): si cambian allí, cambia aquí y
|
|
4
|
+
* sube `VERSION_DEL_PROTOCOLO`.
|
|
5
|
+
*/
|
|
6
|
+
const objeto = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
7
|
+
const texto = (x) => typeof x === "string";
|
|
8
|
+
const noVacio = (x) => texto(x) && x.length > 0;
|
|
9
|
+
const esActo = (x) => objeto(x) && texto(x.tipo);
|
|
10
|
+
export function validarDelMovil(m) {
|
|
11
|
+
if (!objeto(m))
|
|
12
|
+
return undefined;
|
|
13
|
+
switch (m.clase) {
|
|
14
|
+
case "prosa":
|
|
15
|
+
if (!texto(m.texto) || m.adjuntos !== undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (m.detener === undefined)
|
|
18
|
+
return { clase: "prosa", texto: m.texto };
|
|
19
|
+
return m.detener === true ? { clase: "prosa", texto: m.texto, detener: true } : undefined;
|
|
20
|
+
case "decision": {
|
|
21
|
+
if (!objeto(m.decisiones))
|
|
22
|
+
return undefined;
|
|
23
|
+
const decisiones = {};
|
|
24
|
+
for (const [id, valor] of Object.entries(m.decisiones)) {
|
|
25
|
+
if (id === "__proto__")
|
|
26
|
+
continue;
|
|
27
|
+
if (valor !== "approve" && valor !== "reject")
|
|
28
|
+
return undefined;
|
|
29
|
+
decisiones[id] = valor;
|
|
30
|
+
}
|
|
31
|
+
if (m.consola === undefined)
|
|
32
|
+
return { clase: "decision", decisiones };
|
|
33
|
+
return noVacio(m.consola) ? { clase: "decision", decisiones, consola: m.consola } : undefined;
|
|
34
|
+
}
|
|
35
|
+
case "respuesta":
|
|
36
|
+
return texto(m.texto) && m.seleccion === undefined ? { clase: "respuesta", texto: m.texto } : undefined;
|
|
37
|
+
case "cancelar":
|
|
38
|
+
return { clase: "cancelar" };
|
|
39
|
+
case "feedback":
|
|
40
|
+
return noVacio(m.tarea) && texto(m.texto) && m.texto.trim() !== "" ? { clase: "feedback", tarea: m.tarea, texto: m.texto } : undefined;
|
|
41
|
+
case "continuar":
|
|
42
|
+
return noVacio(m.tarea) ? { clase: "continuar", tarea: m.tarea } : undefined;
|
|
43
|
+
default:
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Pendientes, ficheros y diffs de una aprobación: la validación es UNA, la del mensaje `aprobacion` y la de cada consola del panorama. */
|
|
48
|
+
function validarAprobacion(m) {
|
|
49
|
+
// Un anfitrión autenticado es de fiar en el CONTENIDO, no en la FORMA: se valida y se reconstruye.
|
|
50
|
+
if (!Array.isArray(m.pendientes) || !objeto(m.ficheros) || !objeto(m.diffs))
|
|
51
|
+
return undefined;
|
|
52
|
+
const pendientes = [];
|
|
53
|
+
for (const p of m.pendientes) {
|
|
54
|
+
if (!objeto(p) || !texto(p.id) || !texto(p.origen) || !texto(p.descripcion))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (!Array.isArray(p.decisionesPermitidas) || !p.decisionesPermitidas.every(texto))
|
|
57
|
+
return undefined;
|
|
58
|
+
pendientes.push({
|
|
59
|
+
id: p.id,
|
|
60
|
+
origen: p.origen,
|
|
61
|
+
descripcion: p.descripcion,
|
|
62
|
+
decisionesPermitidas: [...p.decisionesPermitidas],
|
|
63
|
+
...(p.remota === true ? { remota: true } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const ficheros = {};
|
|
67
|
+
for (const [k, v] of Object.entries(m.ficheros)) {
|
|
68
|
+
if (!texto(v))
|
|
69
|
+
return undefined;
|
|
70
|
+
ficheros[k] = v;
|
|
71
|
+
}
|
|
72
|
+
const diffs = {};
|
|
73
|
+
for (const [k, v] of Object.entries(m.diffs)) {
|
|
74
|
+
if (!Array.isArray(v))
|
|
75
|
+
return undefined;
|
|
76
|
+
const lineas = [];
|
|
77
|
+
for (const l of v) {
|
|
78
|
+
if (!objeto(l) || !texto(l.texto))
|
|
79
|
+
return undefined;
|
|
80
|
+
if (l.tipo !== "igual" && l.tipo !== "anadido" && l.tipo !== "quitado")
|
|
81
|
+
return undefined;
|
|
82
|
+
lineas.push({ tipo: l.tipo, texto: l.texto });
|
|
83
|
+
}
|
|
84
|
+
diffs[k] = lineas;
|
|
85
|
+
}
|
|
86
|
+
return { pendientes, ficheros, diffs };
|
|
87
|
+
}
|
|
88
|
+
const ESTADOS_DE_CONSOLA = ["trabajando", "esperando-aprobacion", "esperando-respuesta", "parada"];
|
|
89
|
+
const ESTADOS_DE_TAREA = ["nuevo", "en-proceso", "requiere-atencion", "terminada"];
|
|
90
|
+
/** Un campo de texto OPCIONAL: ausente vale, presente tiene que ser texto. `false` = forma mala. */
|
|
91
|
+
const opcional = (x) => x === undefined || texto(x);
|
|
92
|
+
function validarConsola(c) {
|
|
93
|
+
if (!objeto(c) || !texto(c.id) || !texto(c.proyecto) || typeof c.foco !== "boolean")
|
|
94
|
+
return undefined;
|
|
95
|
+
if (!texto(c.estado) || !ESTADOS_DE_CONSOLA.includes(c.estado))
|
|
96
|
+
return undefined;
|
|
97
|
+
if (!opcional(c.sesion) || !opcional(c.ultimaActividad))
|
|
98
|
+
return undefined;
|
|
99
|
+
let aprobacion;
|
|
100
|
+
if (c.aprobacion !== undefined) {
|
|
101
|
+
if (!objeto(c.aprobacion))
|
|
102
|
+
return undefined;
|
|
103
|
+
const a = validarAprobacion(c.aprobacion);
|
|
104
|
+
if (!a)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (c.aprobacion.recortada !== undefined && c.aprobacion.recortada !== true)
|
|
107
|
+
return undefined;
|
|
108
|
+
aprobacion = { ...a, ...(c.aprobacion.recortada === true ? { recortada: true } : {}) };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
id: c.id,
|
|
112
|
+
proyecto: c.proyecto,
|
|
113
|
+
...(texto(c.sesion) ? { sesion: c.sesion } : {}),
|
|
114
|
+
foco: c.foco,
|
|
115
|
+
estado: c.estado,
|
|
116
|
+
...(texto(c.ultimaActividad) ? { ultimaActividad: c.ultimaActividad } : {}),
|
|
117
|
+
...(aprobacion ? { aprobacion } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function validarTarea(t) {
|
|
121
|
+
if (!objeto(t) || !texto(t.id) || !texto(t.titulo) || !texto(t.proyecto) || !texto(t.creada))
|
|
122
|
+
return undefined;
|
|
123
|
+
if (!texto(t.estado) || !ESTADOS_DE_TAREA.includes(t.estado))
|
|
124
|
+
return undefined;
|
|
125
|
+
if (!opcional(t.tras) || !opcional(t.motivo) || !opcional(t.acabada))
|
|
126
|
+
return undefined;
|
|
127
|
+
return {
|
|
128
|
+
id: t.id,
|
|
129
|
+
titulo: t.titulo,
|
|
130
|
+
proyecto: t.proyecto,
|
|
131
|
+
estado: t.estado,
|
|
132
|
+
...(texto(t.tras) ? { tras: t.tras } : {}),
|
|
133
|
+
...(texto(t.motivo) ? { motivo: t.motivo } : {}),
|
|
134
|
+
creada: t.creada,
|
|
135
|
+
...(texto(t.acabada) ? { acabada: t.acabada } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function validarDelAnfitrion(m) {
|
|
139
|
+
if (!objeto(m))
|
|
140
|
+
return undefined;
|
|
141
|
+
switch (m.clase) {
|
|
142
|
+
// El contenido de los actos pasa tal cual lo mandó el anfitrión, a propósito: la interfaz lo pinta como texto.
|
|
143
|
+
case "acto":
|
|
144
|
+
case "sustitucion":
|
|
145
|
+
return esActo(m.acto) ? { clase: m.clase, acto: m.acto } : undefined;
|
|
146
|
+
case "reemision":
|
|
147
|
+
return Array.isArray(m.actos) && m.actos.every(esActo) ? { clase: "reemision", actos: m.actos } : undefined;
|
|
148
|
+
case "aprobacion": {
|
|
149
|
+
const a = validarAprobacion(m);
|
|
150
|
+
return a ? { clase: "aprobacion", ...a } : undefined;
|
|
151
|
+
}
|
|
152
|
+
case "pregunta":
|
|
153
|
+
// El móvil solo contesta TEXTO: una pregunta con forma de decisión (plan, casillas, selector) no se le reenvía.
|
|
154
|
+
if (m.decision !== undefined || m.seleccionable !== undefined || m.selector !== undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
return texto(m.texto) ? { clase: "pregunta", texto: m.texto } : undefined;
|
|
157
|
+
case "turno":
|
|
158
|
+
if (typeof m.activo !== "boolean")
|
|
159
|
+
return undefined;
|
|
160
|
+
return m.detenible === true ? { clase: "turno", activo: m.activo, detenible: true } : { clase: "turno", activo: m.activo };
|
|
161
|
+
case "remoto.estado":
|
|
162
|
+
return {
|
|
163
|
+
clase: "remoto.estado",
|
|
164
|
+
...(texto(m.proyecto) ? { proyecto: m.proyecto } : {}),
|
|
165
|
+
...(texto(m.sesion) ? { sesion: m.sesion } : {}),
|
|
166
|
+
};
|
|
167
|
+
case "panorama": {
|
|
168
|
+
if (!Array.isArray(m.consolas) || !Array.isArray(m.tareas))
|
|
169
|
+
return undefined;
|
|
170
|
+
if (typeof m.esperando !== "number" || !Number.isFinite(m.esperando) || m.esperando < 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
const consolas = [];
|
|
173
|
+
for (const c of m.consolas) {
|
|
174
|
+
const v = validarConsola(c);
|
|
175
|
+
if (!v)
|
|
176
|
+
return undefined;
|
|
177
|
+
consolas.push(v);
|
|
178
|
+
}
|
|
179
|
+
const tareas = [];
|
|
180
|
+
for (const t of m.tareas) {
|
|
181
|
+
const v = validarTarea(t);
|
|
182
|
+
if (!v)
|
|
183
|
+
return undefined;
|
|
184
|
+
tareas.push(v);
|
|
185
|
+
}
|
|
186
|
+
return { clase: "panorama", consolas, tareas, esperando: m.esperando };
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type SobreDelServidor } from "../index.js";
|
|
2
|
+
export interface Topes {
|
|
3
|
+
movilesPorSala: number;
|
|
4
|
+
bytesPorMensaje: number;
|
|
5
|
+
/** Ritmo sostenido de un MÓVIL (y de toda conexión antes de su primer mensaje). */
|
|
6
|
+
mensajesPorMinuto: number;
|
|
7
|
+
/**
|
|
8
|
+
* Ritmo sostenido de un ANFITRIÓN. xonecode manda una `sustitucion` cada 80 ms mientras el modelo
|
|
9
|
+
* escribe (unas 750 por minuto, hasta el doble con razonamiento): con el tope del móvil se cortaría.
|
|
10
|
+
*/
|
|
11
|
+
mensajesPorMinutoAnfitrion: number;
|
|
12
|
+
graciaMs: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const TOPES_POR_OMISION: Topes;
|
|
15
|
+
type Enviar = (sobre: SobreDelServidor) => void;
|
|
16
|
+
/** Salas en memoria: empareja y enruta. No sabe nada de lo que va dentro de `carga`. */
|
|
17
|
+
export declare class Salas {
|
|
18
|
+
private readonly topes;
|
|
19
|
+
private readonly ahora;
|
|
20
|
+
private readonly nuevoId;
|
|
21
|
+
private readonly salas;
|
|
22
|
+
constructor(topes: Topes, ahora?: () => number, nuevoId?: () => string);
|
|
23
|
+
/** Todo envío pasa por aquí: un socket roto no puede cortar un bucle ni dejar una sala sin borrar. */
|
|
24
|
+
private emitir;
|
|
25
|
+
existe(sala: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Supuesto: no hay prueba de identidad del anfitrión durante la gracia. El id de sala solo lo
|
|
28
|
+
* conocen el relé y quien tiene la URL de la sesión, que ya controla la sesión por diseño; el
|
|
29
|
+
* cifrado extremo a extremo impide leer o falsificar nada. Lo peor es una denegación de servicio
|
|
30
|
+
* durante la gracia.
|
|
31
|
+
*/
|
|
32
|
+
abrir(sala: string, enviar: Enviar): boolean;
|
|
33
|
+
entrar(sala: string, enviar: Enviar): string | undefined;
|
|
34
|
+
salir(sala: string, id: string): void;
|
|
35
|
+
datoDelAnfitrion(sala: string, para: string | undefined, carga: string): void;
|
|
36
|
+
/** Solo de un móvil que SIGUE en la sala: uno de una sala ya cerrada no llega a quien reabra el mismo id. */
|
|
37
|
+
datoDelMovil(sala: string, id: string, carga: string): void;
|
|
38
|
+
/** Solo vale si `enviar` es el anfitrión actual: el cierre tardío de uno viejo no echa al nuevo. */
|
|
39
|
+
anfitrionCaido(sala: string, enviar: Enviar): void;
|
|
40
|
+
/** Cierre pedido por el anfitrión actual; con otro `enviar` no hace nada. */
|
|
41
|
+
cerrar(sala: string, enviar: Enviar): void;
|
|
42
|
+
private expulsar;
|
|
43
|
+
/** Cierra las salas cuyo anfitrión lleva caído más que la gracia. Se llama periódicamente. */
|
|
44
|
+
barrer(): void;
|
|
45
|
+
}
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { TOPE_DE_SOBRE } from "../index.js";
|
|
2
|
+
export const TOPES_POR_OMISION = {
|
|
3
|
+
movilesPorSala: 5,
|
|
4
|
+
bytesPorMensaje: TOPE_DE_SOBRE,
|
|
5
|
+
mensajesPorMinuto: 600,
|
|
6
|
+
mensajesPorMinutoAnfitrion: 6000,
|
|
7
|
+
graciaMs: 300_000,
|
|
8
|
+
};
|
|
9
|
+
/** Salas en memoria: empareja y enruta. No sabe nada de lo que va dentro de `carga`. */
|
|
10
|
+
export class Salas {
|
|
11
|
+
topes;
|
|
12
|
+
ahora;
|
|
13
|
+
nuevoId;
|
|
14
|
+
salas = new Map();
|
|
15
|
+
constructor(topes, ahora = Date.now, nuevoId = () => crypto.randomUUID()) {
|
|
16
|
+
this.topes = topes;
|
|
17
|
+
this.ahora = ahora;
|
|
18
|
+
this.nuevoId = nuevoId;
|
|
19
|
+
}
|
|
20
|
+
/** Todo envío pasa por aquí: un socket roto no puede cortar un bucle ni dejar una sala sin borrar. */
|
|
21
|
+
emitir(enviar, sobre) {
|
|
22
|
+
if (enviar === undefined)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
enviar(sobre);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// se ignora: la conexión rota la limpia su propio cierre
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
existe(sala) {
|
|
32
|
+
return this.salas.has(sala);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Supuesto: no hay prueba de identidad del anfitrión durante la gracia. El id de sala solo lo
|
|
36
|
+
* conocen el relé y quien tiene la URL de la sesión, que ya controla la sesión por diseño; el
|
|
37
|
+
* cifrado extremo a extremo impide leer o falsificar nada. Lo peor es una denegación de servicio
|
|
38
|
+
* durante la gracia.
|
|
39
|
+
*/
|
|
40
|
+
abrir(sala, enviar) {
|
|
41
|
+
const existente = this.salas.get(sala);
|
|
42
|
+
if (existente !== undefined && existente.anfitrion !== undefined) {
|
|
43
|
+
this.emitir(enviar, { t: "error", motivo: "sala-ocupada" });
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const s = existente ?? { anfitrion: undefined, caidoDesde: undefined, moviles: new Map() };
|
|
47
|
+
s.anfitrion = enviar;
|
|
48
|
+
s.caidoDesde = undefined;
|
|
49
|
+
this.salas.set(sala, s);
|
|
50
|
+
this.emitir(enviar, { t: "abierta" });
|
|
51
|
+
if (s.moviles.size > 0) {
|
|
52
|
+
this.emitir(enviar, { t: "presencia", moviles: [...s.moviles.keys()] });
|
|
53
|
+
for (const movil of s.moviles.values())
|
|
54
|
+
this.emitir(movil, { t: "anfitrion", conectado: true });
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
entrar(sala, enviar) {
|
|
59
|
+
const s = this.salas.get(sala);
|
|
60
|
+
if (s === undefined) {
|
|
61
|
+
this.emitir(enviar, { t: "error", motivo: "sala-inexistente" });
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (s.moviles.size >= this.topes.movilesPorSala) {
|
|
65
|
+
this.emitir(enviar, { t: "error", motivo: "tope-de-moviles" });
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const id = this.nuevoId();
|
|
69
|
+
s.moviles.set(id, enviar);
|
|
70
|
+
this.emitir(enviar, { t: "entrada", id });
|
|
71
|
+
this.emitir(enviar, { t: "anfitrion", conectado: s.anfitrion !== undefined });
|
|
72
|
+
this.emitir(s.anfitrion, { t: "presencia", moviles: [...s.moviles.keys()] });
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
salir(sala, id) {
|
|
76
|
+
const s = this.salas.get(sala);
|
|
77
|
+
if (s === undefined || !s.moviles.delete(id))
|
|
78
|
+
return;
|
|
79
|
+
this.emitir(s.anfitrion, { t: "presencia", moviles: [...s.moviles.keys()] });
|
|
80
|
+
}
|
|
81
|
+
datoDelAnfitrion(sala, para, carga) {
|
|
82
|
+
const s = this.salas.get(sala);
|
|
83
|
+
if (s === undefined)
|
|
84
|
+
return;
|
|
85
|
+
if (para !== undefined) {
|
|
86
|
+
this.emitir(s.moviles.get(para), { t: "dato", carga });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const movil of s.moviles.values())
|
|
90
|
+
this.emitir(movil, { t: "dato", carga });
|
|
91
|
+
}
|
|
92
|
+
/** Solo de un móvil que SIGUE en la sala: uno de una sala ya cerrada no llega a quien reabra el mismo id. */
|
|
93
|
+
datoDelMovil(sala, id, carga) {
|
|
94
|
+
const s = this.salas.get(sala);
|
|
95
|
+
if (s === undefined || !s.moviles.has(id))
|
|
96
|
+
return;
|
|
97
|
+
this.emitir(s.anfitrion, { t: "dato", de: id, carga });
|
|
98
|
+
}
|
|
99
|
+
/** Solo vale si `enviar` es el anfitrión actual: el cierre tardío de uno viejo no echa al nuevo. */
|
|
100
|
+
anfitrionCaido(sala, enviar) {
|
|
101
|
+
const s = this.salas.get(sala);
|
|
102
|
+
if (s === undefined || s.anfitrion !== enviar)
|
|
103
|
+
return;
|
|
104
|
+
s.anfitrion = undefined;
|
|
105
|
+
s.caidoDesde = this.ahora();
|
|
106
|
+
for (const movil of s.moviles.values())
|
|
107
|
+
this.emitir(movil, { t: "anfitrion", conectado: false });
|
|
108
|
+
}
|
|
109
|
+
/** Cierre pedido por el anfitrión actual; con otro `enviar` no hace nada. */
|
|
110
|
+
cerrar(sala, enviar) {
|
|
111
|
+
const s = this.salas.get(sala);
|
|
112
|
+
if (s === undefined || s.anfitrion !== enviar)
|
|
113
|
+
return;
|
|
114
|
+
this.expulsar(sala, s);
|
|
115
|
+
}
|
|
116
|
+
expulsar(sala, s) {
|
|
117
|
+
for (const movil of s.moviles.values())
|
|
118
|
+
this.emitir(movil, { t: "cerrada" });
|
|
119
|
+
this.salas.delete(sala);
|
|
120
|
+
}
|
|
121
|
+
/** Cierra las salas cuyo anfitrión lleva caído más que la gracia. Se llama periódicamente. */
|
|
122
|
+
barrer() {
|
|
123
|
+
for (const [sala, s] of this.salas) {
|
|
124
|
+
if (s.caidoDesde !== undefined && this.ahora() - s.caidoDesde > this.topes.graciaMs)
|
|
125
|
+
this.expulsar(sala, s);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Topes } from "./salas.js";
|
|
2
|
+
export interface OpcionesDelServidor {
|
|
3
|
+
puerto: number;
|
|
4
|
+
/** Dirección de escucha; por omisión solo loopback. */
|
|
5
|
+
host?: string;
|
|
6
|
+
/** La web móvil; por omisión la que viaja en el paquete (`web/`, relativa a este módulo). */
|
|
7
|
+
carpetaWeb?: string;
|
|
8
|
+
topes?: Topes;
|
|
9
|
+
/** Periodo del barrido y del ping de latido; por omisión 10 s. */
|
|
10
|
+
latidoMs?: number;
|
|
11
|
+
/** Plazo para el primer `abrir`/`entrar` válido; por omisión `PLAZO_PRIMER_MENSAJE_MS`. */
|
|
12
|
+
plazoPrimerMensajeMs?: number;
|
|
13
|
+
/** El reloj (ms) del ritmo y de la gracia de las salas; por omisión `Date.now`. Solo para los tests. */
|
|
14
|
+
ahora?: () => number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Un cubo de fichas por conexión: cabe un minuto de ritmo y se rellena a ese ritmo, sin ventanas
|
|
18
|
+
* fijas que dejen pasar el doble a caballo del cambio de minuto. El tope lo da el papel de la
|
|
19
|
+
* conexión en cada mensaje, así que al pasar a anfitrión se rellena a su ritmo.
|
|
20
|
+
*/
|
|
21
|
+
export declare function crearCubo(ahora: () => number, inicial: number): {
|
|
22
|
+
/** Gasta una ficha si la hay; `false` es que se ha pasado del ritmo. */
|
|
23
|
+
gastar(porMinuto: number): boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare const PLAZO_PRIMER_MENSAJE_MS = 10000;
|
|
26
|
+
export interface ServidorEnMarcha {
|
|
27
|
+
urlHttp: string;
|
|
28
|
+
urlWs: string;
|
|
29
|
+
cerrar(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function arrancarServidor(opciones: OpcionesDelServidor): Promise<ServidorEnMarcha>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { extname, join, normalize } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { WebSocketServer } from "ws";
|
|
6
|
+
import { FORMA_DE_SALA, VERSION_DEL_PROTOCOLO, interpretarSobre } from "../index.js";
|
|
7
|
+
import { Salas, TOPES_POR_OMISION } from "./salas.js";
|
|
8
|
+
/** `dist/servidor/` y `src/servidor/` están a la misma profundidad de `web/`, que `npm run build` copia de la web. */
|
|
9
|
+
const CARPETA_WEB_DEL_PAQUETE = fileURLToPath(new URL("../../web/", import.meta.url));
|
|
10
|
+
/**
|
|
11
|
+
* Un cubo de fichas por conexión: cabe un minuto de ritmo y se rellena a ese ritmo, sin ventanas
|
|
12
|
+
* fijas que dejen pasar el doble a caballo del cambio de minuto. El tope lo da el papel de la
|
|
13
|
+
* conexión en cada mensaje, así que al pasar a anfitrión se rellena a su ritmo.
|
|
14
|
+
*/
|
|
15
|
+
export function crearCubo(ahora, inicial) {
|
|
16
|
+
let fichas = inicial;
|
|
17
|
+
let desde = ahora();
|
|
18
|
+
return {
|
|
19
|
+
/** Gasta una ficha si la hay; `false` es que se ha pasado del ritmo. */
|
|
20
|
+
gastar(porMinuto) {
|
|
21
|
+
const t = ahora();
|
|
22
|
+
fichas = Math.min(porMinuto, fichas + (Math.max(0, t - desde) * porMinuto) / 60_000);
|
|
23
|
+
desde = t;
|
|
24
|
+
if (fichas < 1)
|
|
25
|
+
return false;
|
|
26
|
+
fichas -= 1;
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export const PLAZO_PRIMER_MENSAJE_MS = 10_000;
|
|
32
|
+
const TIPOS = {
|
|
33
|
+
".html": "text/html; charset=utf-8",
|
|
34
|
+
".js": "text/javascript; charset=utf-8",
|
|
35
|
+
".css": "text/css; charset=utf-8",
|
|
36
|
+
".svg": "image/svg+xml",
|
|
37
|
+
".png": "image/png",
|
|
38
|
+
".woff2": "font/woff2",
|
|
39
|
+
".json": "application/json",
|
|
40
|
+
};
|
|
41
|
+
async function servirEstatico(carpeta, peticion, respuesta) {
|
|
42
|
+
const ruta = new URL(peticion.url ?? "/", "http://x").pathname;
|
|
43
|
+
if (ruta === "/salud") {
|
|
44
|
+
respuesta.writeHead(200, { "content-type": "text/plain" }).end("ok");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Toda URL de sesión sirve la misma página: la sala la lee la web, el secreto está en el fragmento.
|
|
48
|
+
const relativa = ruta === "/" || ruta.startsWith("/r/") ? "index.html" : normalize(ruta).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
|
|
49
|
+
try {
|
|
50
|
+
const cuerpo = await readFile(join(carpeta, relativa));
|
|
51
|
+
respuesta.writeHead(200, {
|
|
52
|
+
"content-type": TIPOS[extname(relativa)] ?? "application/octet-stream",
|
|
53
|
+
"x-content-type-options": "nosniff",
|
|
54
|
+
"referrer-policy": "no-referrer",
|
|
55
|
+
...(relativa === "index.html" ? { "cache-control": "no-store" } : {}),
|
|
56
|
+
});
|
|
57
|
+
respuesta.end(cuerpo);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
respuesta.writeHead(404).end();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function arrancarServidor(opciones) {
|
|
64
|
+
const topes = opciones.topes ?? TOPES_POR_OMISION;
|
|
65
|
+
const ahora = opciones.ahora ?? Date.now;
|
|
66
|
+
const salas = new Salas(topes, ahora);
|
|
67
|
+
const carpetaWeb = opciones.carpetaWeb ?? CARPETA_WEB_DEL_PAQUETE;
|
|
68
|
+
const http = createServer((peticion, respuesta) => void servirEstatico(carpetaWeb, peticion, respuesta));
|
|
69
|
+
const wss = new WebSocketServer({ server: http, path: "/ws", maxPayload: topes.bytesPorMensaje });
|
|
70
|
+
const vivos = new WeakMap();
|
|
71
|
+
const barrido = setInterval(() => {
|
|
72
|
+
salas.barrer();
|
|
73
|
+
for (const c of wss.clients) {
|
|
74
|
+
if (vivos.get(c) === false)
|
|
75
|
+
c.terminate();
|
|
76
|
+
else {
|
|
77
|
+
vivos.set(c, false);
|
|
78
|
+
c.ping();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, opciones.latidoMs ?? 10_000);
|
|
82
|
+
barrido.unref();
|
|
83
|
+
wss.on("connection", (ws) => {
|
|
84
|
+
let papel;
|
|
85
|
+
let cerrando = false;
|
|
86
|
+
const cortar = () => {
|
|
87
|
+
cerrando = true;
|
|
88
|
+
ws.close();
|
|
89
|
+
};
|
|
90
|
+
const enviar = (s) => {
|
|
91
|
+
if (ws.readyState === ws.OPEN)
|
|
92
|
+
ws.send(JSON.stringify(s));
|
|
93
|
+
// `cerrada` es lo último que oye un móvil: su sala ya no existe, y un socket que sigue abierto es un zombi.
|
|
94
|
+
if (s.t === "cerrada")
|
|
95
|
+
cortar();
|
|
96
|
+
};
|
|
97
|
+
vivos.set(ws, true);
|
|
98
|
+
ws.on("pong", () => vivos.set(ws, true));
|
|
99
|
+
const plazo = setTimeout(() => {
|
|
100
|
+
if (papel === undefined)
|
|
101
|
+
ws.terminate();
|
|
102
|
+
}, opciones.plazoPrimerMensajeMs ?? PLAZO_PRIMER_MENSAJE_MS);
|
|
103
|
+
plazo.unref();
|
|
104
|
+
// Hasta su primer mensaje nadie sabe qué es: va con el tope del móvil, el más estrecho.
|
|
105
|
+
const cubo = crearCubo(ahora, topes.mensajesPorMinuto);
|
|
106
|
+
ws.on("message", (datos) => {
|
|
107
|
+
if (cerrando || ws.readyState !== ws.OPEN)
|
|
108
|
+
return;
|
|
109
|
+
if (!cubo.gastar(papel?.tipo === "anfitrion" ? topes.mensajesPorMinutoAnfitrion : topes.mensajesPorMinuto)) {
|
|
110
|
+
enviar({ t: "error", motivo: "ritmo" });
|
|
111
|
+
cortar();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const texto = String(datos);
|
|
115
|
+
if (papel === undefined) {
|
|
116
|
+
const comoAnfitrion = interpretarSobre(texto, "anfitrion");
|
|
117
|
+
const comoMovil = interpretarSobre(texto, "movil");
|
|
118
|
+
const inicio = comoAnfitrion?.t === "abrir" ? comoAnfitrion : comoMovil?.t === "entrar" ? comoMovil : undefined;
|
|
119
|
+
if (inicio === undefined) {
|
|
120
|
+
enviar({ t: "error", motivo: "mensaje-ilegible" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (inicio.v !== VERSION_DEL_PROTOCOLO) {
|
|
124
|
+
enviar({ t: "error", motivo: "version" });
|
|
125
|
+
cortar();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!FORMA_DE_SALA.test(inicio.sala)) {
|
|
129
|
+
enviar({ t: "error", motivo: "mensaje-ilegible" });
|
|
130
|
+
cortar();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (inicio.t === "abrir") {
|
|
134
|
+
if (salas.abrir(inicio.sala, enviar))
|
|
135
|
+
papel = { tipo: "anfitrion", sala: inicio.sala };
|
|
136
|
+
else
|
|
137
|
+
cortar();
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const id = salas.entrar(inicio.sala, enviar);
|
|
141
|
+
if (id !== undefined)
|
|
142
|
+
papel = { tipo: "movil", sala: inicio.sala, id };
|
|
143
|
+
else
|
|
144
|
+
cortar();
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (papel.tipo === "anfitrion") {
|
|
149
|
+
const s = interpretarSobre(texto, "anfitrion");
|
|
150
|
+
if (s?.t === "dato")
|
|
151
|
+
salas.datoDelAnfitrion(papel.sala, s.para, s.carga);
|
|
152
|
+
else if (s?.t === "cerrar") {
|
|
153
|
+
salas.cerrar(papel.sala, enviar);
|
|
154
|
+
papel = undefined;
|
|
155
|
+
cortar();
|
|
156
|
+
}
|
|
157
|
+
else
|
|
158
|
+
enviar({ t: "error", motivo: "mensaje-ilegible" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const s = interpretarSobre(texto, "movil");
|
|
162
|
+
if (s?.t === "dato")
|
|
163
|
+
salas.datoDelMovil(papel.sala, papel.id, s.carga);
|
|
164
|
+
else
|
|
165
|
+
enviar({ t: "error", motivo: "mensaje-ilegible" });
|
|
166
|
+
});
|
|
167
|
+
// Un mensaje mayor que el tope emite `error` en el socket del servidor; sin oyente tumbaría el proceso.
|
|
168
|
+
// `ws` ya ha empezado el cierre con su código (1009 para el tamaño, `TOPE_DE_SOBRE`): terminar aquí
|
|
169
|
+
// cortaría el TCP antes de mandar ese marco y el cliente vería un 1006 sin motivo. Solo se termina
|
|
170
|
+
// lo que siga abierto; el cierre en curso lo remata el propio plazo de `ws`.
|
|
171
|
+
ws.on("error", () => {
|
|
172
|
+
cerrando = true;
|
|
173
|
+
if (ws.readyState === ws.OPEN)
|
|
174
|
+
ws.terminate();
|
|
175
|
+
});
|
|
176
|
+
ws.on("close", () => {
|
|
177
|
+
clearTimeout(plazo);
|
|
178
|
+
if (papel?.tipo === "anfitrion")
|
|
179
|
+
salas.anfitrionCaido(papel.sala, enviar);
|
|
180
|
+
if (papel?.tipo === "movil")
|
|
181
|
+
salas.salir(papel.sala, papel.id);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
await new Promise((resolver) => http.listen(opciones.puerto, opciones.host ?? "127.0.0.1", resolver));
|
|
185
|
+
const direccion = http.address();
|
|
186
|
+
const puerto = typeof direccion === "object" && direccion !== null ? direccion.port : opciones.puerto;
|
|
187
|
+
return {
|
|
188
|
+
urlHttp: `http://127.0.0.1:${puerto}`,
|
|
189
|
+
urlWs: `ws://127.0.0.1:${puerto}/ws`,
|
|
190
|
+
cerrar: async () => {
|
|
191
|
+
clearInterval(barrido);
|
|
192
|
+
for (const c of wss.clients)
|
|
193
|
+
c.terminate();
|
|
194
|
+
await new Promise((r) => wss.close(() => r()));
|
|
195
|
+
await new Promise((r) => http.close(() => r()));
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El tope de un sobre en BYTES, tal como viaja (el JSON entero del sobre). Quien manda tiene que
|
|
3
|
+
* mantener cada sobre por debajo: la carga va cifrada (AES-GCM) y en base64url, que suman en torno a
|
|
4
|
+
* un 33 % al contenido en claro. Por encima, el relé no contesta con un `error`: cierra el socket con
|
|
5
|
+
* el código 1009 de WebSocket (mensaje demasiado grande).
|
|
6
|
+
*/
|
|
7
|
+
export declare const TOPE_DE_SOBRE = 1000000;
|
|
8
|
+
export type MotivoDeError = "version" | "sala-inexistente" | "sala-ocupada" | "tope-de-moviles" | "ritmo" | "mensaje-ilegible";
|
|
9
|
+
export type SobreDelAnfitrion = {
|
|
10
|
+
t: "abrir";
|
|
11
|
+
sala: string;
|
|
12
|
+
v: number;
|
|
13
|
+
} | {
|
|
14
|
+
t: "dato";
|
|
15
|
+
para?: string;
|
|
16
|
+
carga: string;
|
|
17
|
+
} | {
|
|
18
|
+
t: "cerrar";
|
|
19
|
+
};
|
|
20
|
+
export type SobreDelMovil = {
|
|
21
|
+
t: "entrar";
|
|
22
|
+
sala: string;
|
|
23
|
+
v: number;
|
|
24
|
+
} | {
|
|
25
|
+
t: "dato";
|
|
26
|
+
carga: string;
|
|
27
|
+
};
|
|
28
|
+
export type SobreDelServidor = {
|
|
29
|
+
t: "abierta";
|
|
30
|
+
} | {
|
|
31
|
+
t: "entrada";
|
|
32
|
+
id: string;
|
|
33
|
+
} | {
|
|
34
|
+
t: "presencia";
|
|
35
|
+
moviles: string[];
|
|
36
|
+
} | {
|
|
37
|
+
t: "anfitrion";
|
|
38
|
+
conectado: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
t: "dato";
|
|
41
|
+
de?: string;
|
|
42
|
+
carga: string;
|
|
43
|
+
} | {
|
|
44
|
+
t: "error";
|
|
45
|
+
motivo: MotivoDeError;
|
|
46
|
+
} | {
|
|
47
|
+
t: "cerrada";
|
|
48
|
+
};
|
|
49
|
+
type Lado = "anfitrion" | "movil" | "servidor";
|
|
50
|
+
type SobreDe<L extends Lado> = L extends "anfitrion" ? SobreDelAnfitrion : L extends "movil" ? SobreDelMovil : SobreDelServidor;
|
|
51
|
+
/** Lee un sobre de un lado. Lo que no tiene exactamente una forma permitida es `undefined`. */
|
|
52
|
+
export declare function interpretarSobre<L extends Lado>(texto: string, lado: L): SobreDe<L> | undefined;
|
|
53
|
+
export {};
|