// ═══════════════════════════════════════════════════════════════════════════════ // Het bedieningsvlak: één pagina waarop je alle eigenaars ziet en aanstuurt. // // Wat dit scherm wél doet staat in het plan Testclient, PLAN.md §4b. Wat het // NIET doet, en dat is geen tekortkoming maar de poortindeling van de app: de // leerstand openzetten, iemand blokkeren en labelen. Die lopen via de agent-API // van Evolu Relay, en die hangt achter de inlog van umbrelOS. Zet hier dus geen // knoppen bij die dat proberen; ze kunnen niet werken. // // De server luistert op localhost en nergens anders. Hier zitten mnemonics // achter, en dit is gereedschap op je eigen machine. // ═══════════════════════════════════════════════════════════════════════════════ import { readFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js'; import { klopAan } from './probe.js'; const HIER = dirname(fileURLToPath(import.meta.url)); const MAX_BODY_BYTES = 4096; const MAX_LOG = 200; export const startUi = async ({ config, register, databasesIn }) => { /** De open verbindingen, per eigenaar. Alleen deze module raakt hem aan. */ const open = new Map(); const log = []; /** * De laatste uitkomst van "Testen", per eigenaar. * * Hierop wordt de knop "Verbinden" uitgezet. Onbekend telt als toegestaan, en * dat is met opzet: anders moet je eerst testen voordat je iets mag, en een * test is zelf een poging die in de weigerlijst van de relay belandt. */ const laatsteTest = new Map(); /** * Hoeveel sockets er achter elkaar mislukt zijn, per eigenaar. * * Evolu is local-first en blijft eindeloos opnieuw proberen. Dat is bij een * relay die even weg is precies goed, maar bij een eigenaar die geweigerd * wordt is elke poging een regel in de weigerlijst van de relay. Vandaar de * teller: na een paar mislukkingen wordt er één keer getest, en bij een * weigering gaat de verbinding eruit in plaats van door te blijven kloppen. */ const socketFouten = new Map(); const FOUTEN_VOOR_TEST = 3; const meld = (tekst) => { log.unshift({ tijd: new Date().toISOString(), tekst }); if (log.length > MAX_LOG) log.length = MAX_LOG; console.log(`${new Date().toLocaleTimeString()} ${tekst}`); }; const verbind = async (naam, metRelay) => { if (open.has(naam)) return open.get(naam); const regel = register.zoek(naam); if (!regel) throw new Error(`Geen eigenaar "${naam}".`); if (metRelay && !config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.'); const store = await openStore({ directory: databasesIn, owner: eigenaarUitMnemonic(regel.mnemonic), relayUrl: metRelay ? config.relayUrl : null, onDefect: (defect) => { meld(`${naam}: defect uit een worker, zie de terminal`); console.error(defect); }, onWebSocket: (bericht) => { if (bericht.wat === 'open') { socketFouten.set(naam, 0); meld(`${naam}: socket open`); return; } if (bericht.wat !== 'dicht') return; const aantal = (socketFouten.get(naam) ?? 0) + 1; socketFouten.set(naam, aantal); if (aantal !== FOUTEN_VOOR_TEST) return; // Precies één keer testen, bij de derde mislukking. Daarna weten we of // dit een weigering is of een relay die even weg is, en dat scheelt de // relay een weigerlijst vol pogingen. meld(`${naam}: ${aantal} sockets mislukt (${bericht.detail}), wordt getest`); void (async () => { const uitkomst = await klopAan(config.relayUrl, regel.ownerId); laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() }); if (uitkomst.geaccepteerd) { meld(`${naam}: de relay laat hem toe, dus dit is iets anders dan een weigering`); return; } meld(`${naam}: geweigerd (${uitkomst.uitleg}), verbinding wordt verbroken`); await verbreek(naam); })(); }, }); const ingang = { store, sinds: new Date().toISOString(), metRelay }; open.set(naam, ingang); meld(metRelay ? `${naam} verbonden met ${store.transportUrl}` : `${naam} lokaal geopend`); return ingang; }; const verbreek = async (naam) => { const ingang = open.get(naam); if (!ingang) return; open.delete(naam); socketFouten.delete(naam); await ingang.store.sluit(); meld(`${naam} losgekoppeld`); }; const status = async () => { const eigenaars = []; for (const regel of register.lijst()) { const ingang = open.get(regel.naam); let blobs = null; if (ingang) { try { blobs = (await ingang.store.lees()).length; } catch { blobs = null; } } const test = laatsteTest.get(regel.naam) ?? null; eigenaars.push({ naam: regel.naam, ownerId: regel.ownerId, aangemaakt: regel.aangemaakt, verbonden: Boolean(ingang), metRelay: ingang?.metRelay ?? false, sinds: ingang?.sinds ?? null, transportUrl: ingang?.store.transportUrl ?? null, blobs, test, // Onbekend telt als toegestaan: eerst moeten testen om te mogen // verbinden zou betekenen dat je altijd een poging in de weigerlijst // van de relay zet voordat je iets kunt doen. magVerbinden: test === null || test.geaccepteerd === true, }); } return { relayUrl: config.relayUrl, eigenaars, log }; }; // ── De afhandeling ────────────────────────────────────────────────────────── const acties = { nieuw: async ({ naam }) => { const owner = nieuweEigenaar(); const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id }); meld(`${regel.naam} aangemaakt, OwnerId ${regel.ownerId}`); return { naam: regel.naam, ownerId: regel.ownerId }; }, verbind: async ({ naam }) => { await verbind(naam, true); return { verbonden: true }; }, verbreek: async ({ naam }) => { await verbreek(naam); return { verbonden: false }; }, schrijf: async ({ naam, label, bytes }) => { const omvang = Number.parseInt(bytes ?? '64', 10); if (!Number.isInteger(omvang) || omvang < 1 || omvang > 8 * 1024 * 1024) { throw new Error('bytes moet tussen 1 en 8388608 liggen.'); } // Schrijven opent zo nodig zelf een verbinding, dus dezelfde poort als bij // de knop "Verbinden": een geweigerde eigenaar gaat lokaal verder in // plaats van de weigerlijst van de relay te vullen. const test = laatsteTest.get(naam); const geweigerd = test != null && test.geaccepteerd === false; const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl) && !geweigerd)); const body = new Uint8Array(omvang); for (let i = 0; i < omvang; i += 1) body[i] = i % 256; const geschreven = await ingang.store.schrijf(label || 'blob', body); meld(`${naam}: blob van ${omvang} bytes weggeschreven (${geschreven.id})`); return { id: geschreven.id, bytes: omvang }; }, lees: async ({ naam }) => { const ingang = open.get(naam) ?? (await verbind(naam, false)); const rijen = await ingang.store.lees(); return { rijen: rijen.map((rij) => ({ id: rij.id, label: rij.label, bytes: rij.body?.length ?? 0, })), }; }, test: async ({ naam }) => { const regel = register.zoek(naam); if (!regel) throw new Error(`Geen eigenaar "${naam}".`); if (!config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.'); const uitkomst = await klopAan(config.relayUrl, regel.ownerId); laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() }); meld(`${naam}: testen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`); return uitkomst; }, vergeet: async ({ naam }) => { await verbreek(naam); laatsteTest.delete(naam); const weg = register.vergeet(naam); meld(`${weg.naam} uit het register gehaald`); return { naam: weg.naam }; }, }; const leesBody = (verzoek) => new Promise((klaar, faal) => { let ruw = ''; verzoek.on('data', (stuk) => { ruw += stuk; if (ruw.length > MAX_BODY_BYTES) { faal(new Error('Verzoek te groot.')); verzoek.destroy(); } }); verzoek.on('end', () => { try { klaar(ruw === '' ? {} : JSON.parse(ruw)); } catch (oorzaak) { faal(new Error(`Onleesbare JSON: ${oorzaak.message}`)); } }); verzoek.on('error', faal); }); const stuur = (antwoord, code, waarde) => { const inhoud = JSON.stringify(waarde); antwoord.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(inhoud), }); antwoord.end(inhoud); }; const server = createServer((verzoek, antwoord) => { void (async () => { const pad = (verzoek.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/'; if (verzoek.method === 'GET' && (pad === '/' || pad === '/index.html')) { // Elke keer van schijf. Dit is gereedschap op je eigen machine, dus een // pagina bijwerken en verversen hoort te werken zonder herstart. const pagina = readFileSync(join(HIER, 'index.html')); antwoord.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': pagina.length, }); antwoord.end(pagina); return; } if (verzoek.method === 'GET' && pad === '/api/status') { stuur(antwoord, 200, await status()); return; } if (verzoek.method === 'POST' && pad.startsWith('/api/')) { const actie = acties[pad.slice('/api/'.length)]; if (!actie) { stuur(antwoord, 404, { fout: 'Onbekende actie.' }); return; } try { stuur(antwoord, 200, await actie(await leesBody(verzoek))); } catch (oorzaak) { // 400 en niet 500: vrijwel alles wat hier misgaat is een naam die al // bestaat of een relay die er niet is, en dat is geen serverfout. stuur(antwoord, 400, { fout: oorzaak.message }); } return; } stuur(antwoord, 404, { fout: 'Niet gevonden.' }); })(); }); await new Promise((klaar) => { server.listen(config.uiPoort, '127.0.0.1', klaar); }); const adres = `http://127.0.0.1:${config.uiPoort}`; console.log(''); console.log(` Bedieningsvlak: ${adres}`); console.log(` Relay: ${config.relayUrl ?? 'niet ingesteld (zie .env.sample)'}`); console.log(` Register: ${register.pad}`); console.log(''); console.log(' Ctrl-C om te stoppen.'); console.log(''); const stop = async () => { console.log(''); console.log('Afsluiten...'); server.close(); for (const naam of [...open.keys()]) await verbreek(naam); process.exit(0); }; process.on('SIGINT', () => { void stop(); }); await new Promise(() => {}); };