Oasis 0.9.5 con Karvan para escritorio
Rama de desarrollo experimental sobre el Oasis de epsylon, sin afiliacion con el proyecto original. Base: 0.9.5. Trae el modulo Karvan —salas efimeras en memoria con autodestruccion por TTL, chat que funciona sin JavaScript y llamadas de audio y video sobre WebRTC— con el enlace en el menu lateral por el mismo patron que el resto de modulos: renderKarvanLink calcado de renderPollsLink, entrada en modules_view y en la lista de /modules. Los estilos del modulo van en su propia hoja, styles/karvan.css, siguiendo el patron de highlight.css: asi se ve igual con cualquier tema, sin depender del tema movil. Sin servidores ICE de terceros: no hay STUN de fabrica, porque un STUN aprende la IP publica y el momento de cada llamada. Solo candidatos host salvo que se configure un TURN propio en oasis-config.json, con credenciales efimeras por el mecanismo REST de coturn. El codigo propio vive separado —views/fork/, backend/fork_routes.js, models/karvan_model.js y translations/fork/— de modo que sobre los ficheros de upstream solo hay enganches de una linea y cada version nueva se integra sin arrastrar nada. Respecto al arbol de Android: fuera el wrapper y main.js, que es su arranque; el tema vuelve a Dark-SNH y se restauran los modulos que en movil se recortan por peso. Verificado arrancando sin OASIS_MOBILE: nueve rutas OK, menu lateral de upstream con 27 grupos y el enlace de Karvan, cero rastro de la interfaz movil (ni hexagonos, ni topbar, ni barra inferior), y una sala creada y servida.
This commit is contained in:
commit
ddd2787b73
286 changed files with 157145 additions and 0 deletions
62
docs/devs/architecture/00-README.md
Normal file
62
docs/devs/architecture/00-README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Guía de desarrollo de Oasis
|
||||
|
||||
Esta carpeta documenta cómo está construido Oasis por dentro, para que puedas
|
||||
entenderlo y extenderlo (por ejemplo, creando un **módulo de juegos** nuevo).
|
||||
|
||||
Está pensada para leerse en orden, pero cada archivo es autónomo.
|
||||
|
||||
## Índice
|
||||
|
||||
1. **[01-red-ssb.md](01-red-ssb.md)** — Cómo funciona Oasis **a nivel de red**:
|
||||
Secure Scuttlebutt (SSB), secret-stack, multiserver, muxrpc, EBT, blobs,
|
||||
federación P2P, puertos y cifrado.
|
||||
|
||||
2. **[02-arquitectura-software.md](02-arquitectura-software.md)** — La
|
||||
**arquitectura de software** del backend: el patrón MVC casero
|
||||
(models / views / backend.js), el stack de dependencias, el "cooler", el
|
||||
flujo de una petición y cómo se interrelacionan los archivos.
|
||||
|
||||
3. **[03-modulos-y-juegos.md](03-modulos-y-juegos.md)** — Cómo está construido
|
||||
el **módulo de juegos** actual y la anatomía de un módulo en general.
|
||||
|
||||
4. **[04-tutorial-modulo-desde-cero.md](04-tutorial-modulo-desde-cero.md)** —
|
||||
**Tutorial paso a paso** para crear un módulo nuevo desde cero (caso práctico:
|
||||
un módulo de juegos propio), con todos los puntos de integración.
|
||||
|
||||
5. **[05-mapa-funciones.txt](05-mapa-funciones.txt)** — **Gran mapa ASCII** de
|
||||
cómo se relacionan las funciones lógicas entre archivos, la persistencia
|
||||
(SSB) y la media (blobs).
|
||||
|
||||
## Conceptos en 30 segundos
|
||||
|
||||
Oasis es un **cliente web de Secure Scuttlebutt (SSB)**. SSB es un protocolo
|
||||
social **P2P, distribuido y federado**: no hay servidor central; cada nodo tiene
|
||||
una copia local de un *log append-only* firmado criptográficamente, y los nodos
|
||||
se replican entre sí por *gossip*.
|
||||
|
||||
Oasis se compone de **dos procesos**:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐ ┌──────────────────────────┐
|
||||
│ SBOT (nodo de red SSB) │ <─────> │ FRONTEND WEB (Koa/HTML) │
|
||||
│ src/server/SSB_server │ unix │ src/backend/backend.js │
|
||||
│ habla con otros peers │ socket │ habla con tu navegador │
|
||||
└─────────────────────────┘ └──────────────────────────┘
|
||||
puerto 8008 (SSB) puerto 3000 (HTTP)
|
||||
```
|
||||
|
||||
- El **sbot** (`src/server/SSB_server.js`) es el nodo SSB: se conecta a otros
|
||||
peers, replica feeds, sirve y descarga blobs. → [01-red-ssb.md](01-red-ssb.md)
|
||||
- El **frontend** (`src/backend/backend.js`) es un servidor HTTP en Koa que
|
||||
renderiza HTML puro (sin JS de navegador) y se conecta al sbot como **cliente
|
||||
muxrpc** por un socket Unix local. → [02-arquitectura-software.md](02-arquitectura-software.md)
|
||||
|
||||
El patrón para **cada funcionalidad** ("módulo") es siempre el mismo:
|
||||
|
||||
```
|
||||
navegador → backend.js (ruta) → *_model.js (lee/escribe SSB) → *_view.js (HTML) → navegador
|
||||
```
|
||||
|
||||
> Las rutas, líneas y nombres citados en estos documentos corresponden a la
|
||||
> versión 0.8.1 del código. Si editas el código, vuelve a comprobar los números
|
||||
> de línea con `grep`.
|
||||
243
docs/devs/architecture/01-red-ssb.md
Normal file
243
docs/devs/architecture/01-red-ssb.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# 1. Oasis a nivel de red (Secure Scuttlebutt)
|
||||
|
||||
Oasis es un cliente/servidor de **Secure Scuttlebutt (SSB)**. Toda la "red" de
|
||||
Oasis se construye sobre la pila clásica del ecosistema SSB:
|
||||
`secret-stack` + `ssb-db` + un montón de plugins.
|
||||
|
||||
Hay una separación clara en **dos procesos**:
|
||||
|
||||
- **Backend SSB (sbot)** — `src/server/SSB_server.js`: el nodo de red propiamente
|
||||
dicho. Es quien habla con otros peers.
|
||||
- **Frontend web (GUI Koa)** — `src/backend/backend.js`: un servidor HTTP que
|
||||
actúa como **cliente muxrpc** del sbot, conectándose por un socket Unix local.
|
||||
|
||||
```
|
||||
RED SSB (otros peers Oasis, misma SHS cap)
|
||||
│
|
||||
net:IP:8008~shs:<pubkey> · ssb-lan (UDP) · ssb-onion (Tor)
|
||||
│
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ SBOT (src/server/SSB_server.js) │
|
||||
│ peer <-> MULTISERVER <-> SECRET-STACK (SHS) │
|
||||
│ <-> MUXRPC <-> PLUGINS │
|
||||
└─────────────────────────┬───────────────────┘
|
||||
│ unix socket ~noauth (muxrpc local, sin SHS)
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ FRONTEND (src/backend/backend.js, Koa) │
|
||||
│ cooler = src/client/gui.js → ssb-client │
|
||||
│ app.listen({host, port:3000}) │
|
||||
└─────────────────────────┬───────────────────┘
|
||||
▼
|
||||
Navegador http://localhost:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.1 Arranque de red
|
||||
|
||||
### Scripts de arranque
|
||||
|
||||
- **`install.sh`**: instala Node 22 y hace `npm install` en `src/server`
|
||||
(línea 23). Opcionalmente descarga modelos de IA. No toca la red.
|
||||
- **`oasis.sh`** es el lanzador. Modos relevantes:
|
||||
- `server` / `pub` (líneas 61-69): ejecuta `node SSB_server.js start` — **solo
|
||||
el sbot headless**, sin interfaz web.
|
||||
- `gui` o por defecto (líneas 73-81): ejecuta `node backend.js` — el frontend.
|
||||
- comandos pub `whoami|invite|name|announce|follow|status|gossip`
|
||||
(líneas 70-72) → delegan en `scripts/oasis-pub.js`.
|
||||
- **`src/server/package.json`**, script `start`:
|
||||
`npm run start:ssb && sleep 10 && npm run start:backend`.
|
||||
Arranca el sbot en segundo plano, **espera 10 s** (para que el socket Unix
|
||||
exista) y luego arranca el frontend.
|
||||
|
||||
### Qué hace `SSB_server.js` paso a paso
|
||||
|
||||
(`src/server/SSB_server.js`)
|
||||
|
||||
1. **Líneas 7-11**: carga `secret-stack`, `ssb-caps` (la capability SHS),
|
||||
`ssb-db`, la config (`./ssb_config`) y utilidades de metadatos.
|
||||
2. **Líneas 13-52** (IIFE): *monkey-patch* de `console.error`/`console.warn`
|
||||
para silenciar ruido de red — rechazos de handshake por SHS cap incorrecta,
|
||||
excepciones EBT, etc. Un rechazo se convierte en un log limpio:
|
||||
`[ts] REJECTED <ip:port> (wrong SHS cap)`.
|
||||
3. **Líneas 56-81**: construye el servidor con
|
||||
`SecretStack({ caps }).use(plugin).use(plugin)...` encadenando todos los
|
||||
plugins (ver tabla abajo).
|
||||
4. **Líneas 83-86**: si **no** es pub, carga `ssb-lan` (descubrimiento LAN por
|
||||
UDP) y el router LAN local `./lanRouter`.
|
||||
5. **Líneas 88-98**: lógica de `autofollow`; carga `ssb-autofollow` si hay feeds.
|
||||
6. **Líneas 123-196** (`argv[0] === 'start'`): instancia el servidor, escribe
|
||||
`manifest.json` (el catálogo de métodos muxrpc remotos), monta una barra de
|
||||
progreso de replicación, imprime metadatos y **escucha eventos del hub de
|
||||
conexiones** (`server.conn.hub().listen()`) para loguear `CONNECTED` /
|
||||
`DISCONNECTED`.
|
||||
7. **Líneas 198-202**: exporta `{ config, server, open() }` — esto permite a la
|
||||
GUI reusar el servidor **in-process** si corre en el mismo proceso.
|
||||
|
||||
### Plugins secret-stack cargados (líneas 56-98)
|
||||
|
||||
| Plugin | Función de red |
|
||||
|---|---|
|
||||
| `ssb-db` | base de datos *append-only* de feeds |
|
||||
| `ssb-master` | identidad maestra / auth local |
|
||||
| `ssb-gossip` | gossip clásico (legacy) de peers |
|
||||
| `ssb-ebt` | **Epidemic Broadcast Trees** — replicación gossip eficiente |
|
||||
| `ssb-friends` | grafo social: hops, follows/blocks |
|
||||
| `ssb-blobs` | transferencia de ficheros binarios |
|
||||
| `ssb-meme` | blobs de imágenes/memes |
|
||||
| `ssb-conn` | gestor de conexiones moderno (hub, db, staging) |
|
||||
| `ssb-box` | cifrado de mensajes privados (box1) |
|
||||
| `ssb-search`, `ssb-private`, `ssb-friend-pub` | búsqueda, PM, pub de amigos |
|
||||
| `ssb-invite` / `ssb-invite-client` | invites (servidor si es pub, cliente si no) |
|
||||
| `ssb-replication-scheduler`, `ssb-partial-replication` | planificación de replicación |
|
||||
| `ssb-onion` | transporte Tor |
|
||||
| `ssb-unix-socket` | **socket Unix local** para clientes (la GUI) |
|
||||
| `ssb-no-auth` | conexiones locales sin handshake SHS |
|
||||
| `ssb-about`, `ssb-backlinks`, `ssb-links`, `ssb-tangle`, `ssb-query` | índices de vistas |
|
||||
| `ssb-lan` + `lanRouter` | descubrimiento LAN (solo si no es pub) |
|
||||
|
||||
> Nota: la replicación NO usa `ssb-replicate` directamente; la orquestan
|
||||
> `ssb-ebt` + `ssb-replication-scheduler` + `ssb-friends`.
|
||||
|
||||
---
|
||||
|
||||
## 1.2 El protocolo SSB en términos del proyecto
|
||||
|
||||
- **secret-stack + handshake SHS (capability).** `SecretStack({ caps })` crea el
|
||||
nodo. La `caps.shs` está **fijada en `src/configs/server-config.json`**
|
||||
(líneas 5-7): `"H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="`. Es la **clave
|
||||
de capacidad de red** (Secret Handshake): dos peers solo completan el
|
||||
handshake si comparten *exactamente* esta cap. Actúa como "contraseña de red":
|
||||
**Oasis forma su propia red SSB privada**, distinta de la red SSB pública.
|
||||
|
||||
- **multiserver (transportes).** Capa de transporte abstracta. La config
|
||||
`connections` (`server-config.json`, líneas 29-60) define:
|
||||
- **incoming.net**: `transform: "shs"`, `port: 8008` → TCP cifrado con SHS.
|
||||
- **incoming.unix**: `transform: "noauth"` → socket Unix local sin handshake,
|
||||
para que el frontend se conecte como cliente privilegiado.
|
||||
- **outgoing.net**: `transform: "shs"`.
|
||||
- Una dirección multiserver típica: `net:IP:8008~shs:<pubkey-base64>`.
|
||||
|
||||
- **muxrpc (RPC sobre el stream).** Una vez establecido el stream SHS, `muxrpc`
|
||||
multiplexa llamadas RPC asíncronas y *pull-streams* sobre la misma conexión.
|
||||
El `manifest.json` describe qué métodos remotos existen (`sync`/`async`/
|
||||
`source`). Es como el frontend invoca `ssb.publish(...)`, `ssb.blobs.get(...)`,
|
||||
etc.
|
||||
|
||||
- **EBT (`epidemic-broadcast-trees` + `ssb-ebt`).** El algoritmo de replicación
|
||||
gossip. Cada peer anuncia el `seq` (reloj vectorial) de los feeds que tiene;
|
||||
el otro envía solo los mensajes que faltan, formando árboles de difusión
|
||||
epidémica que evitan duplicados.
|
||||
|
||||
- **Identidad de los peers.** Cada peer es un **feed id ed25519**:
|
||||
`@<base64>.ed25519`. En las direcciones multiserver, el sufijo
|
||||
`~shs:<pubkey>` identifica criptográficamente al peer durante el handshake.
|
||||
|
||||
---
|
||||
|
||||
## 1.3 Federación / P2P
|
||||
|
||||
- **Descubrimiento LAN** (`src/server/lanRouter.js`): plugin local custom.
|
||||
`startRouter` (líneas 55-64) arranca `ssb.lan.start()` (broadcast UDP) y
|
||||
consume `ssb.lan.discoveredPeers()`. Cada peer descubierto pasa por
|
||||
`handleDiscovery` → `stagePeer` (líneas 15-45): lo *stagea* en
|
||||
`ssb.conn.stage(...)` (vía moderna) con fallback a `ssb.gossip.add(...)`
|
||||
(legacy); si `eagerReplicate`, fuerza `ebt.request`. Respeta el flag
|
||||
`lanBroadcasting` de `oasis-config.json`.
|
||||
|
||||
- **Pubs / invites.** En modo pub se carga `ssb-invite` (servidor); en cliente,
|
||||
`ssb-invite-client`. Los comandos `oasis.sh invite|announce|follow`
|
||||
(→ `scripts/oasis-pub.js`) permiten a un pub publicar su dirección y generar
|
||||
códigos de invitación. Un invite contiene `host:port:pubkey~seed`; al
|
||||
canjearlo, el peer se conecta y el pub le sigue de vuelta (`ssb-friend-pub`).
|
||||
|
||||
- **Replicación por hops.** `ssb-friends` define el grafo social.
|
||||
`server-config.json` fija `friends.hops: 2` → el nodo replica los feeds de la
|
||||
gente que sigues y de la gente que ellos siguen (amigos de amigos). El
|
||||
`ssb-replication-scheduler` decide qué feeds pedir por EBT según ese grafo.
|
||||
|
||||
- **"Distributed not decentralized".** No hay servidor central ni DHT global.
|
||||
Cada nodo Oasis tiene su **propia copia local** de la base de datos
|
||||
(logs *append-only* firmados con ed25519) y solo replica los feeds dentro de
|
||||
su radio social (hops) más los descubiertos por LAN. La red se federa por
|
||||
gossip entre peers que comparten la misma SHS cap. Es **distribuida** (datos
|
||||
replicados en muchos nodos autónomos, funciona offline) pero **no
|
||||
descentralizada** como una blockchain (no hay consenso global ni vista única;
|
||||
cada quien ve solo su subgrafo). El flag `wish` de `oasis-config.json`
|
||||
(`whole` / `mutuals` / `only-lan`) controla el alcance de replicación deseado.
|
||||
|
||||
---
|
||||
|
||||
## 1.4 Puertos y endpoints
|
||||
|
||||
| Qué | Dónde | Puerto / dirección |
|
||||
|---|---|---|
|
||||
| muxrpc SSB (sbot) — otros peers se conectan aquí | `server-config.json` incoming.net | **8008/TCP** transform `shs` |
|
||||
| Socket Unix local — para el frontend | incoming.unix | `unix:<ssb.path>/socket~noauth:<pubkey>` |
|
||||
| Frontend web (Koa) — tu navegador | `oasis_client.js` / `middleware.js:180` | **3000** host `localhost` (configurable con `--host`/`--port`) |
|
||||
|
||||
**Cómo se conecta el frontend al sbot** (el "cooler", `src/client/gui.js`):
|
||||
|
||||
1. Intenta `require('../server/SSB_server')` y reusar el `server` **in-process**
|
||||
si existe (sin socket, uso directo del objeto).
|
||||
2. Si no, construye `remote = unix:<path>/socket~noauth:<pubkey>` y se conecta
|
||||
con **`ssb-client`** (muxrpc por el socket Unix `noauth`).
|
||||
3. `attemptConnectionWithBackoff`: reintentos con backoff exponencial (de ahí el
|
||||
`sleep 10` del arranque).
|
||||
4. Expone `cooler.open()` / `cooler.close()`. **Todos los modelos** del backend
|
||||
reciben este `cooler` y hacen `await cooler.open()` para obtener el cliente
|
||||
muxrpc.
|
||||
|
||||
> Para exponer la GUI en un VPS se usa `--host=0.0.0.0`.
|
||||
|
||||
---
|
||||
|
||||
## 1.5 Blobs (ficheros / imágenes)
|
||||
|
||||
- Plugin `ssb-blobs` (+ `ssb-meme`). Tamaño máximo en `ssb_config.js`: 50 MB.
|
||||
- Un blob se referencia por hash: `&<sha256-base64>.sha256`. **Los mensajes del
|
||||
log solo llevan el hash**, no el binario.
|
||||
- Flujo en `src/backend/blobHandler.js`:
|
||||
- **Subida**: `ssbClient.blobs.add(...)` recibe el contenido por pull-stream y
|
||||
devuelve el ref `&...sha256`.
|
||||
- **Descarga por red**: `blobs.has(id)` comprueba si ya está local; si no,
|
||||
`blobs.want(id)` **lo pide a los peers conectados** (gossip de "want/has"
|
||||
sobre muxrpc), y `blobs.get(id)` lo lee como stream.
|
||||
|
||||
Los blobs viajan sobre la misma conexión SHS/muxrpc, fuera del log append-only.
|
||||
|
||||
---
|
||||
|
||||
## 1.6 Caps / cifrado
|
||||
|
||||
- **`ssb-caps`** define las capabilities de protocolo (shs, sign, invite). La
|
||||
**shs cap real** está fijada en `server-config.json` → red privada de Oasis.
|
||||
- **Handshake SHS**: autenticación mutua por ed25519 + confidencialidad del
|
||||
stream de transporte.
|
||||
- **`ssb-box`**: cifrado de **mensajes privados box1** (hasta ~7 destinatarios,
|
||||
cifrado por clave pública de cada feed). `ssb-private` expone publish/read.
|
||||
- **Grupos privados ("tribes").** No se usa `box2`/`ssb-tribes`. En su lugar
|
||||
Oasis implementa cifrado **propio a nivel de aplicación** en
|
||||
`src/models/crypto.js`, instanciado por dominio en `backend.js`
|
||||
(`tribeCrypto`, `chatCrypto`, `padCrypto`, `mapCrypto`, `calendarCrypto`,
|
||||
`eventCrypto`, `forumCrypto`). Los grupos se cifran con esas claves derivadas
|
||||
y se almacenan/replican como mensajes normales del feed.
|
||||
|
||||
---
|
||||
|
||||
## 1.7 Archivos clave de la capa de red
|
||||
|
||||
| Archivo | Rol |
|
||||
|---|---|
|
||||
| `src/server/SSB_server.js` | Construye el sbot y carga los plugins (56-98) |
|
||||
| `src/server/ssb_config.js` | Fusiona `server-config.json`; fija `blobs.max=50MB` |
|
||||
| `src/server/ssb_metadata.js` | Deriva e imprime puerto SSB, hops, LAN, feed ID |
|
||||
| `src/server/lanRouter.js` | Descubrimiento/staging de peers LAN |
|
||||
| `src/configs/server-config.json` | `caps.shs`, `pub`, `friends.hops`, `connections` (puertos) |
|
||||
| `src/configs/oasis-config.json` | `lanBroadcasting`, `wish`, `pmVisibility`, módulos |
|
||||
| `src/client/gui.js` | El "cooler": cliente muxrpc por socket Unix noauth |
|
||||
| `src/backend/blobHandler.js` | add/has/want/get de blobs |
|
||||
| `scripts/oasis-pub.js` | Comandos pub (invite/announce/follow) |
|
||||
|
||||
Siguiente: **[02-arquitectura-software.md](02-arquitectura-software.md)**.
|
||||
350
docs/devs/architecture/02-arquitectura-software.md
Normal file
350
docs/devs/architecture/02-arquitectura-software.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# 2. Arquitectura de software del backend
|
||||
|
||||
Oasis usa un patrón **MVC casero** (sin framework MVC):
|
||||
|
||||
```
|
||||
src/backend/backend.js → el ROUTER monolítico (Koa, ~7250 líneas, ~472 rutas)
|
||||
src/models/*_model.js → la lógica de DATOS (lee/escribe SSB)
|
||||
src/views/*_view.js → el RENDER de HTML (con hyperaxe)
|
||||
```
|
||||
|
||||
Flujo de una funcionalidad:
|
||||
|
||||
```
|
||||
navegador → backend.js (ruta) → xModel.metodo() (SSB) → xView(data) (HTML) → ctx.body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Stack de software (dependencias clave)
|
||||
|
||||
Todas las dependencias están en `src/server/package.json`. Importante: casi
|
||||
todos los `require` usan **ruta relativa explícita** al node_modules del
|
||||
servidor, p.ej. `require("../server/node_modules/@koa/router")`.
|
||||
|
||||
**Capa web / HTTP**
|
||||
- **`koa`** — servidor HTTP base. Se instancian varias apps Koa en
|
||||
`src/client/middleware.js` (principal + sub-apps para estáticos).
|
||||
- **`@koa/router`** — enrutado. `backend.js:442` instancia
|
||||
`const router = new koaRouter()`. Toda la API es **una única cadena fluida**
|
||||
`router.param(...).get(...).post(...)` (líneas ~1270-7124).
|
||||
- **`koa-static` / `koa-mount`** — sirven assets estáticos (`/assets`,
|
||||
`/maptiles`, `/game-assets`, `/js`...) montados en `middleware.js`.
|
||||
- **`koa-body`** — parseo de `multipart/form-data` y POST. Se aplica por ruta:
|
||||
`.post('/votes/create', koaBody(), ...)`.
|
||||
- `express` y `cors` figuran como dependencias pero **el flujo principal es
|
||||
Koa**; Express no se usa en el router.
|
||||
|
||||
**Motor de plantillas (vistas)**
|
||||
- **`hyperaxe`** — hyperscript → HTML. **No hay archivos `.html`**; el HTML se
|
||||
construye programáticamente desde JS: `div(...)`, `nav(...)`, `form(...)`...
|
||||
Cada elemento expone `.outerHTML`.
|
||||
- **`ssb-markdown` + `ssb-msgs` + `ssb-ref`** — render de Markdown SSB con
|
||||
resolución de menciones/enlaces internos (`src/views/markdown.js`).
|
||||
- **`highlight.js`, `qrcode`, `moment`, `pretty-ms`** — sintaxis, QR, fechas.
|
||||
- **`dompurify` + `jsdom` + `is-svg`** — saneado anti-XSS
|
||||
(`src/backend/sanitizeHtml.js`).
|
||||
|
||||
**Streams SSB**
|
||||
- **`pull-stream`** — el paradigma de streams de SSB. Es el mecanismo central de
|
||||
lectura: `pull(source, pull.filter(...), pull.collect(cb))`.
|
||||
- Auxiliares: `pull-paramap` (map asíncrono), `pull-sort`, `pull-abortable`
|
||||
(cancelar streams `live`), `pull-cat`, `pull-many`, `pull-pushable`.
|
||||
|
||||
**Cliente / servidor SSB**
|
||||
- **`ssb-client`** — cliente RPC muxrpc para conectar al sbot (en `gui.js`).
|
||||
- **`ssb-config`, `ssb-keys`, `ssb-ref`** — config, claves ed25519, validación
|
||||
de identificadores (`@feed`, `%msg`, `&blob`).
|
||||
- **`secret-stack` + `ssb-caps` + `ssb-db`** y los plugins SSB → ver
|
||||
[01-red-ssb.md](01-red-ssb.md).
|
||||
|
||||
**Otros**: `module-alias`, `lodash`, `minimist`, `open`, `archiver`,
|
||||
`file-type`, `pdfjs-dist`, `openpgp`, `axios`, `node-llama-cpp` +
|
||||
`@xenova/transformers` (módulo IA local).
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Flujo de una petición
|
||||
|
||||
**a) Construcción del servidor** — `src/client/middleware.js`: la función
|
||||
exportada `({host, port, middleware, allowHost})` crea la app Koa, monta
|
||||
estáticos, fija cabeceras de seguridad (CSP, `X-Frame-Options`...), valida el
|
||||
host, aplica los middlewares recibidos y finalmente `app.listen({host, port})`
|
||||
(línea 180). En `backend.js:7239`:
|
||||
`const app = http({ host, port, middleware, allowHost: config.allowHost })`.
|
||||
|
||||
**b) Cadena de middleware** — `backend.js:7126-7238`: incluye (1) bloqueo de
|
||||
no-GET en modo público, (2) `setLanguage(...)` por cookie, (3) gate de
|
||||
indexación (muestra `indexingView` si la base no está sincronizada), (4) refresco
|
||||
de estado compartido cada 60 s, y por último `routes` (las rutas del router).
|
||||
|
||||
**c) Ruta GET — ejemplo agenda** (`backend.js:1977-1982`):
|
||||
|
||||
```js
|
||||
.get('/agenda', async (ctx) => {
|
||||
const filter = qf(ctx);
|
||||
let data = await agendaModel.listAgenda(filter); // MODELO: lee SSB
|
||||
if (Array.isArray(data)) data = await applyListFilters(data, ctx);
|
||||
ctx.body = await agendaView(data, filter); // VISTA: genera HTML
|
||||
})
|
||||
```
|
||||
|
||||
`agendaModel.listAgenda` abre el cliente (`cooler.open()`), lee el log
|
||||
(`createLogStream`), agrega ítems y devuelve datos. `agendaView` los recibe y
|
||||
construye HTML con hyperaxe, envuelto en `template(...)`. El HTML se asigna a
|
||||
`ctx.body` y Koa lo sirve.
|
||||
|
||||
**d) Ruta POST — ejemplo votos** (`backend.js:5572`):
|
||||
|
||||
```js
|
||||
.post('/votes/create', koaBody(), async ctx => {
|
||||
const b = ctx.request.body;
|
||||
const parsedOptions = b.options ? b.options.split(',')... : defaultOptions;
|
||||
await votesModel.createVote(stripDangerousTags(b.question), b.deadline, parsedOptions, ...);
|
||||
ctx.redirect(safeReturnTo(ctx, '/votes?filter=mine', ['/votes']));
|
||||
})
|
||||
```
|
||||
|
||||
El POST **sanea** la entrada (`stripDangerousTags`), llama al modelo que
|
||||
**publica un mensaje SSB**, y **redirige** (patrón Post/Redirect/Get). El GET
|
||||
posterior vuelve a leer del modelo y renderiza.
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Conexión al servidor SSB: el "cooler"
|
||||
|
||||
El **cooler** es la abstracción que gestiona la conexión al sbot. Se define en
|
||||
`src/client/gui.js` (importado en `backend.js:441` como
|
||||
`const ssb = require("../client/gui")`) y se instancia en `backend.js:540`:
|
||||
|
||||
```js
|
||||
const cooler = ssb({ offline: config.offline, port: config.port,
|
||||
host: config.host, isPublic: config.public });
|
||||
```
|
||||
|
||||
- Si existe un sbot **en el mismo proceso** (`internalSSB`), `cooler.open()` lo
|
||||
devuelve directamente sin RPC.
|
||||
- Si no, conecta por muxrpc vía socket Unix `noauth` con backoff exponencial.
|
||||
|
||||
**Patrón de uso en todo el código**:
|
||||
`const ssb = await cooler.open();` y luego se usan los métodos muxrpc:
|
||||
|
||||
| Método | Para qué |
|
||||
|---|---|
|
||||
| `ssb.createUserStream({id, reverse, limit})` | feed de un usuario |
|
||||
| `ssb.createLogStream({reverse, limit})` | log global (lo más usado) |
|
||||
| `ssb.query.read({query:[{$filter:{...}}]})` | consultas ssb-query |
|
||||
| `ssb.messagesByType({type, private})` | mensajes por tipo |
|
||||
| `ssb.backlinks.read({query})` | referencias/backlinks |
|
||||
| `ssb.get(id, cb)` | un mensaje por id |
|
||||
| `ssb.publish(content, cb)` | **publicar un mensaje** |
|
||||
| `ssb.blobs.want/get/add` | blobs |
|
||||
| `ssb.friends.isFollowing/isBlocking/graph` | grafo social |
|
||||
| `ssb.conn.peers / ssb.status() / ssb.progress()` | estado de red |
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Patrón de un MODELO
|
||||
|
||||
Todos los modelos siguen la **factory pattern**:
|
||||
`module.exports = ({ cooler, isPublic, ...deps }) => ({ ...métodos })`. Reciben
|
||||
el `cooler` por inyección desde `backend.js` (líneas 553-602). Muchos cachean el
|
||||
cliente:
|
||||
|
||||
```js
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||
```
|
||||
|
||||
**Publicar (escribir)** — `ssb.publish(content, cb)` con un campo `type`.
|
||||
Ejemplo `votes_model.js` (`createVote`):
|
||||
|
||||
```js
|
||||
const content = {
|
||||
type: 'votes', question, options, deadline, createdBy: userId,
|
||||
status: 'OPEN', votes: {...}, totalVotes: 0, createdAt: new Date().toISOString()
|
||||
};
|
||||
return new Promise((res, rej) =>
|
||||
ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
||||
```
|
||||
|
||||
**Leer** — `pull-stream` + `collect`:
|
||||
|
||||
```js
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, results) => err ? reject(err) : resolve(results))
|
||||
);
|
||||
```
|
||||
|
||||
Luego se procesa en memoria: se construye un índice, se filtra por `type`, se
|
||||
ordena.
|
||||
|
||||
**Borrar / editar — modelo de tombstone** (el log es *append-only*: nada se
|
||||
borra físicamente):
|
||||
|
||||
- **Borrado lógico**: se publica `{ type:'tombstone', target:<msgId>, deletedAt,
|
||||
author }`.
|
||||
- **Validación** (`src/models/tombstone_validator.js`,
|
||||
`buildValidatedTombstoneSet`): solo acepta el tombstone si **el autor del
|
||||
tombstone coincide con el autor del mensaje objetivo** — evita borrados por
|
||||
terceros.
|
||||
- **Edición**: se publica una versión nueva con `replaces: <idAnterior>` y se
|
||||
lapida la anterior, formando una cadena de versiones; al leer se sigue la
|
||||
cadena hasta la "punta".
|
||||
- Las comprobaciones de autoría (`if (c.createdBy !== userId) throw`) se hacen en
|
||||
el modelo.
|
||||
|
||||
**Tipos de mensaje SSB** (campo `content.type`): los nativos SSB `post`, `vote`
|
||||
(likes), `contact` (follow/block), `about` (perfil), y los **propios de Oasis**:
|
||||
`votes`, `tombstone`, `task`, `event`, `calendar`, `transfer`, `tribe`,
|
||||
`market`, `report`, `job`, `project`, `gameScore`, etc.
|
||||
|
||||
> **`main_models.js`** es el modelo núcleo: exporta `about`, `blob`, `friend`,
|
||||
> `meta`, `post`, `vote`, `spreads`, `lifetime`. Su función `transform()`
|
||||
> enriquece cada mensaje con metadatos (autor, avatar, votos, timestamps) antes
|
||||
> de pasarlo a las vistas.
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Patrón de una VISTA
|
||||
|
||||
Las vistas exportan funciones que reciben datos del modelo y devuelven HTML.
|
||||
Patrón típico:
|
||||
|
||||
```js
|
||||
const { div, h2, p, section, button, form } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink } = require('./main_views');
|
||||
|
||||
exports.agendaView = async (data, filter) =>
|
||||
template(i18n.agendaTitle, section( h2(i18n.agendaTitle), /* ... */ ));
|
||||
```
|
||||
|
||||
**Layout común — `main_views.js`**:
|
||||
- `template(titlePrefix, ...elements)` construye el documento completo:
|
||||
`html → head (title, CSS, favicon) → body (header, sidebar de navegación,
|
||||
main-content)`. Se exporta como `exports.template`.
|
||||
- **Navegación**: helper `navLink({href, emoji, text, current})` genera cada
|
||||
`<li><a>`. `navGroup({id, emoji, title})` agrupa enlaces en secciones
|
||||
colapsables del menú lateral. Cada módulo tiene su `renderXxxLink()` que solo
|
||||
emite el enlace si el módulo está activo en config.
|
||||
- **i18n**: `i18n` es un objeto compartido importado por todas las vistas.
|
||||
`setLanguage(lang)` lo muta in-place (11 idiomas:
|
||||
`en, es, fr, eu, de, it, pt, zh, ar, hi, ru` en
|
||||
`src/client/assets/translations/`). El middleware llama `setLanguage(...)` en
|
||||
cada petición. Las cadenas se referencian como `i18n.agendaTitle`, con
|
||||
fallback `i18n.x || 'Default'`. **Nunca texto literal en las vistas.**
|
||||
- **Temas**: `const theme = currentConfig.themes.current || "Dark-SNH"` →
|
||||
`<link href="/assets/themes/${theme}.css">`.
|
||||
- **Helpers reutilizables** exportados por `main_views`: `userLink`,
|
||||
`renderStateChip`, `renderVisibilityChip`, `renderSpreadButton`, `errorView`,
|
||||
`formatCarbon`, `markdown(input, mentions)`.
|
||||
|
||||
---
|
||||
|
||||
## 2.6 Configuración
|
||||
|
||||
**`src/configs/config-manager.js`** — gestor central de `oasis-config.json`:
|
||||
- Si el archivo no existe, escribe un `defaultConfig`.
|
||||
- `getConfig()` lee y parsea el JSON **en cada llamada** (sin caché → cambios en
|
||||
caliente).
|
||||
- `saveConfig(newConfig)` reescribe el archivo.
|
||||
|
||||
Qué controla **`oasis-config.json`**:
|
||||
- `themes.current` — tema CSS activo.
|
||||
- `ux.current` — modo de interfaz (`"blocks"` o `"ainav"`).
|
||||
- **`modules`** — ~50 flags `<nombre>Mod: "on"|"off"` que activan/desactivan cada
|
||||
módulo. En backend se comprueba con `checkMod(ctx, mod)` (líneas 170-177): si
|
||||
está `off`, redirige a `/modules`.
|
||||
- `wallet`/`walletPub`, `ai.prompt`, `ssbLogStream.limit` (límite de mensajes
|
||||
leídos del log), `homePage`, `language`, `wish`, `pmVisibility`,
|
||||
`lanBroadcasting`.
|
||||
|
||||
**`src/configs/shared-state.js`** — estado en memoria (no persistente):
|
||||
contador de inbox, huella de carbono, nº de peers online, valor eco, etc.
|
||||
Lo actualiza el middleware periódico y lo consume `template`.
|
||||
|
||||
---
|
||||
|
||||
## 2.7 Tabla de módulos (modelo ↔ vista)
|
||||
|
||||
| Modelo | Vista | Notas |
|
||||
|---|---|---|
|
||||
| `main_models.js` | `main_views.js` | Núcleo: layout, i18n, temas, about/blob/friend |
|
||||
| `activity_model` | `activity_view` | Feed de actividad |
|
||||
| `agenda_model` | `agenda_view` | Agrega tasks/events/transfers/market/... |
|
||||
| `audios_model` | `audio_view` | |
|
||||
| `banking_model` | `banking_views` | ECOin, epochs |
|
||||
| `blockchain_model` | `blockchain_view` | |
|
||||
| `bookmarking_model` | `bookmark_view` | |
|
||||
| `calendars_model` | `calendars_view` | Cifrado |
|
||||
| `chats_model` | `chats_view` | Cifrado |
|
||||
| `cipher_model` | `cipher_view` | |
|
||||
| `courts_model` | `courts_view` | |
|
||||
| `cv_model` | `cv_view` | |
|
||||
| `documents_model` | `document_view` | |
|
||||
| `events_model` | `event_view` | |
|
||||
| `favorites_model` | `favorites_view` | Agrega favoritos |
|
||||
| `feed_model` | `feed_view` | |
|
||||
| `forum_model` | `forum_view` | Cifrado |
|
||||
| **`games_model`** | **`games_view`** | Hall of fame + shell de juegos |
|
||||
| `images_model` | `image_view` | |
|
||||
| `inhabitants_model` | `inhabitants_view` | Perfiles |
|
||||
| `jobs_model` | `jobs_view` | |
|
||||
| `larp_model` | `larp_view` | |
|
||||
| `legacy_model` | `legacy_view` | |
|
||||
| `logs_model` | `logs_view` | |
|
||||
| `maps_model` | `maps_view` | Cifrado |
|
||||
| `market_model` | `market_view` | |
|
||||
| `melody_model` | `melody_view` | |
|
||||
| `opinions_model` | `opinions_view` | |
|
||||
| `pads_model` | `pads_view` | Cifrado |
|
||||
| `parliament_model` | `parliament_view` | |
|
||||
| `pixelia_model` | `pixelia_view` | |
|
||||
| `pm_model` | `pm_view` | Mensajería privada |
|
||||
| `projects_model` | `projects_view` | |
|
||||
| `reports_model` | `report_view` | |
|
||||
| `search_model` | `search_view` | |
|
||||
| `shops_model` | `shops_view` | |
|
||||
| `stats_model` | `stats_view` | |
|
||||
| `tags_model` | `tags_view` | |
|
||||
| `tasks_model` | `task_view` | |
|
||||
| `torrents_model` | `torrents_view` | |
|
||||
| `transfers_model` | `transfer_view` | |
|
||||
| `trending_model` | `trending_view` | |
|
||||
| `tribes_model` + `tribes_content_model` | `tribes_view` | Cifrado |
|
||||
| `videos_model` | `video_view` | |
|
||||
| `votes_model` | `vote_view` | |
|
||||
| `wallet_model` | `wallet_view` | |
|
||||
| (sin modelo) | `AI_view` | Lógica en `src/AI/*` |
|
||||
| `crypto.js`/`tribe_crypto.js` | — | Cifrado simétrico por dominio |
|
||||
|
||||
---
|
||||
|
||||
## 2.8 Mapa de dependencias entre archivos
|
||||
|
||||
**`backend.js` → modelos** (líneas 541-602): importa `main_models` y **todos**
|
||||
los `*_model.js` como factories invocadas con `{ cooler, isPublic, ...deps }`.
|
||||
Hay **inyección de dependencias entre modelos**: p.ej. `agenda_model` recibe
|
||||
`{calendarsModel, eventsModel, tasksModel, marketModel, jobsModel,
|
||||
projectsModel}`; `favorites_model` recibe casi todos los modelos de contenido.
|
||||
|
||||
**`backend.js` → vistas** (líneas ~1162-1216 + `require` inline): importa las
|
||||
funciones de render de cada `*_view.js`. Algunas se cargan diferidas dentro de
|
||||
los handlers (`require('../views/...')`).
|
||||
|
||||
**Modelos → importan**: `pull-stream`, `moment`, `./tombstone_validator`,
|
||||
`../configs/config-manager`, helpers, y reciben `cooler` por parámetro (no
|
||||
importan `gui` directamente). `tombstone_validator.js` no tiene dependencias
|
||||
(función pura).
|
||||
|
||||
**Vistas → importan**: hyperaxe, `{template, i18n, userLink, ...}` de
|
||||
`main_views`, `moment`, y a veces `../server/SSB_server` para obtener
|
||||
`config.keys.id` (el feedId propio).
|
||||
|
||||
**Estado compartido**: `config-manager.js` (lee/escribe `oasis-config.json`) y
|
||||
`shared-state.js` (memoria) son importados por backend, modelos y vistas; son
|
||||
los puntos de coordinación transversal. `nameCache.js` (un Map en memoria) lo
|
||||
alimenta `main_models` y lo consume `main_views`.
|
||||
|
||||
Siguiente: **[03-modulos-y-juegos.md](03-modulos-y-juegos.md)**.
|
||||
216
docs/devs/architecture/03-modulos-y-juegos.md
Normal file
216
docs/devs/architecture/03-modulos-y-juegos.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# 3. El módulo de juegos y la anatomía de un módulo
|
||||
|
||||
> **Hallazgo clave que cambia las expectativas:** el módulo de juegos de Oasis
|
||||
> **NO es un sistema multijugador asíncrono por SSB**. Es mucho más simple:
|
||||
>
|
||||
> - Cada juego es un **único `index.html` autocontenido** (HTML + CSS + JS
|
||||
> inline), 100% client-side, servido como archivo estático y embebido en un
|
||||
> `<iframe>`. No hay JS externos, ni sprites, ni carpetas de assets: cada
|
||||
> carpeta de juego tiene **solo 2 archivos**: `index.html` + `thumbnail.svg`.
|
||||
> - La **única integración con SSB** es publicar un mensaje tipo `gameScore`
|
||||
> (puntuación individual) para un **Hall of Fame** (ranking global). No hay
|
||||
> retos, ni partidas, ni movimientos sincronizados. El "multijugador" se
|
||||
> reduce a comparar las puntuaciones de distintos autores leyendo el log SSB.
|
||||
|
||||
---
|
||||
|
||||
## 3.1 El modelo: `src/models/games_model.js` (67 líneas)
|
||||
|
||||
Minimalista. Solo gestiona puntuaciones. Patrón factory estándar:
|
||||
`module.exports = ({ cooler }) => {...}`.
|
||||
|
||||
- **Líneas 5-9**: lista blanca `VALID_GAMES` (Set de ids permitidos). ⚠️ Incluye
|
||||
14 juegos pero **NO** `rockpaperscissors` ni `audiopendulum` (que sí están en
|
||||
la vista): esos dos **no pueden enviar score** (el modelo los rechaza).
|
||||
- **Líneas 18-25** `readAll()`: vuelca todo el log con `createLogStream`.
|
||||
- **Líneas 28-38** `submitScore(game, score)` — único método de escritura:
|
||||
|
||||
```js
|
||||
async submitScore(game, score) {
|
||||
if (!VALID_GAMES.has(game)) throw new Error('invalid game');
|
||||
const n = Number(score);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
|
||||
const ssbClient = await openSsb();
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) },
|
||||
(err, msg) => err ? reject(err) : resolve(msg));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
El mensaje SSB resultante: `{ type: 'gameScore', game: '<id>', score: <int> }`.
|
||||
|
||||
- **Líneas 40-65** `getHallOfFame()`: recorre el log, filtra
|
||||
`content.type === 'gameScore'`, y por cada par `game:author` guarda **solo la
|
||||
mejor puntuación**. Devuelve `hall[game] = [{author, score, game, ts}, ...]`
|
||||
ordenado descendente y truncado a top-10 por juego.
|
||||
|
||||
---
|
||||
|
||||
## 3.2 La vista: `src/views/games_view.js` (151 líneas)
|
||||
|
||||
- **Líneas 5-22** `getGames()`: catálogo hardcodeado de **16 juegos**. Cada
|
||||
entrada es `{ id, title: () => i18n.<key>, desc: () => i18n.<key> }`. Es el
|
||||
registro maestro de la vista.
|
||||
- **Línea 58** `VALID_GAME_IDS`: un **tercer** Set, usado para validar la ruta
|
||||
del shell. Incluye los 16.
|
||||
- **Líneas 26-56** `renderHallOfFame(hall)`: tabla por juego con thumbnail
|
||||
(`/game-assets/${id}/thumbnail.svg`), título, descripción y filas (posición,
|
||||
`userLink(author)`, score, fecha).
|
||||
- **Líneas 60-93** `gameShellView(name)`: así se **embebe cada juego**. Valida
|
||||
`name` contra `VALID_GAME_IDS` y renderiza un iframe:
|
||||
|
||||
```js
|
||||
iframe({
|
||||
src: `/game-assets/${name}/index.html`,
|
||||
class: `game-iframe game-iframe-${name}`,
|
||||
scrolling: 'no',
|
||||
allowfullscreen: true
|
||||
})
|
||||
```
|
||||
|
||||
- **Líneas 95-151** `gamesView(filter, hall)`: el **lobby**. Barra de filtros
|
||||
(`all` / `scoring`). En modo `all` lista cada juego con thumbnail, título,
|
||||
récord top y botón PLAY → `/games/${id}`. En modo `scoring` invoca
|
||||
`renderHallOfFame`.
|
||||
|
||||
> ⚠️ Hay **TRES listas** que deben mantenerse sincronizadas: `getGames()` y
|
||||
> `VALID_GAME_IDS` (vista) + `VALID_GAMES` (modelo). La inconsistencia de
|
||||
> `rockpaperscissors`/`audiopendulum` lo demuestra.
|
||||
|
||||
---
|
||||
|
||||
## 3.3 Estructura de un juego: `src/games/<nombre>/`
|
||||
|
||||
17 carpetas: `8ball, arkanoid, artillery, asteroids, audiopendulum, cocoland,
|
||||
cocoman, ecoinflow, flipflop, labyrinth, neoninfiltrator, pingpong,
|
||||
rockpaperscissors, spaceinvaders, tetris, tiktaktoe`.
|
||||
|
||||
**TODOS los juegos tienen exactamente 2 archivos**:
|
||||
- `index.html` (6-10 KB): documento completo y autosuficiente, con `<style>` y
|
||||
`<script>` inline. Sin dependencias externas, sin CDN.
|
||||
- `thumbnail.svg` (~1-2 KB): miniatura SVG vectorial (viewBox ~`0 0 400 220`).
|
||||
|
||||
No existe distinción "arcade" vs "social SSB": todos son arcade HTML5/canvas o
|
||||
DOM puro, client-side, en iframe. La única diferencia es si **envían score** o
|
||||
no.
|
||||
|
||||
**Mecanismo de envío de score** (idéntico en todos los juegos puntuables;
|
||||
ejemplo `tiktaktoe/index.html`):
|
||||
|
||||
```html
|
||||
<div id="scoreSubmit" style="display:none">
|
||||
<form method="POST" action="/games/submit-score" target="_top">
|
||||
<input type="hidden" name="game" value="tiktaktoe">
|
||||
<input type="hidden" id="scoreInput" name="score" value="0">
|
||||
<button type="submit">Submit Score to Hall of Fame</button>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
Claves del patrón:
|
||||
- `target="_top"` → el POST **rompe el iframe** y navega la ventana superior
|
||||
(necesario porque el juego corre sandboxeado dentro de `/games/<name>`).
|
||||
- El JS del juego rellena `#scoreInput` al terminar
|
||||
(`document.getElementById('scoreInput').value = score;`) y muestra el `div`.
|
||||
- Cada juego tiene una topbar con `<a href="/games" target="_top">← Back</a>`.
|
||||
|
||||
El resto del `index.html` es lógica de juego propia en JS vanilla (tiktaktoe usa
|
||||
minimax con poda alfa-beta; tetris usa canvas).
|
||||
|
||||
`gameScore` además es un tipo SSB de primera clase **indexado en búsqueda**
|
||||
(`src/views/search_view.js` lo mapea a `/games/<game>` y lo renderiza con su
|
||||
thumbnail).
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Cómo se sirve y enruta
|
||||
|
||||
**Servido estático** — `src/client/middleware.js` (líneas 109-111):
|
||||
|
||||
```js
|
||||
const gamesStatic = new Koa();
|
||||
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
|
||||
app.use(mount("/game-assets", gamesStatic));
|
||||
```
|
||||
|
||||
Sub-app Koa con `koa-static` sobre `src/games/`, montada en `/game-assets`. Por
|
||||
eso `/game-assets/tetris/index.html` sirve `src/games/tetris/index.html`.
|
||||
**Independiente del router principal** → una carpeta nueva queda servida
|
||||
automáticamente.
|
||||
|
||||
**Rutas dinámicas** — `src/backend/backend.js`:
|
||||
- **Línea 598**: `const gamesModel = require('../models/games_model')({ cooler });`
|
||||
- **Línea 1168**: `const { gamesView } = require("../views/games_view");`
|
||||
- **Líneas 1358-1373**:
|
||||
|
||||
```js
|
||||
.get('/games', async (ctx) => {
|
||||
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
|
||||
const filter = ctx.query.filter === 'scoring' ? 'scoring' : 'all';
|
||||
const hall = await gamesModel.getHallOfFame();
|
||||
ctx.body = gamesView(filter, hall);
|
||||
})
|
||||
.get('/games/:name', async (ctx) => {
|
||||
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
|
||||
const { gameShellView } = require('../views/games_view');
|
||||
ctx.body = gameShellView(ctx.params.name);
|
||||
})
|
||||
.post('/games/submit-score', koaBody(), async (ctx) => {
|
||||
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
|
||||
const { game, score } = ctx.request.body;
|
||||
try { await gamesModel.submitScore(game, score); } catch (_) {}
|
||||
ctx.redirect('/games?filter=scoring');
|
||||
})
|
||||
```
|
||||
|
||||
Flujo completo:
|
||||
|
||||
```
|
||||
/games (lobby)
|
||||
└─ PLAY → /games/:name (shell con iframe)
|
||||
└─ iframe carga /game-assets/:name/index.html
|
||||
└─ al terminar: POST /games/submit-score (target="_top")
|
||||
└─ redirect /games?filter=scoring (Hall of Fame)
|
||||
```
|
||||
|
||||
El guard `checkMod(ctx, 'gamesMod')` (líneas 170-177) revisa la config del
|
||||
servidor + una cookie por-usuario; default `on`. Si está off → `/modules`.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Cómo se registra el módulo (puntos de integración)
|
||||
|
||||
| Lugar | Archivo | Qué hay |
|
||||
|---|---|---|
|
||||
| **Menú** | `src/views/main_views.js:898` | `renderGamesLink()` (emite el `navLink` si `gamesMod==="on"`); se invoca en línea 1280 dentro del `navGroup` "creative" |
|
||||
| **Config** | `src/configs/oasis-config.json` | `"gamesMod": "on"` dentro de `modules` |
|
||||
| **Página /modules** | `src/views/modules_view.js:23` | `{ name: 'games', label, description }` |
|
||||
| **Listas de backend** | `src/backend/backend.js` | `'games'` en línea 1346 (GET /modules), 7054 (POST /save-modules), 7038 (`ALL_MODULES`) y presets (7040-7042) |
|
||||
| **i18n** | `src/client/assets/translations/oasis_*.js` (×11) | `gamesTitle`, `gamesDescription`, `modulesGamesLabel/Description`, y por juego `games<Nombre>Title` + `games<Nombre>Desc` |
|
||||
|
||||
> El nombre del módulo está **hardcodeado y duplicado en muchos sitios**; no hay
|
||||
> un registro central único. Por eso, al crear un módulo, cada punto es
|
||||
> obligatorio. → [04-tutorial-modulo-desde-cero.md](04-tutorial-modulo-desde-cero.md)
|
||||
|
||||
---
|
||||
|
||||
## 3.6 Receta rápida: añadir un juego NUEVO al sistema existente
|
||||
|
||||
Para añadir un juego `foobar`:
|
||||
|
||||
1. **`src/games/foobar/index.html`** — HTML autocontenido (CSS+JS inline).
|
||||
Incluir la topbar `← Back` y, si puntúa, el form oculto a
|
||||
`/games/submit-score` con `target="_top"` y `name="game" value="foobar"`.
|
||||
2. **`src/games/foobar/thumbnail.svg`** — miniatura.
|
||||
3. **`src/views/games_view.js`**:
|
||||
- Añadir a `getGames()`:
|
||||
`{ id: 'foobar', title: () => i18n.gamesFoobarTitle, desc: () => i18n.gamesFoobarDesc }`.
|
||||
- Añadir `'foobar'` a `VALID_GAME_IDS`.
|
||||
4. **`src/models/games_model.js`** — añadir `'foobar'` a `VALID_GAMES`
|
||||
(**obligatorio si puntúa**, o `submitScore` lo rechaza).
|
||||
5. **i18n** — añadir `gamesFoobarTitle` y `gamesFoobarDesc` en TODOS los
|
||||
`oasis_*.js`.
|
||||
|
||||
No hay que tocar el backend ni el static serving (la carpeta nueva ya se sirve
|
||||
por `/game-assets/`).
|
||||
361
docs/devs/architecture/04-tutorial-modulo-desde-cero.md
Normal file
361
docs/devs/architecture/04-tutorial-modulo-desde-cero.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
# 4. Tutorial: crear un módulo desde cero
|
||||
|
||||
Vamos a crear un módulo nuevo, **paso a paso**, tomando `games` como plantilla.
|
||||
Como caso práctico crearemos un módulo `arena` (un módulo de juegos propio: un
|
||||
lobby de juegos con su propio ranking). Sustituye `arena` por el nombre de tu
|
||||
módulo.
|
||||
|
||||
> **Idea clave**: en Oasis no existe un "registro central" de módulos. Crear un
|
||||
> módulo = crear 2 archivos nuevos (model + view) y **tocar ~7 sitios** de
|
||||
> integración. Esta guía los enumera todos. Si te saltas uno, el módulo
|
||||
> funcionará a medias (p.ej. la ruta existe pero no aparece en el menú).
|
||||
|
||||
## Mapa de lo que vamos a tocar
|
||||
|
||||
```
|
||||
NUEVO src/models/arena_model.js ← lógica de datos (SSB)
|
||||
NUEVO src/views/arena_view.js ← render HTML (hyperaxe)
|
||||
EDITA src/backend/backend.js ← instanciar modelo + rutas + 4 listas
|
||||
EDITA src/views/main_views.js ← enlace en el menú lateral
|
||||
EDITA src/configs/oasis-config.json ← flag arenaMod: "on"
|
||||
EDITA src/views/modules_view.js ← entrada en la página /modules
|
||||
EDITA src/client/assets/translations/oasis_*.js (×11) ← i18n
|
||||
(opc) src/client/middleware.js ← static mount si sirves archivos (juegos)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 1 — El modelo: `src/models/arena_model.js`
|
||||
|
||||
El modelo encapsula **toda la lógica de datos**: publicar y leer mensajes SSB.
|
||||
Sigue el patrón factory `({ cooler }) => ({...})`.
|
||||
|
||||
```js
|
||||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 5000;
|
||||
|
||||
const VALID_GAMES = new Set(['snake', 'breakout']); // tus juegos puntuables
|
||||
|
||||
module.exports = ({ cooler }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => {
|
||||
if (!ssb) ssb = await cooler.open();
|
||||
return ssb;
|
||||
};
|
||||
|
||||
function readAll(ssbClient) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
pull.collect((err, res) => (err ? reject(err) : resolve(res)))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// ESCRITURA: publica un mensaje SSB tipado
|
||||
async submitScore(game, score) {
|
||||
if (!VALID_GAMES.has(game)) throw new Error('invalid game');
|
||||
const n = Number(score);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
|
||||
const ssbClient = await openSsb();
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish({ type: 'arenaScore', game, score: Math.round(n) },
|
||||
(err, msg) => (err ? reject(err) : resolve(msg)));
|
||||
});
|
||||
},
|
||||
|
||||
// LECTURA: lee el log, filtra por type, agrega
|
||||
async getRanking() {
|
||||
const ssbClient = await openSsb();
|
||||
const messages = await readAll(ssbClient);
|
||||
const best = {};
|
||||
for (const m of messages) {
|
||||
const c = m.value && m.value.content;
|
||||
if (!c || c.type !== 'arenaScore') continue;
|
||||
if (!VALID_GAMES.has(c.game)) continue;
|
||||
const key = `${c.game}:${m.value.author}`;
|
||||
const score = Number(c.score);
|
||||
if (!Number.isFinite(score)) continue;
|
||||
if (!best[key] || score > best[key].score) {
|
||||
best[key] = { author: m.value.author, score, game: c.game, ts: m.value.timestamp || 0 };
|
||||
}
|
||||
}
|
||||
const ranking = {};
|
||||
for (const g of VALID_GAMES) ranking[g] = [];
|
||||
for (const e of Object.values(best)) if (ranking[e.game]) ranking[e.game].push(e);
|
||||
for (const g of VALID_GAMES) ranking[g] = ranking[g].sort((a, b) => b.score - a.score).slice(0, 10);
|
||||
return ranking;
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Reglas del patrón de modelo:
|
||||
- **Conexión SSB lazy** con `openSsb()` cacheado.
|
||||
- **Escritura** siempre con `ssb.publish({ type: '<algo>', ... })`. El `type` es
|
||||
el discriminador de tus mensajes en el log global compartido — elige un nombre
|
||||
único (`arenaScore`, no `score`).
|
||||
- **Lectura** con `createLogStream` + `pull.collect`, luego filtras por `type` en
|
||||
memoria.
|
||||
- **Borrado/edición** (si lo necesitas): publica
|
||||
`{ type:'tombstone', target, author }` y usa
|
||||
`src/models/tombstone_validator.js` al leer. Para editar, publica una versión
|
||||
nueva con `replaces: <idAnterior>`.
|
||||
|
||||
---
|
||||
|
||||
## Paso 2 — La vista: `src/views/arena_view.js`
|
||||
|
||||
La vista recibe los datos del modelo y devuelve HTML con **hyperaxe**. Siempre se
|
||||
envuelve en `template(...)` y se usan claves `i18n` (nunca texto literal).
|
||||
|
||||
```js
|
||||
const { div, h2, p, section, form, input, button, a, img, table, tr, td, th, iframe }
|
||||
= require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n, userLink } = require('./main_views');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const getGames = () => [
|
||||
{ id: 'snake', title: () => i18n.arenaSnakeTitle, desc: () => i18n.arenaSnakeDesc },
|
||||
{ id: 'breakout', title: () => i18n.arenaBreakoutTitle, desc: () => i18n.arenaBreakoutDesc },
|
||||
];
|
||||
|
||||
const VALID_GAME_IDS = new Set(['snake', 'breakout']);
|
||||
|
||||
// Lobby
|
||||
exports.arenaView = (filter = 'all', ranking = null) => {
|
||||
const games = getGames();
|
||||
const filterBar = div({ class: 'filter-group' },
|
||||
form({ method: 'GET', action: '/arena' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'all' }),
|
||||
button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' }, i18n.arenaFilterAll)),
|
||||
form({ method: 'GET', action: '/arena' },
|
||||
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
|
||||
button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' }, i18n.arenaFilterScoring))
|
||||
);
|
||||
|
||||
const content = filter === 'scoring' && ranking
|
||||
? div({ class: 'arena-scoring' },
|
||||
getGames().filter(g => ranking[g.id] && ranking[g.id].length).map(g =>
|
||||
div({ class: 'arena-section' },
|
||||
h2(g.title()),
|
||||
table({ class: 'ranking-table' },
|
||||
tr(th('#'), th(i18n.arenaPlayer), th(i18n.arenaScore)),
|
||||
...ranking[g.id].map((e, i) =>
|
||||
tr(td(String(i + 1)), td(userLink(e.author)), td(String(e.score))))))))
|
||||
: div({ class: 'arena-list' },
|
||||
games.map(g =>
|
||||
div({ class: 'game-row' },
|
||||
img({ src: `/arena-assets/${g.id}/thumbnail.svg`, loading: 'lazy' }),
|
||||
div(h2(g.title()), p(g.desc())),
|
||||
a({ href: `/arena/${g.id}`, class: 'filter-btn' }, i18n.arenaPlayButton))));
|
||||
|
||||
return template(i18n.arenaTitle, section(h2(i18n.arenaTitle), filterBar), section(content));
|
||||
};
|
||||
|
||||
// Shell: embebe el juego en un iframe
|
||||
exports.arenaShellView = (name) => {
|
||||
if (!VALID_GAME_IDS.has(name)) return template(i18n.arenaTitle, section(p(i18n.notFound || 'Not found')));
|
||||
const game = getGames().find(g => g.id === name);
|
||||
return template(game ? game.title() : name,
|
||||
section({ class: 'game-shell-section' },
|
||||
iframe({ src: `/arena-assets/${name}/index.html`, class: `game-iframe game-iframe-${name}`,
|
||||
scrolling: 'no', allowfullscreen: true })));
|
||||
};
|
||||
```
|
||||
|
||||
Reglas del patrón de vista:
|
||||
- `template(titulo, ...secciones)` siempre como envoltorio (te da head, menú
|
||||
lateral, tema, etc.).
|
||||
- Helpers de `main_views`: `userLink(feedId)`, `markdown(texto, mentions)`,
|
||||
chips, `errorView`...
|
||||
- Si tu módulo no es de juegos, simplemente no uses iframe/static: renderiza tus
|
||||
datos directamente (listas, formularios POST a tus rutas).
|
||||
|
||||
---
|
||||
|
||||
## Paso 3 — Backend: `src/backend/backend.js`
|
||||
|
||||
**3a. Instanciar el modelo** (junto a la línea ~598, donde están los demás):
|
||||
|
||||
```js
|
||||
const arenaModel = require('../models/arena_model')({ cooler });
|
||||
```
|
||||
|
||||
**3b. Importar la vista** (junto a la línea ~1168):
|
||||
|
||||
```js
|
||||
const { arenaView } = require("../views/arena_view");
|
||||
```
|
||||
|
||||
**3c. Añadir las rutas** (dentro de la cadena `router....`, junto a las de
|
||||
games en ~1358). Cada ruta empieza con el guard `checkMod`:
|
||||
|
||||
```js
|
||||
.get('/arena', async (ctx) => {
|
||||
if (!checkMod(ctx, 'arenaMod')) { ctx.redirect('/modules'); return; }
|
||||
const filter = ctx.query.filter === 'scoring' ? 'scoring' : 'all';
|
||||
const ranking = await arenaModel.getRanking();
|
||||
ctx.body = arenaView(filter, ranking);
|
||||
})
|
||||
.get('/arena/:name', async (ctx) => {
|
||||
if (!checkMod(ctx, 'arenaMod')) { ctx.redirect('/modules'); return; }
|
||||
const { arenaShellView } = require('../views/arena_view');
|
||||
ctx.body = arenaShellView(ctx.params.name);
|
||||
})
|
||||
.post('/arena/submit-score', koaBody(), async (ctx) => {
|
||||
if (!checkMod(ctx, 'arenaMod')) { ctx.redirect('/modules'); return; }
|
||||
const { game, score } = ctx.request.body;
|
||||
try { await arenaModel.submitScore(game, score); } catch (_) {}
|
||||
ctx.redirect('/arena?filter=scoring');
|
||||
})
|
||||
```
|
||||
|
||||
**3d. Añadir `'arena'` a las cuatro listas de módulos** (si no lo haces, el
|
||||
módulo no aparecerá en la página `/modules` ni se podrá activar/desactivar):
|
||||
- Línea **1346** — array `modules` del GET `/modules`.
|
||||
- Línea **7054** — array `modules` del POST `/save-modules`.
|
||||
- Línea **7038** — `ALL_MODULES`.
|
||||
- Líneas **7040-7042** — los presets (`minimal`/`social`/`economy`/`full`) donde
|
||||
quieras que aparezca por defecto.
|
||||
|
||||
---
|
||||
|
||||
## Paso 4 — Menú lateral: `src/views/main_views.js`
|
||||
|
||||
**4a. Crear `renderArenaLink()`** (copia el patrón de `renderGamesLink`,
|
||||
línea ~898):
|
||||
|
||||
```js
|
||||
const renderArenaLink = () => {
|
||||
const arenaMod = getConfig().modules.arenaMod === "on";
|
||||
return arenaMod
|
||||
? [navLink({ href: "/arena", emoji: "🎮", text: i18n.arenaTitle, class: "arena-link enabled" })]
|
||||
: "";
|
||||
};
|
||||
```
|
||||
|
||||
**4b. Invocarlo dentro de un `navGroup`** (junto a `renderGamesLink()` en la
|
||||
línea ~1280, grupo "creative", o el grupo que prefieras):
|
||||
|
||||
```js
|
||||
renderGamesLink(),
|
||||
renderArenaLink(), // ← añadir
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 5 — Config: `src/configs/oasis-config.json`
|
||||
|
||||
Añadir el flag dentro de `modules` (junto a `"gamesMod": "on"`):
|
||||
|
||||
```json
|
||||
"arenaMod": "on",
|
||||
```
|
||||
|
||||
`config-manager.js` también tiene un `defaultConfig`; si quieres que el módulo
|
||||
exista en instalaciones nuevas, añade el flag ahí también.
|
||||
|
||||
---
|
||||
|
||||
## Paso 6 — Página `/modules`: `src/views/modules_view.js`
|
||||
|
||||
Añadir una entrada al array `modules` (línea ~23). El toggle on/off lo renderiza
|
||||
el código genérico (`${name}Mod` / `${name}Form`):
|
||||
|
||||
```js
|
||||
{ name: 'arena', label: i18n.modulesArenaLabel, description: i18n.modulesArenaDescription },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Paso 7 — i18n: `src/client/assets/translations/oasis_*.js`
|
||||
|
||||
En **todos** los archivos de idioma (×11) añade, como mínimo:
|
||||
|
||||
```js
|
||||
arenaTitle: 'Arena',
|
||||
arenaDescription: 'Play and compete in your network.',
|
||||
arenaFilterAll: 'All',
|
||||
arenaFilterScoring: 'Ranking',
|
||||
arenaPlayButton: 'PLAY',
|
||||
arenaPlayer: 'Player',
|
||||
arenaScore: 'Score',
|
||||
modulesArenaLabel: 'Arena',
|
||||
modulesArenaDescription: 'A games arena with rankings.',
|
||||
// y por cada juego:
|
||||
arenaSnakeTitle: 'Snake', arenaSnakeDesc: 'Classic snake.',
|
||||
arenaBreakoutTitle: 'Breakout', arenaBreakoutDesc: 'Break the bricks.',
|
||||
```
|
||||
|
||||
Empieza por `oasis_en.js` (es el fallback). Si una clave falta en un idioma,
|
||||
usa el patrón `i18n.x || 'Default'` en la vista para evitar `undefined`.
|
||||
|
||||
---
|
||||
|
||||
## Paso 8 (opcional) — Servir archivos estáticos (juegos)
|
||||
|
||||
Solo si tu módulo sirve archivos (como hace games con sus `index.html`). En
|
||||
`src/client/middleware.js`, replica el bloque de games (líneas 109-111):
|
||||
|
||||
```js
|
||||
const arenaStatic = new Koa();
|
||||
arenaStatic.use(koaStatic(join(__dirname, "..", "arena-games")));
|
||||
app.use(mount("/arena-assets", arenaStatic));
|
||||
```
|
||||
|
||||
Y crea tus juegos en `src/arena-games/<id>/index.html` + `thumbnail.svg`. Cada
|
||||
`index.html` autocontenido; si puntúa, el form oculto apunta a
|
||||
`/arena/submit-score` con `target="_top"`:
|
||||
|
||||
```html
|
||||
<form method="POST" action="/arena/submit-score" target="_top">
|
||||
<input type="hidden" name="game" value="snake">
|
||||
<input type="hidden" id="scoreInput" name="score" value="0">
|
||||
<button type="submit">Submit Score</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist final
|
||||
|
||||
```
|
||||
[ ] src/models/arena_model.js (factory {cooler}, publish/read SSB)
|
||||
[ ] src/views/arena_view.js (hyperaxe + template + i18n)
|
||||
[ ] backend.js: instancia modelo (~598)
|
||||
[ ] backend.js: import vista (~1168)
|
||||
[ ] backend.js: rutas GET/POST con checkMod(ctx,'arenaMod')
|
||||
[ ] backend.js: 'arena' en las 4 listas (1346, 7054, 7038, presets)
|
||||
[ ] main_views.js: renderArenaLink() + llamada en navGroup
|
||||
[ ] oasis-config.json: "arenaMod": "on"
|
||||
[ ] modules_view.js: entrada en array modules
|
||||
[ ] oasis_*.js (×11): claves i18n
|
||||
[ ] (opc) middleware.js: static mount + carpeta de juegos
|
||||
```
|
||||
|
||||
## Cómo probarlo
|
||||
|
||||
```bash
|
||||
# Arranca sbot + frontend
|
||||
cd src/server && npm run start
|
||||
# o por separado (sbot ya levantado):
|
||||
node ../backend/backend.js
|
||||
```
|
||||
|
||||
Abre `http://localhost:3000/arena`. Si redirige a `/modules`, activa el módulo
|
||||
ahí (o revisa `arenaMod` en `oasis-config.json`). Comprueba:
|
||||
1. El enlace aparece en el menú lateral.
|
||||
2. `/arena` muestra el lobby.
|
||||
3. `/arena/snake` embebe el juego.
|
||||
4. Al enviar score, redirige a `/arena?filter=scoring` y aparece en el ranking.
|
||||
|
||||
> Errores típicos: `submitScore` lanza `invalid game` → falta el id en
|
||||
> `VALID_GAMES` (modelo). El juego no carga en el iframe → ruta de
|
||||
> `/arena-assets/...` mal o falta el static mount. La vista peta con `undefined`
|
||||
> → falta una clave i18n; añade el fallback `|| '...'`.
|
||||
|
||||
Siguiente: **[05-mapa-funciones.txt](05-mapa-funciones.txt)** — el mapa ASCII de
|
||||
relaciones entre funciones, persistencia y media.
|
||||
262
docs/devs/architecture/05-mapa-funciones.txt
Normal file
262
docs/devs/architecture/05-mapa-funciones.txt
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
================================================================================
|
||||
OASIS · MAPA DE FUNCIONES Y FLUJO
|
||||
Cómo se relacionan las funciones lógicas entre archivos,
|
||||
la persistencia (SSB) y la media (blobs)
|
||||
versión 0.8.1
|
||||
================================================================================
|
||||
|
||||
LEYENDA
|
||||
[proc] proceso ( ) función / método
|
||||
--> llamada / flujo === límite de proceso ~~~ red / disco
|
||||
*.js archivo #NNN número de línea aprox.
|
||||
|
||||
|
||||
================================================================================
|
||||
0. VISIÓN GLOBAL: DOS PROCESOS
|
||||
================================================================================
|
||||
|
||||
NAVEGADOR OTROS PEERS SSB
|
||||
(HTML puro, (misma SHS cap)
|
||||
sin JS) |
|
||||
| HTTP :3000 | net:IP:8008~shs
|
||||
v v ~~~~~~~~~~~~~
|
||||
============================[proc gui]== ==============[proc sbot]======
|
||||
| src/backend/backend.js (Koa router) | | src/server/SSB_server.js |
|
||||
| | | SecretStack({caps}).use(..)|
|
||||
| cooler = src/client/gui.js | | ssb-db (log append-only) |
|
||||
| | | <-> | ssb-ebt / ssb-friends |
|
||||
| | cooler.open() |unix | ssb-blobs / ssb-conn |
|
||||
| v |sock | ssb-box / ssb-private |
|
||||
| ssb (cliente muxrpc) ---------------|~noauth-> muxrpc API (publish,get, |
|
||||
| | | createLogStream,...) |
|
||||
========================================= ===============================
|
||||
modelos + vistas replicación P2P por gossip
|
||||
|
||||
|
||||
================================================================================
|
||||
1. CADENA DE UNA PETICIÓN (el patrón de TODO módulo)
|
||||
================================================================================
|
||||
|
||||
navegador
|
||||
| GET /modulo (o POST /modulo/accion)
|
||||
v
|
||||
middleware.js (#43..#209) ── app.listen({host,port})
|
||||
| setLanguage(cookie) · gate indexación · refresco shared-state(60s)
|
||||
v
|
||||
backend.js router.get('/modulo') #1270..#7124 (~472 rutas)
|
||||
| checkMod(ctx,'moduloMod') #170 ── off? --> redirect /modules
|
||||
|
|
||||
+--> xModel.metodo(filtro) [ LECTURA / ESCRITURA SSB ] -----+
|
||||
| (src/models/xxx_model.js) |
|
||||
| v
|
||||
| <------------------ datos JS ------------------------ cooler.open()
|
||||
| (gui.js)
|
||||
+--> xView(datos, filtro) [ RENDER HTML ] |
|
||||
| (src/views/xxx_view.js) v
|
||||
| template(i18n.titulo, section(...)) ssb muxrpc
|
||||
v
|
||||
ctx.body = "<html>...</html>" --> Koa --> navegador
|
||||
|
||||
POST: koaBody() -> stripDangerousTags() -> xModel.crear() -> ctx.redirect()
|
||||
(patrón Post/Redirect/Get; el GET siguiente re-renderiza)
|
||||
|
||||
|
||||
================================================================================
|
||||
2. PERSISTENCIA: cómo el MODELO habla con SSB (pull-stream + publish)
|
||||
================================================================================
|
||||
|
||||
src/models/xxx_model.js = module.exports = ({cooler, ...deps}) => ({...})
|
||||
|
||||
openSsb() ............ if(!ssb) ssb = await cooler.open(); // lazy, cacheado
|
||||
|
|
||||
v
|
||||
ESCRIBIR (persistir):
|
||||
ssb.publish({ type:'<tipo>', ...campos }, cb)
|
||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+--> ssb-db: añade mensaje FIRMADO (ed25519) al log append-only
|
||||
| del feed propio @miclave.ed25519
|
||||
+--> ssb-ebt replica el mensaje a los peers (hops<=2) ~~~~>
|
||||
|
||||
LEER (consultar):
|
||||
pull(
|
||||
ssb.createLogStream({limit}) <-- lee el log local (todos los feeds
|
||||
pull.filter(m => m.value.content.type === '<tipo>') replicados)
|
||||
pull.collect((err,res)=>...)
|
||||
)
|
||||
(variantes: ssb.messagesByType, ssb.query.read, ssb.backlinks.read,
|
||||
ssb.createUserStream{id})
|
||||
|
||||
BORRAR (lógico; el log NO se borra):
|
||||
ssb.publish({ type:'tombstone', target:<msgId>, author })
|
||||
|
|
||||
+--> tombstone_validator.js :: buildValidatedTombstoneSet(msgs)
|
||||
acepta SOLO si author(tombstone) === author(target) [autoría]
|
||||
|
||||
EDITAR:
|
||||
ssb.publish({ ...nuevo, replaces:<idAnterior> }) + tombstone del viejo
|
||||
|
|
||||
+--> al leer, se sigue la cadena replaces -> hasta la "punta"
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
TIPOS DE MENSAJE (content.type) = el discriminador en el log compartido
|
||||
----------------------------------------------------------------------------
|
||||
nativos SSB : post · vote(like) · contact(follow/block) · about(perfil)
|
||||
Oasis : votes · tombstone · task · event · calendar · transfer ·
|
||||
tribe · market · report · job · project · gameScore · ...
|
||||
|
||||
|
||||
================================================================================
|
||||
3. MEDIA: cómo viajan los ficheros (BLOBS, fuera del log)
|
||||
================================================================================
|
||||
|
||||
El log solo guarda el HASH. El binario viaja aparte por la red SSB.
|
||||
|
||||
SUBIR:
|
||||
navegador --(multipart)--> backend.js --> blobHandler.js
|
||||
ssb.blobs.add(pull-stream) --> &<sha256>.sha256 (ref = hash)
|
||||
(luego se publica un mensaje con ese ref dentro de content)
|
||||
|
||||
MOSTRAR / DESCARGAR:
|
||||
vista genera <img src="/blob/&xxx.sha256"> ó markdown.js resuelve menciones
|
||||
|
|
||||
v
|
||||
backend.js ruta /blob/:id --> blobHandler.js
|
||||
ssb.blobs.has(id)? --- no ---> ssb.blobs.want(id) ~~~~~~~~~~~~~~~~~>
|
||||
| (gossip "want/has" a los peers)
|
||||
+-- si ---> ssb.blobs.get(id) -> pull-stream -> ctx.body (bytes)
|
||||
|
||||
límite: 50 MB (ssb_config.js #17-19)
|
||||
|
||||
|
||||
================================================================================
|
||||
4. RENDER: cómo la VISTA construye HTML (hyperaxe, sin .html)
|
||||
================================================================================
|
||||
|
||||
src/views/xxx_view.js
|
||||
require("../server/node_modules/hyperaxe") -> div(), form(), section()...
|
||||
require('./main_views') -> { template, i18n, userLink, markdown, chips }
|
||||
|
||||
exports.xView(datos) =
|
||||
template( i18n.xTitle, // <- LAYOUT global
|
||||
section( h2(i18n.xTitle), ...lista... ) )
|
||||
|
|
||||
v
|
||||
main_views.js :: template(titulo, ...elems) #1017..#1321
|
||||
html
|
||||
+- head: <title> · CSS · /assets/themes/<tema>.css (oasis-config.themes)
|
||||
+- body
|
||||
+- header (logo, inbox badge <- shared-state, publish, search)
|
||||
+- sidebar-left = navGroup(...) { renderXxxLink() por módulo }
|
||||
| navLink({href,emoji,text}) #327
|
||||
| renderGamesLink() #898 -> si gamesMod==='on' (config-manager)
|
||||
+- main-content = tus secciones
|
||||
|
||||
i18n: objeto compartido mutado por setLanguage(lang) (11 idiomas)
|
||||
backend middleware lo fija por cookie en cada request
|
||||
markdown.js: ssb-markdown -> resuelve @feed,%msg,&blob,#hashtag a rutas /...
|
||||
|
||||
|
||||
================================================================================
|
||||
5. GRAFO DE DEPENDENCIAS ENTRE ARCHIVOS
|
||||
================================================================================
|
||||
|
||||
src/backend/backend.js (orquestador central)
|
||||
|--require--> ../client/gui.js .................. cooler (conexión SSB)
|
||||
|--require--> ../client/middleware.js ........... http server Koa
|
||||
|--require--> ../configs/config-manager.js ...... getConfig/saveConfig
|
||||
|--require--> ../configs/shared-state.js ........ estado en memoria
|
||||
|--require--> ./sanitizeHtml.js / ./blobHandler.js
|
||||
|--require--> ../server/SSB_server.js ........... config + (sbot in-process)
|
||||
|--require--> ../models/*_model.js (factories: ({cooler,deps}))
|
||||
| |--inyecta--> otros modelos (agenda<-tasks,events,market..)
|
||||
| |--inyecta--> *Crypto (crypto.js por dominio)
|
||||
|--require--> ../views/*_view.js (funciones de render)
|
||||
|
||||
src/models/xxx_model.js
|
||||
|--require--> pull-stream · moment
|
||||
|--require--> ./tombstone_validator.js (función pura, sin deps)
|
||||
|--require--> ../configs/config-manager.js
|
||||
|--recibe----> cooler (NO importa gui directamente)
|
||||
|
||||
src/views/xxx_view.js
|
||||
|--require--> hyperaxe
|
||||
|--require--> ./main_views.js { template, i18n, userLink, helpers }
|
||||
|--require--> moment · (a veces) ../server/SSB_server (feedId propio)
|
||||
|
||||
src/views/main_views.js (el "framework" de vistas)
|
||||
|--require--> hyperaxe · lodash · highlight.js · qrcode · moment
|
||||
|--require--> ./markdown.js · ../backend/renderUrl.js · ./sanitizeHtml?
|
||||
|--require--> ../backend/nameCache.js (Map nombres <- main_models warmup)
|
||||
|--require--> ../client/gui.js · ../configs/{config-manager,shared-state}
|
||||
|--require--> ../client/assets/translations/i18n.js (11 idiomas)
|
||||
|
||||
src/server/SSB_server.js (sbot)
|
||||
|--require--> secret-stack · ssb-caps · ssb-db · plugins ssb-*
|
||||
|--require--> ./ssb_config.js (<- server-config.json) · ./ssb_metadata.js
|
||||
|--require--> ./lanRouter.js (descubrimiento LAN)
|
||||
|
||||
|
||||
================================================================================
|
||||
6. EL MÓDULO GAMES, DE PRINCIPIO A FIN (ejemplo concreto)
|
||||
================================================================================
|
||||
|
||||
MENU main_views.js #898 renderGamesLink() --(gamesMod==on)--> /games
|
||||
|
|
||||
LOBBY backend.js #1358 GET /games |
|
||||
checkMod #170 -> gamesModel.getHallOfFame() #40 ----------+
|
||||
| pull(createLogStream) |
|
||||
| filter type==='gameScore' |
|
||||
| best[game:author]=max(score) |
|
||||
gamesView(filter,hall) #95 -> HTML |
|
||||
|
|
||||
PLAY GET /games/:name #1364 -> gameShellView(name) #60 |
|
||||
<iframe src="/game-assets/<name>/index.html"> |
|
||||
| |
|
||||
STATIC middleware.js #109 mount('/game-assets', src/games/) ~~disk~+
|
||||
|
|
||||
JUEGO src/games/<name>/index.html (HTML+CSS+JS inline, canvas)
|
||||
al terminar: form POST -> /games/submit-score target=_top
|
||||
|
|
||||
SCORE POST /games/submit-score #1369
|
||||
gamesModel.submitScore(game,score) #28
|
||||
ssb.publish({type:'gameScore',game,score}) ~~> SSB log
|
||||
redirect /games?filter=scoring -> Hall of Fame
|
||||
|
||||
LISTAS A SINCRONIZAR (sin registro central):
|
||||
games_view.js getGames() #5 + VALID_GAME_IDS #58 (16 juegos)
|
||||
games_model.js VALID_GAMES #5 (14 -> falta rps,audio)
|
||||
backend.js listas módulos #1346 #7038 #7054 #7040 ('games')
|
||||
oasis-config.json modules.gamesMod
|
||||
modules_view.js array modules #23
|
||||
oasis_*.js gamesTitle, games<Nombre>Title/Desc (x11 idiomas)
|
||||
|
||||
|
||||
================================================================================
|
||||
7. CONFIG Y ESTADO (puntos de coordinación transversal)
|
||||
================================================================================
|
||||
|
||||
oasis-config.json <--getConfig()/saveConfig()--> config-manager.js
|
||||
| modules.{xMod:on/off} themes.current ux.current language
|
||||
| wish pmVisibility lanBroadcasting ssbLogStream.limit wallet
|
||||
+--leen--> backend.js (checkMod) · main_views (render links/tema) · modelos
|
||||
|
||||
server-config.json <-- ssb_config.js --> SSB_server.js
|
||||
| caps.shs (red privada) friends.hops=2 connections (puerto 8008, unix)
|
||||
|
||||
shared-state.js (memoria, NO persistente)
|
||||
| inboxCount carbon peersOnline ecoValue lastRefresh
|
||||
+--escribe--> middleware refresco 60s +--lee--> main_views (badges)
|
||||
|
||||
nameCache.js (Map en memoria)
|
||||
+--alimenta--> main_models._startNameWarmup (escucha 'about' en live)
|
||||
+--consume---> main_models.about · main_views (mostrar nombres)
|
||||
|
||||
|
||||
================================================================================
|
||||
RESUMEN EN UNA FRASE
|
||||
================================================================================
|
||||
backend.js enruta -> el MODELO persiste/lee mensajes tipados en el log SSB
|
||||
(replicado P2P por hops) y los blobs por want/has -> la VISTA (hyperaxe)
|
||||
los pinta dentro de template() con i18n y tema -> y todo módulo se activa con
|
||||
un flag xMod en oasis-config.json comprobado por checkMod().
|
||||
================================================================================
|
||||
Loading…
Add table
Add a link
Reference in a new issue