@gaabrielmooraes/sheet-relay 0.0.0-stage → 0.1.0

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +18 -0
  3. package/apps/dnd5e/_expo/static/js/web/index-4abe37a46a739db17a1b5d432ec266e6.js +429 -0
  4. package/apps/dnd5e/apple-touch-icon.png +0 -0
  5. package/apps/dnd5e/favicon.ico +0 -0
  6. package/apps/dnd5e/icon-192.png +0 -0
  7. package/apps/dnd5e/icon-512.png +0 -0
  8. package/apps/dnd5e/icon-maskable-512.png +0 -0
  9. package/apps/dnd5e/index.html +35 -0
  10. package/apps/dnd5e/manifest.json +15 -0
  11. package/apps/dnd5e/metadata.json +1 -0
  12. package/apps/dnd5e/sw.js +4 -0
  13. package/apps/fallout/_expo/static/js/web/index-281034279d7bc616f8e2d981d5fa2a0b.js +431 -0
  14. package/apps/fallout/apple-touch-icon.png +0 -0
  15. package/apps/fallout/favicon.ico +0 -0
  16. package/apps/fallout/icon-192.png +0 -0
  17. package/apps/fallout/icon-512.png +0 -0
  18. package/apps/fallout/icon-maskable-512.png +0 -0
  19. package/apps/fallout/index.html +35 -0
  20. package/apps/fallout/manifest.json +15 -0
  21. package/apps/fallout/metadata.json +1 -0
  22. package/apps/fallout/sw.js +4 -0
  23. package/apps/legends/_expo/static/js/web/index-429d3095530ce378a9ca9b24d24ebda8.js +429 -0
  24. package/apps/legends/apple-touch-icon.png +0 -0
  25. package/apps/legends/favicon.ico +0 -0
  26. package/apps/legends/icon-192.png +0 -0
  27. package/apps/legends/icon-512.png +0 -0
  28. package/apps/legends/icon-maskable-512.png +0 -0
  29. package/apps/legends/index.html +35 -0
  30. package/apps/legends/manifest.json +15 -0
  31. package/apps/legends/metadata.json +1 -0
  32. package/apps/legends/sw.js +4 -0
  33. package/bin/sheet-relay.mjs +37 -0
  34. package/headless.mjs +70 -0
  35. package/package.json +50 -4
  36. package/server.js +136 -0
  37. package/static.js +29 -0
  38. package/README.md +0 -3
Binary file
Binary file
Binary file
@@ -0,0 +1,35 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
7
+ <title>Ficha Avatar</title>
8
+ <!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
9
+ <style id="expo-reset">
10
+ /* These styles make the body full-height */
11
+ html,
12
+ body {
13
+ height: 100%;
14
+ }
15
+ /* These styles disable body scrolling if you are using <ScrollView> */
16
+ body {
17
+ overflow: hidden;
18
+ }
19
+ /* These styles make the root element full-height */
20
+ #root {
21
+ display: flex;
22
+ height: 100%;
23
+ flex: 1;
24
+ }
25
+ </style>
26
+ <link rel="icon" href="/ficha/favicon.ico"/></head>
27
+
28
+ <body>
29
+ <!-- Use static rendering with Expo Router to support running without JavaScript. -->
30
+ <noscript> You need to enable JavaScript to run this app. </noscript>
31
+ <!-- The root element for your Expo app. -->
32
+ <div id="root"></div>
33
+ <script src="/ficha/_expo/static/js/web/index-429d3095530ce378a9ca9b24d24ebda8.js" defer></script>
34
+ </body>
35
+ </html>
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "Ficha Foundry — Avatar Legends",
3
+ "short_name": "Ficha Avatar",
4
+ "description": "Fichas de Avatar Legends do Foundry VTT no celular.",
5
+ "start_url": "/ficha/",
6
+ "scope": "/ficha/",
7
+ "display": "standalone",
8
+ "background_color": "#1d2433",
9
+ "theme_color": "#1d2433",
10
+ "icons": [
11
+ { "src": "/ficha/icon-192.png", "sizes": "192x192", "type": "image/png" },
12
+ { "src": "/ficha/icon-512.png", "sizes": "512x512", "type": "image/png" },
13
+ { "src": "/ficha/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
14
+ ]
15
+ }
@@ -0,0 +1 @@
1
+ {"version":0,"bundler":"metro","fileMetadata":{}}
@@ -0,0 +1,4 @@
1
+ // Service worker mínimo: só para o navegador oferecer "Instalar app". Sem cache: a ficha precisa estar online (dados ao vivo do Foundry).
2
+ self.addEventListener("install", () => self.skipWaiting());
3
+ self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
4
+ self.addEventListener("fetch", () => {});
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // CLI do pacote npm: `sheet-relay serve` (relay + app) e `sheet-relay headless` (usuário do relay sem Docker).
3
+ import { existsSync, readdirSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const pkg = path.resolve(here, "..");
9
+ const [cmd, ...args] = process.argv.slice(2);
10
+ const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def; };
11
+ const apps = path.join(pkg, "apps");
12
+
13
+ const help = () => {
14
+ console.log(`sheet-relay <command>
15
+
16
+ serve Run the relay and serve the web app.
17
+ --system <id> dnd5e | fallout | legends (env SYSTEM, default dnd5e)
18
+ --port <n> (env PORT, default 8787) --host <h> (env HOST, default 127.0.0.1)
19
+ --base <path> where the app is served (env BASE_PATH, default /ficha; must match the build)
20
+ Env: RELAY_SECRET (shared secret with the module setting "Relay secret"), TRUST_PROXY=1 behind a reverse proxy.
21
+ headless Keep the relay user logged in to your Foundry world (needs: npm i playwright && npx playwright install chromium).
22
+ Env: FOUNDRY_URL, FOUNDRY_USER, FOUNDRY_PASSWORD, [FOUNDRY_HOST]`);
23
+ };
24
+
25
+ if (cmd === "serve") {
26
+ const system = opt("system", process.env.SYSTEM ?? "dnd5e");
27
+ const dir = path.join(apps, system);
28
+ if (!existsSync(dir)) { console.error(`No bundled app for "${system}". Available: ${existsSync(apps) ? readdirSync(apps).join(", ") : "none"}`); process.exit(1); }
29
+ process.env.STATIC_DIR = dir;
30
+ process.env.BASE_PATH = opt("base", process.env.BASE_PATH ?? "/ficha");
31
+ const { server } = await import("../server.js");
32
+ const port = Number(opt("port", process.env.PORT ?? 8787)), host = opt("host", process.env.HOST ?? "127.0.0.1");
33
+ if (!process.env.RELAY_SECRET) console.warn("WARNING: RELAY_SECRET is not set; anyone who can reach this port can impersonate Foundry.");
34
+ server.listen(port, host, () => console.log(`sheet-relay on http://${host}:${port}${process.env.BASE_PATH}/ (system ${system})`));
35
+ } else if (cmd === "headless") {
36
+ await import("../headless.mjs");
37
+ } else { help(); process.exit(cmd ? 1 : 0); }
package/headless.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // Mantém o usuário do relay logado no Foundry (sem canvas). Se algo falhar, sai com erro e o Docker reinicia.
2
+ import { chromium } from "playwright";
3
+
4
+ const { FOUNDRY_URL, FOUNDRY_USER, FOUNDRY_PASSWORD } = process.env;
5
+ if (!FOUNDRY_URL || !FOUNDRY_USER || !FOUNDRY_PASSWORD) throw new Error("defina FOUNDRY_URL, FOUNDRY_USER e FOUNDRY_PASSWORD");
6
+
7
+ // deste mundo está desligado, espera aqui sem abrir o navegador; quando ele volta, entra.
8
+ const probe = (process.env.FOUNDRY_HOST ? FOUNDRY_URL.replace("localhost", process.env.FOUNDRY_HOST) : FOUNDRY_URL) + "/join";
9
+ for (let warned = false; ; warned = true) {
10
+ const up = await fetch(probe, { signal: AbortSignal.timeout(5_000) }).then((r) => r.ok, () => false);
11
+ if (up) break;
12
+ if (!warned) console.log("Foundry fora do ar; aguardando ele ligar...");
13
+ await new Promise((r) => setTimeout(r, 15_000));
14
+ }
15
+
16
+ const browser = await chromium.launch({
17
+ // O módulo usa crypto.subtle (só em contexto seguro) e abre ws:// no relay (bloqueado em páginas https).
18
+ // Solução: abrir o Foundry como http://localhost, que é seguro, e resolver "localhost" para o contêiner do Foundry.
19
+ args: ["--no-sandbox", "--disable-gpu", ...(process.env.FOUNDRY_HOST ? [`--host-resolver-rules=MAP localhost ${process.env.FOUNDRY_HOST}`] : [])]
20
+ });
21
+ browser.on("disconnected", () => process.exit(1));
22
+ const page = await browser.newPage();
23
+ // Foundry ativa módulos por mundo, não por usuário; aqui o navegador do relay recebe um módulo vazio no lugar dos que não estão na lista.
24
+ // audit-logger fica: ele registra no Discord as edições feitas por este cliente (as do app).
25
+ const ALLOW = (process.env.MODULES_ALLOW ?? "sheet-relay,lib-wrapper,audit-logger").split(",");
26
+ // O relay só precisa dos dados: vídeo/YouTube (foundry-tube), imagens e fontes eram ~125% de CPU.
27
+ await page.route("**/*", (r) => {
28
+ const q = r.request(), url = q.url();
29
+ if (["image", "media", "font"].includes(q.resourceType()) || /youtube|googlevideo|ytimg|ggpht/.test(url)) return r.abort();
30
+ const mod = url.match(/\/modules\/([^/]+)\/.*\.(m?js|css)(\?|$)/);
31
+ if (mod && !ALLOW.includes(mod[1])) return r.fulfill({ contentType: mod[2] === "css" ? "text/css" : "text/javascript", body: mod[2] === "css" ? "" : "export {};" });
32
+ return r.continue();
33
+ });
34
+ // Configuração de cliente "Sem canvas" do Foundry (o parâmetro ?nocanvas não funciona na v13): evita o WebGL e a CPU alta.
35
+ await page.addInitScript(() => {
36
+ localStorage.setItem("core.noCanvas", "true");
37
+ localStorage.setItem("core.performanceMode", "0"); // LOW
38
+ localStorage.setItem("core.maxFPS", "10");
39
+ });
40
+ await page.goto(`${FOUNDRY_URL}/join`);
41
+ await page.selectOption('select[name="userid"]', { label: FOUNDRY_USER });
42
+ await page.fill('input[name="password"]', FOUNDRY_PASSWORD);
43
+ await page.click('button[name="join"]');
44
+ await page.waitForURL("**/game", { timeout: 30_000 });
45
+ await page.goto(`${FOUNDRY_URL}/game`);
46
+ await page.waitForFunction(() => globalThis.game?.ready, null, { timeout: 120_000 });
47
+ const info = await page.evaluate(() => ({ canvasReady: !!globalThis.canvas?.ready, hasStage: !!globalThis.canvas?.stage, webgl: !!globalThis.canvas?.app?.renderer }));
48
+ console.log(`logado como ${FOUNDRY_USER}`, JSON.stringify(info));
49
+
50
+ if (process.env.DIAG) { // DIAG=1: perfila o JS da página e imprime as funções mais pesadas (tempo próprio)
51
+ await new Promise((r) => setTimeout(r, 20_000));
52
+ const cdp = await page.context().newCDPSession(page);
53
+ await cdp.send("Profiler.enable"); await cdp.send("Profiler.start");
54
+ await new Promise((r) => setTimeout(r, 8_000));
55
+ const { profile } = await cdp.send("Profiler.stop");
56
+ const self = new Map(); const byId = new Map(profile.nodes.map((n) => [n.id, n]));
57
+ profile.samples.forEach((id, i) => {
58
+ const f = byId.get(id).callFrame; const k = `${f.functionName || "(anon)"} ${f.url.replace(/^https?:\/\/[^/]+/, "")}:${f.lineNumber}`;
59
+ self.set(k, (self.get(k) ?? 0) + (profile.timeDeltas[i] ?? 0));
60
+ });
61
+ const total = [...self.values()].reduce((a, b) => a + b, 0);
62
+ [...self].sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([k, v]) => console.log(`${((v / total) * 100).toFixed(1)}% ${k}`));
63
+ process.exit(0);
64
+ }
65
+
66
+ for (;;) { // vigia: mundo recarregado, Foundry desligado (socket caiu) ou aba morta -> reinicia o contêiner, que volta a esperar
67
+ await new Promise((r) => setTimeout(r, 30_000));
68
+ const ok = await page.evaluate(() => !!globalThis.game?.ready && !!globalThis.game.socket?.connected).catch(() => false);
69
+ if (!ok) { console.error("sessão perdida"); process.exit(1); }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@gaabrielmooraes/sheet-relay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "start": "node server.js",
7
+ "test": "node test.js && node test-static.js"
8
+ },
9
+ "dependencies": {
10
+ "ws": "^8.18.0"
11
+ },
12
+ "description": "Relay + web app that serve Foundry VTT character sheets to a companion app (players and GM). Pairs with the sheet-relay Foundry module.",
13
+ "license": "MIT",
14
+ "bin": {
15
+ "sheet-relay": "bin/sheet-relay.mjs"
16
+ },
17
+ "files": [
18
+ "server.js",
19
+ "static.js",
20
+ "headless.mjs",
21
+ "bin",
22
+ "apps",
23
+ "LICENSE",
24
+ "NOTICE",
25
+ "README.md"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/GabrielDiasMoraes/sheet-relay.git"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "keywords": [
35
+ "foundry-vtt",
36
+ "foundryvtt",
37
+ "character-sheet",
38
+ "rpg"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ },
44
+ "peerDependencies": {
45
+ "playwright": ">=1.45"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "playwright": {
49
+ "optional": true
50
+ }
51
+ }
52
+ }
package/server.js ADDED
@@ -0,0 +1,136 @@
1
+ import http from "node:http";
2
+ import { WebSocketServer } from "ws";
3
+ import { timingSafeEqual } from "node:crypto";
4
+ import { staticHandler } from "./static.js";
5
+
6
+ const PORT = process.env.PORT ?? 8787;
7
+ const MAX_FAILS = 10, WINDOW_MS = 60_000;
8
+ const pending = new Map();
9
+ const fails = new Map(); // ip -> [timestamps de falhas de auth]
10
+ let foundry = null; // conexão do módulo (Foundry)
11
+ const subs = new Map(); // actorId -> Set(res): apps com a ficha aberta (Server-Sent Events)
12
+ let seq = 0;
13
+
14
+ const wss = new WebSocketServer({ noServer: true });
15
+ wss.on("connection", (ws) => {
16
+ foundry = ws;
17
+ ws.on("message", (raw) => {
18
+ const m = JSON.parse(raw);
19
+ if (m.event === "actor") for (const r of subs.get(m.actorId) ?? []) r.write("data: changed\n\n");
20
+ else pending.get(m.id)?.(m);
21
+ });
22
+ ws.on("close", () => { if (foundry === ws) foundry = null; });
23
+ });
24
+
25
+ const ask = (msg) => new Promise((resolve, reject) => {
26
+ if (!foundry) return reject(Object.assign(new Error("Foundry disconnected"), { status: 503, code: "relay.disconnected" }));
27
+ const id = ++seq;
28
+ const t = setTimeout(() => { pending.delete(id); reject(Object.assign(new Error("timeout"), { status: 504 })); }, 60_000);
29
+ pending.set(id, (m) => { clearTimeout(t); pending.delete(id); resolve(m); });
30
+ foundry.send(JSON.stringify({ id, ...msg }));
31
+ });
32
+
33
+ // IP do jogador para o limite de tentativas. O relay só é acessível pelo Caddy (rede docker, sem porta no host), e o Caddy
34
+ // sobrescreve o X-Forwarded-For com o IP real do cliente; sem isso todos os jogadores dividiam o IP do proxy e uma
35
+ // sessão velha de um bloqueava a mesa inteira.
36
+ // X-Forwarded-For só vale atrás de proxy confiável (TRUST_PROXY=1); direto na internet qualquer um forjaria o IP e burlaria o limite.
37
+ const clientIp = (req) => (process.env.TRUST_PROXY && req.headers["x-forwarded-for"]?.split(",").at(-1).trim()) || req.socket.remoteAddress;
38
+
39
+ const recentFails = (ip) => {
40
+ const l = (fails.get(ip) ?? []).filter((t) => Date.now() - t < WINDOW_MS);
41
+ fails.set(ip, l);
42
+ return l;
43
+ };
44
+
45
+ const routes = [
46
+ ["GET", /^\/api\/users$/, () => ({ type: "listUsers", public: true })],
47
+ ["GET", /^\/api\/status$/, () => ({ type: "status", public: true })], // sistema e plugin carregados no mundo (sem token)
48
+ ["GET", /^\/api\/characters$/, () => ({ type: "listCharacters" })],
49
+ ["GET", /^\/api\/character\/([\w-]+)$/, (m) => ({ type: "getCharacter", actorId: m[1] })],
50
+ ["GET", /^\/api\/packs$/, () => ({ type: "listPacks" })],
51
+ ["GET", /^\/api\/packs\/([\w.-]+)\/search$/, (m, body, url) => ({ type: "searchCompendium", pack: m[1], query: url.searchParams.get("q") ?? "" })],
52
+ // Rota genérica: qualquer comando do módulo, argumentos no corpo. É o que os apps novos (ex.: Avatar) usam;
53
+ // o módulo valida o comando e a permissão, então o relay não precisa conhecer cada um.
54
+ ["POST", /^\/api\/call\/(\w+)$/, (m, body) => { const { type: _t, token: _k, id: _i, ...args } = body ?? {}; return { ...args, type: m[1] }; }],
55
+ ["POST", /^\/api\/password$/, (m, body) => ({ type: "changePassword", oldPassword: body.oldPassword, newPassword: body.newPassword })],
56
+ // sem token: o único endpoint público; falhas contam no limite por IP
57
+ ["POST", /^\/api\/login$/, (m, body) => ({ type: "login", username: body.username, password: body.password, public: true })]
58
+ ];
59
+
60
+ const MAX_BODY = 64 * 1024;
61
+ // Corpo grande demais: drena sem guardar e rejeita no fim, para a resposta 413 chegar (destruir o socket vira "conexão caiu").
62
+ const readJson = (req) => new Promise((resolve, reject) => {
63
+ let raw = "", big = false;
64
+ req.on("data", (c) => { if (big) return; raw += c; if (raw.length > MAX_BODY) { big = true; raw = ""; } });
65
+ req.on("error", () => resolve(null));
66
+ req.on("end", () => {
67
+ if (big) return reject(Object.assign(new Error("Request body too large"), { status: 413, code: "relay.bodyTooLarge" }));
68
+ try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); }
69
+ });
70
+ });
71
+
72
+ // STATIC_DIR (pasta do `expo export` de um sistema) faz o relay servir também o app em BASE_PATH (padrão /ficha). O app chama /sheet-api/...; /api/... continua valendo.
73
+ const serveApp = process.env.STATIC_DIR ? staticHandler({ dir: process.env.STATIC_DIR, base: process.env.BASE_PATH ?? "/ficha" }) : null;
74
+
75
+ export const server = http.createServer(async (req, res) => {
76
+ // Token em header (sem cookie), então "*" não abre brecha de CSRF.
77
+ const out = (s, body) => { res.writeHead(s, { "content-type": "application/json", "access-control-allow-origin": "*" }); res.end(JSON.stringify(body)); };
78
+ if (req.method === "OPTIONS") {
79
+ res.writeHead(204, { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization,content-type", "access-control-allow-methods": "GET,PATCH,POST,OPTIONS" });
80
+ return res.end();
81
+ }
82
+ const ip = clientIp(req);
83
+ const url = new URL(req.url.replace(/^\/sheet-api(?=\/|$|\?)/, "/api"), "http://relay");
84
+ if (serveApp && req.method === "GET" && !url.pathname.startsWith("/api/") && await serveApp(url.pathname, res)) return;
85
+ const route = routes.map(([method, re, build]) => req.method === method && [url.pathname.match(re), build]).find((r) => r && r[0]);
86
+ if (url.pathname === "/api/events" && req.method === "GET") return events(req, res, url, ip, out);
87
+ if (!route) return out(404, { error: "Not found", code: "relay.notFound" });
88
+
89
+ if (recentFails(ip).length >= MAX_FAILS) return out(429, { error: "Too many attempts", code: "relay.tooManyAttempts" });
90
+ const token = req.headers.authorization?.match(/^Bearer (\S+)$/)?.[1];
91
+ const fail401 = (msg = "Invalid token", code = "relay.invalidToken") => { recentFails(ip).push(Date.now()); out(401, { error: msg, ...(code && { code }) }); };
92
+ const isPublic = route[1](route[0], {}, url).public;
93
+ if (!token && !isPublic) return fail401();
94
+
95
+ try {
96
+ const body = req.method === "GET" ? {} : await readJson(req);
97
+ if (!body) return out(400, { error: "Invalid JSON", code: "relay.badJson" });
98
+ const { public: _p, ...cmd } = route[1](route[0], body, url);
99
+ const r = await ask({ ...cmd, token });
100
+ if (r.status === 401) return fail401(r.error, r.code ?? null);
101
+ out(r.ok ? 200 : r.status ?? 500, r.ok ? r.data : { error: r.error, code: r.code });
102
+ } catch (e) { out(e.status ?? 500, { error: e.message, code: e.code }); }
103
+ });
104
+ // GET /api/events?actor=ID&t=TOKEN — EventSource não manda header, por isso o token vem na URL.
105
+ // Mantém a conexão aberta e manda "changed" quando a ficha muda no Foundry.
106
+ async function events(req, res, url, ip, out) {
107
+ if (recentFails(ip).length >= MAX_FAILS) return out(429, { error: "Too many attempts", code: "relay.tooManyAttempts" });
108
+ const actorId = url.searchParams.get("actor") ?? "", token = url.searchParams.get("t") ?? "";
109
+ if (!/^[\w-]+$/.test(actorId) || !token) { recentFails(ip).push(Date.now()); return out(401, { error: "Invalid token", code: "relay.invalidToken" }); }
110
+ try {
111
+ const r = await ask({ type: "subscribe", actorId, token });
112
+ if (!r.ok) { if (r.status === 401) recentFails(ip).push(Date.now()); return out(r.status ?? 500, { error: r.error, code: r.code }); }
113
+ } catch (e) { return out(e.status ?? 500, { error: e.message, code: e.code }); }
114
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", "access-control-allow-origin": "*" });
115
+ res.write(": ok\n\n");
116
+ if (!subs.has(actorId)) subs.set(actorId, new Set());
117
+ subs.get(actorId).add(res);
118
+ const beat = setInterval(() => res.write(": ping\n\n"), 25_000); // mantém proxies sem derrubar a conexão ociosa
119
+ req.on("close", () => { clearInterval(beat); subs.get(actorId)?.delete(res); if (!subs.get(actorId)?.size) subs.delete(actorId); });
120
+ }
121
+
122
+ // O módulo do Foundry é quem se conecta aqui e recebe logins e tokens: com RELAY_SECRET só quem tem o segredo entra.
123
+ const secretOk = (req) => {
124
+ const want = process.env.RELAY_SECRET;
125
+ if (!want) return true;
126
+ const got = String(new URL(req.url, "http://x").searchParams.get("secret") ?? "");
127
+ const a = Buffer.from(got), b = Buffer.from(want);
128
+ return a.length === b.length && timingSafeEqual(a, b);
129
+ };
130
+ server.on("upgrade", (req, sock, head) => {
131
+ if (!secretOk(req)) { sock.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); return sock.destroy(); }
132
+ wss.handleUpgrade(req, sock, head, (ws) => wss.emit("connection", ws));
133
+ });
134
+
135
+ // Escuta só em localhost por padrão; no Docker, HOST=0.0.0.0 e só o Caddy (HTTPS) alcança a porta.
136
+ if (import.meta.url === `file://${process.argv[1]}`) server.listen(PORT, process.env.HOST ?? "127.0.0.1", () => { console.log(`relay em :${PORT}`); if (!process.env.RELAY_SECRET) console.warn("AVISO: RELAY_SECRET não definido; qualquer um que alcance esta porta pode se passar pelo Foundry."); });
package/static.js ADDED
@@ -0,0 +1,29 @@
1
+ // Serve o app web (expo export) sob BASE (padrão /ficha): arquivos estáticos + volta para index.html nas rotas do app.
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".css": "text/css", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".ttf": "font/ttf", ".woff2": "font/woff2", ".woff": "font/woff", ".map": "application/json", ".txt": "text/plain" };
6
+
7
+ export function staticHandler({ dir, base = "/ficha" }) {
8
+ const root = path.resolve(dir);
9
+ const b = ("/" + base.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "");
10
+ /** @returns {Promise<boolean>} true se a requisição era do app (e já foi respondida) */
11
+ return async (pathname, res) => {
12
+ if (pathname === "/" && b) { res.writeHead(302, { location: `${b}/` }); res.end(); return true; }
13
+ if (b && pathname === b) { res.writeHead(302, { location: `${b}/` }); res.end(); return true; }
14
+ if (b && !pathname.startsWith(`${b}/`)) return false;
15
+ let rel;
16
+ try { rel = decodeURIComponent(pathname.slice(b.length)); } catch { res.writeHead(400); res.end(); return true; }
17
+ let file = path.join(root, rel);
18
+ if (file !== root && !file.startsWith(root + path.sep)) { res.writeHead(403); res.end(); return true; }
19
+ let st = await stat(file).catch(() => null);
20
+ if (st?.isDirectory()) { file = path.join(file, "index.html"); st = await stat(file).catch(() => null); }
21
+ if (!st && !path.extname(rel)) { file = path.join(root, "index.html"); st = await stat(file).catch(() => null); } // rota do app
22
+ if (!st) { res.writeHead(404, { "content-type": "text/plain" }); res.end("Not found"); return true; }
23
+ const ext = path.extname(file);
24
+ const immutable = /\/_expo\/static\//.test(file);
25
+ res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache" });
26
+ res.end(await readFile(file));
27
+ return true;
28
+ };
29
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.