#!/usr/bin/env node // ═══════════════════════════════════════════════════════════════════════════════ // De opdrachtregel van de testcliënt. // // Alles wat hier gebeurt kan ook in het bedieningsvlak (`ui`), en dat is de // bedoeling: dit is dezelfde laag, alleen zonder browser. Handig om een proef te // scripten en om te zien wat er misgaat als de pagina niet meewerkt. // // De opdrachten staan in argumenten.js, het register in register.js, en het // praten met Evolu in client.js. Hier staat alleen de lijm plus wat er op het // scherm komt. // ═══════════════════════════════════════════════════════════════════════════════ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { gebruiksaanwijzing, leesArgumenten } from './argumenten.js'; import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js'; import { leesConfig } from './config.js'; import { klopAan } from './probe.js'; import { openRegister } from './register.js'; const config = leesConfig(); const register = openRegister(config.dataDir); const databasesIn = join(config.dataDir, 'db'); /** Zoekt een eigenaar op of stopt met een nette melding. */ const haalEigenaarOp = (naam) => { const regel = register.zoek(naam); if (!regel) { console.error(`Geen eigenaar "${naam}". \`lijst\` toont wie er wel zijn.`); process.exit(1); } return { regel, owner: eigenaarUitMnemonic(regel.mnemonic) }; }; /** Opent de opslag van een eigenaar, met of zonder relay. */ const openVoor = (regel, { metRelay }) => openStore({ directory: databasesIn, owner: eigenaarUitMnemonic(regel.mnemonic), relayUrl: metRelay ? config.relayUrl : null, // Een defect in een worker betekent dat deze instantie stuk is, en de // opdracht erna wacht dan op een antwoord dat nooit komt. Afkappen is hier // dus vriendelijker dan doordraaien: een opdrachtregel die blijft hangen // zegt niets, een foutmelding wel. onDefect: (defect) => { console.error('Defect uit een worker, de opdracht wordt afgebroken:'); console.error(defect); process.exit(1); }, // Wat Evolu wérkelijk opent, en niet wat wij hem meegaven. De `OwnerId` in // die URL is wat de relay ziet en dus wie er toegelaten of geweigerd wordt. onWebSocket: (m) => { console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`); }, }); const eisRelay = () => { if (config.relayUrl) return; console.error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.'); process.exit(1); }; const opdrachten = { nieuw: async ({ naam }) => { const owner = nieuweEigenaar(); const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id }); console.log(`Eigenaar "${regel.naam}" aangemaakt.`); console.log(` OwnerId ${regel.ownerId}`); console.log(''); console.log('Dat OwnerId is wat je op de statuspagina van de app terugziet. De mnemonic staat'); console.log(`in ${register.pad} en wordt hier niet afgedrukt.`); }, lijst: async () => { const eigenaars = register.lijst(); if (eigenaars.length === 0) { console.log('Nog geen eigenaars. `nieuw ` maakt er een.'); return; } const breedte = Math.max(...eigenaars.map((e) => e.naam.length), 4); console.log(`${'naam'.padEnd(breedte)} OwnerId`); for (const eigenaar of eigenaars) { console.log(`${eigenaar.naam.padEnd(breedte)} ${eigenaar.ownerId}`); } }, schrijf: async ({ naam, label }, vlaggen) => { const { regel } = haalEigenaarOp(naam); const bytes = Number.parseInt(vlaggen.bytes ?? '64', 10); if (!Number.isInteger(bytes) || bytes < 1) { console.error(`--bytes moet een positief geheel getal zijn, niet "${vlaggen.bytes}".`); process.exit(1); } // Een herkenbaar patroon en geen willekeur: bij het teruglezen wil je kunnen // zien dát het jouw bytes zijn, en niet alleen hoeveel het er waren. const body = new Uint8Array(bytes); for (let i = 0; i < bytes; i += 1) body[i] = i % 256; // Standaard mét relay, want schrijven zonder verbinding zegt niets over de // relay en dat is waarvoor dit gereedschap bestaat. Ontbreekt het adres, dan // is dat een fout en geen stille lokale schrijfactie: anders denk je dat je // iets beproefd hebt. Met `--lokaal` kies je er expliciet voor, en dat is // precies wat de proef "toelaten en opnieuw verbinden" nodig heeft. const lokaal = vlaggen.lokaal === true || vlaggen.lokaal === 'true'; if (!lokaal) eisRelay(); const store = await openVoor(regel, { metRelay: !lokaal }); try { const geschreven = await store.schrijf(label, body); console.log(`Geschreven: ${geschreven.id} (${bytes} bytes, label "${label}")`); if (lokaal) { console.log('Lokaal, dus niet naar de relay gestuurd.'); return; } console.log(`Verbonden met ${store.transportUrl}`); // De synchronisatie loopt asynchroon. Even wachten is hier eerlijker dan // meteen afsluiten en doen alsof het weg is. Wat er daarna op de relay // staat, zegt de statuspagina van de app en niet deze uitdraai. await new Promise((klaar) => { setTimeout(klaar, 2000); }); } finally { await store.sluit(); } }, lees: async ({ naam }) => { const { regel } = haalEigenaarOp(naam); const store = await openVoor(regel, { metRelay: false }); try { const rijen = await store.lees(); if (rijen.length === 0) { console.log(`"${naam}" heeft nog geen blobs.`); return; } console.log(`${rijen.length} blob(s) voor "${naam}":`); for (const rij of rijen) { console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`); } } finally { await store.sluit(); } }, test: async ({ naam }) => { eisRelay(); const { regel } = haalEigenaarOp(naam); console.log(`Testen bij ${config.relayUrl} als ${regel.ownerId}`); const uitkomst = await klopAan(config.relayUrl, regel.ownerId); console.log(` status ${uitkomst.status ?? '-'}`); console.log(` uitkomst ${uitkomst.uitleg}`); if (!uitkomst.geaccepteerd && uitkomst.status === 401) { console.log(''); console.log('Zet op de statuspagina van de app het venster voor nieuwe eigenaars open,'); console.log('of laat deze eigenaar daar alsnog toe.'); } process.exit(uitkomst.geaccepteerd ? 0 : 1); }, spiegel: async ({ naam }, vlaggen) => { // De enige proef die van de cliëntkant kan bewijzen dat er werkelijk iets op // de relay staat. Dezelfde eigenaar, een lege database ernaast: alles wat // hier binnenkomt heeft de heenreis en de terugreis gemaakt. // // Waarom dit nodig is: de cliënt is local-first, dus `lees` toont je altijd // je eigen rijen, ook als de relay ze nooit gezien heeft. Wat de relay // bewaart is van hier niet te zien; dit is de omweg eromheen. eisRelay(); const { regel } = haalEigenaarOp(naam); const seconden = Number.parseInt(vlaggen.wacht ?? '15', 10); const tijdelijk = mkdtempSync(join(tmpdir(), 'relay-client-spiegel-')); console.log(`"${naam}" opnieuw opgehaald in een lege database.`); console.log(` ${tijdelijk}`); console.log(` ${config.relayUrl}`); console.log(''); const store = await openStore({ directory: tijdelijk, owner: eigenaarUitMnemonic(regel.mnemonic), relayUrl: config.relayUrl, onWebSocket: (m) => { console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`); }, onDefect: (defect) => { console.error('Defect uit een worker, de opdracht wordt afgebroken:'); console.error(defect); process.exit(1); }, }); try { // Peilen tot het aantal een paar rondes stil blijft. Er is geen signaal // "hier ben ik klaar": Evolu synchroniseert en blijft synchroniseren. let vorige = -1; let stil = 0; for (let ronde = 0; ronde < seconden * 2; ronde += 1) { await new Promise((klaar) => { setTimeout(klaar, 500); }); const nu = (await store.lees()).length; if (nu === vorige && nu > 0) { stil += 1; if (stil >= 6) break; } else { stil = 0; } vorige = nu; } const rijen = await store.lees(); if (rijen.length === 0) { console.log('Er kwam niets terug.'); console.log(''); console.log('Dat kan twee dingen betekenen en de cliënt kan ze niet uit elkaar houden:'); console.log('de relay heeft niets van deze eigenaar, of hij was er nog niet klaar mee.'); console.log('Probeer `--wacht 60` en kijk anders op de statuspagina van de app.'); return; } console.log(`${rijen.length} blob(s) terug van de relay:`); for (const rij of rijen) { console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`); } } finally { await store.sluit(); // Eigen tijdelijke map, dus die ruimt deze opdracht zelf op. Er zit een // database van een echte eigenaar in. rmSync(tijdelijk, { recursive: true, force: true }); } }, sync: async ({ naam }) => { eisRelay(); const { regel } = haalEigenaarOp(naam); const store = await openVoor(regel, { metRelay: true }); console.log(`"${naam}" verbonden met ${store.transportUrl}`); console.log('Ctrl-C om te stoppen.'); const stop = async () => { console.log(''); console.log('Afsluiten...'); await store.sluit(); process.exit(0); }; process.on('SIGINT', () => { void stop(); }); // Blijven draaien. Een lopende verbinding is precies wat de proef "blokkeren // tijdens een lopende verbinding" nodig heeft; zie PLAN.md §4c. await new Promise(() => {}); }, vergeet: async ({ naam }) => { const weg = register.vergeet(naam); console.log(`"${weg.naam}" is uit het register.`); console.log('De lokale database blijft staan en op de relay verandert er niets.'); }, ui: async (_waarden, vlaggen) => { const { startUi } = await import('./ui.js'); const poort = vlaggen.poort ? Number.parseInt(vlaggen.poort, 10) : config.uiPoort; await startUi({ config: { ...config, uiPoort: poort }, register, databasesIn }); }, }; const { fout, opdracht, waarden, vlaggen } = leesArgumenten(process.argv.slice(2)); if (fout) { console.error(fout); console.error(''); console.error(gebruiksaanwijzing()); process.exit(1); } if (!opdracht) { console.log(gebruiksaanwijzing()); process.exit(0); } try { await opdrachten[opdracht](waarden, vlaggen); } catch (oorzaak) { console.error(oorzaak.message); process.exit(1); }