feat(cosmobiologia): crate WASM + fallback inteligente + DEPLOY.md (fase 3b)

Cierra el requerimiento del módulo web. El cliente puede correr en
modo WASM (render local, scrubbing instantáneo, sin round-trip) o
caer al SSR (server compone el SVG) si el bundle WASM no está
desplegado. Switch automático sin configuración.

cosmobiologia-web (crate nuevo, cdylib + rlib):
- `lib.rs` con un único export wasm-bindgen
  `render_model_to_svg(json, size, rot_offset_deg) -> String` que
  deserializa un `RenderModel`, llama `compose_wheel` +
  `draw_commands_to_svg` de cosmobiologia-render, y devuelve el
  SVG inline listo para `wheel.innerHTML = svg`.
- Cargo.toml con `wasm-bindgen` + `getrandom` con feature
  `wasm_js` solo bajo `target_arch = "wasm32"` (en nativo no se
  arrastran).
- `.cargo/config.toml` con `--cfg getrandom_backend="wasm_js"`
  para que la transitividad
  `uuid → cosmobiologia-model → cosmobiologia-render` compile a
  wasm32-unknown-unknown.
- `cargo check -p cosmobiologia-web` pasa en nativo (valida la
  signature). Build WASM real lo dispara el usuario con
  `wasm-pack build --target web --out-dir ../../../apps/
  cosmobiologia-server/static/wasm` — comando documentado en
  DEPLOY.md y en doc del crate.

cosmobiologia-server — soporte cliente WASM:
- Nuevo flag `--static-wasm <dir>` (default = static/wasm relativo
  al cwd). Si el directorio existe, los archivos WASM se sirven
  en `/static/wasm/*`. Si no existe, devuelve 404 y el cliente
  cae al SSR.
- ServeDir de `tower-http` para fileserver simple.

index.html:
- Nueva función `tryLoadWasm()` que hace `import dinámico` del
  módulo WASM al boot. Si carga OK, `wasm` global queda set; si
  falla (archivo no existe o error de WASM), se loguea info y
  sigue.
- `refreshSelected()` ahora hace fetch del RenderModel JSON
  (`/api/sky` o `/api/charts/:id/render`); si hay WASM, llama
  `wasm.render_model_to_svg(json)` localmente; si no hay WASM o
  el render WASM falla, hace fetch del SVG SSR como fallback.
- Info row muestra "WASM" o "SSR" según el modo activo —
  visualmente claro qué pipeline está corriendo.

cosmobiologia-server/DEPLOY.md (nuevo):
- Build del binario + build del WASM (con wasm-pack).
- systemd service template (sandboxing básico: ProtectSystem
  strict, ProtectHome, PrivateTmp, NoNewPrivileges).
- Caddyfile y nginx para reverse proxy con TLS.
- DNS: A records para cosmobiologia.gioser.net + api.*.
- CORS: warnings sobre permissive vs producción multi-usuario.
- Separación demo público (DB vacía en VPS) vs desktop personal
  (DB compartida en `~/.local/share/cosmobiologia/`).
- Backup con SQLite `.backup`.
- Smoke test post-deploy con curl.
- Tabla de referencia de TODOS los endpoints.

Tests: 10 verdes (cosmobiologia-render::math). El cliente WASM
no agrega tests propios — la lógica testeable vive en render.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 01:25:48 +00:00
parent eac8c58974
commit 4619ba3a2b
8 changed files with 467 additions and 14 deletions
@@ -171,30 +171,58 @@
await refreshSelected();
}
// Cliente WASM opcional. Si `/static/wasm/cosmobiologia_web.js`
// existe (= el usuario corrió `wasm-pack build` después de
// `cargo build`), el rendering se hace localmente sin pedirle
// el SVG al server por cada interacción. Si NO existe (deploy
// mínimo), caemos al SSR de `/api/*.svg`. Toggle automático,
// sin configuración.
let wasm = null;
async function tryLoadWasm() {
try {
const mod = await import('/static/wasm/cosmobiologia_web.js');
await mod.default(); // wasm-pack: inicializa
wasm = mod;
document.getElementById('info').textContent = 'WASM cargado — render local';
} catch (e) {
console.info('WASM no disponible, usando SSR:', e.message);
}
}
async function refreshSelected() {
const offset = document.getElementById('offset').value || 0;
const transit = document.getElementById('transit').checked ? '1' : '0';
const params = new URLSearchParams({ offset_min: offset, transit });
let svgUrl, infoUrl;
if (mode === 'sky') {
svgUrl = `/api/sky.svg`;
infoUrl = `/api/sky`;
const jsonUrl = mode === 'sky'
? `/api/sky`
: `/api/charts/${selectedChartId}/render?${params}`;
const ssrUrl = mode === 'sky'
? `/api/sky.svg`
: `/api/charts/${selectedChartId}/wheel.svg?${params}`;
const render = await fetch(jsonUrl).then(r => r.json()).catch(() => null);
let svg;
if (wasm && render) {
// Render local — WASM compose_wheel + draw_commands_to_svg.
try {
svg = wasm.render_model_to_svg(JSON.stringify(render), 600, 0);
} catch (e) {
console.warn('WASM render falló, fallback SSR:', e);
svg = await fetch(ssrUrl).then(r => r.text());
}
} else {
svgUrl = `/api/charts/${selectedChartId}/wheel.svg?${params}`;
infoUrl = `/api/charts/${selectedChartId}/render?${params}`;
// Fallback: SSR — el server devuelve el SVG ya compuesto.
svg = await fetch(ssrUrl).then(r => r.text());
}
const [svg, render] = await Promise.all([
fetch(svgUrl).then(r => r.text()),
fetch(infoUrl).then(r => r.json()).catch(() => null),
]);
document.getElementById('wheel-container').innerHTML = svg;
const info = document.getElementById('info');
if (render) {
const mode_label = wasm ? 'WASM' : 'SSR';
info.innerHTML =
`<b>${escapeHtml(render.title)}</b> · ` +
`Asc ${render.ascendant_deg.toFixed(2)}° · ` +
`MC ${render.midheaven_deg.toFixed(2)}° · ` +
`${render.compute_ms} ms`;
`${render.compute_ms} ms · ${mode_label}`;
} else {
info.textContent = '';
}
@@ -238,8 +266,11 @@
}[c]));
}
loadTree();
loadSky();
(async () => {
await tryLoadWasm();
await loadTree();
await loadSky();
})();
</script>
</body>
</html>