diff --git a/Docs/CONTINUE_HERE.md b/Docs/CONTINUE_HERE.md index cce4eaf..6934988 100644 --- a/Docs/CONTINUE_HERE.md +++ b/Docs/CONTINUE_HERE.md @@ -23,7 +23,7 @@ | Plan | App | Volgende stap | Status | |-|-|-|-| -| [Testclient](Plannen/Actief/003-Testclient/TAKEN.md) | Relay | **De cliënt met de relay laten praten. Daarvoor is het adres van de Umbrel nodig, en dat komt van de gebruiker.** Fase 1 is af op 09-09-2026: `@evolu/nodejs` blijkt géén cliënt te bevatten, dus de afhankelijkheden voor `createEvolu` worden zelf samengesteld in `tools/relay-client/src/evolu-node.js`. Bewezen zonder relay: een eigenaar aanmaken, een blob wegschrijven en teruglezen, en schoon afsluiten. Vier valstrikken kostten dat, alle vier stil falend; ze staan in `PLAN.md` §4e | 🔶 | +| [Testclient](Plannen/Actief/003-Testclient/TAKEN.md) | Relay | **De cliënt staat; er wacht één regel in `.env`. Eigenaar: gebruiker,** want het adres van de Umbrel gaat niet in deze publieke repo. Op 09-09-2026 gebouwd: het register, acht opdrachten op de opdrachtregel, een bedieningsvlak op `127.0.0.1:4380` met `Start.bat` en `Start.command`, en `klop` die de HTTP-status van de WebSocket-upgrade toont (101 of 401). **Alles wat de relay raakt is gebouwd en niets ervan is beproefd.** Uitkomst die het meeste stuurt: `@evolu/nodejs` bevat géén cliënt, dus de afhankelijkheden voor `createEvolu` worden zelf samengesteld; de vier stil falende valstrikken staan in `PLAN.md` §4e | 🔶 | ## B - Los oppakbaar (geen blokkade, geen vaste volgorde) diff --git a/Docs/Plannen/Actief/003-Testclient/OPEN.md b/Docs/Plannen/Actief/003-Testclient/OPEN.md index ca7e864..bb83938 100644 --- a/Docs/Plannen/Actief/003-Testclient/OPEN.md +++ b/Docs/Plannen/Actief/003-Testclient/OPEN.md @@ -3,19 +3,22 @@ > Vragen die het plan niet beantwoordt en die tijdens het werk beslist moeten worden. Wat af is, verhuist > naar [PROGRESS.md](PROGRESS.md); wat een taak wordt, naar [TAKEN.md](TAKEN.md). -1. **Hoe zichtbaar is een 401 in de cliënt?** Evolu probeert opnieuw en is ontworpen om een relay te - overleven die er niet is. Als een weigering en een relay die plat ligt in de cliënt hetzelfde signaal - geven, is de eerste proef uit `PLAN.md` §4c niet af te lezen. De uitweg blijft binnen dit gereedschap: - een eigen WebSocket-poging naast Evolu, met dezelfde `OwnerId` in de URL, die de statuscode wel te zien - krijgt. Dat is een handeling van een paar regels, want de `OwnerId` staat in de URL van de verbinding - (§10 van [Upstream-evolu-relay.md](../../../Referenties/Upstream-evolu-relay.md)). +1. ~~**Hoe zichtbaar is een 401 in de cliënt?**~~ **Opgelost op 09-09-2026, vóórdat het een probleem + werd.** `src/probe.js` doet de WebSocket-upgrade zelf met `node:http` en leest de statuscode af; de + opdracht `klop` en de knop "Aankloppen" tonen 101 of 401. Met `node:http` en niet met de WebSocket van + Node, en dat is de hele reden dat dat bestand bestaat: die laatste geeft je bij een weigering een + `error` en geen status, en 401 tegenover 404 tegenover "niets luistert" is precies wat je wilt weten. + Wat er nog niet is, is een keer zien dat het klopt tegen een echte relay. -2. **Wat is een zinnige blob om mee te experimenteren?** Willekeurige bytes zijn genoeg voor de bytegrens, - maar niet om te zien of gegevens werkelijk heen en weer gaan. Iets met leesbare inhoud en een teller is - waarschijnlijk beter. +2. **Wat is een zinnige blob om mee te experimenteren?** Voorlopig een oplopend bytepatroon (`i % 256`) + met een label erbij: genoeg om de bytegrens te raken en om bij het teruglezen te zien dát het jouw + bytes zijn. Wat er nog niet is, is inhoud die iets betekent, en dat gaat pas meespelen zodra twee + eigenaars werkelijk iets over de relay uitwisselen. -3. **Wordt de gegevensmap van de proef opgeruimd?** Twintig eigenaars die blijven staan is rommel, en het - is sleutelmateriaal. Waarschijnlijk een opdracht die er één weggooit en één die alles weggooit. +3. **De gegevensmap wordt niet opgeruimd.** `vergeet` haalt een eigenaar uit het register en laat zijn + database staan; dat staat er ook bij in de uitdraai. Zolang het er een handvol zijn is dat prima, maar + het is wél sleutelmateriaal dat je dan kwijt bent zonder het weg te gooien. Een opdracht die de + database meeneemt hoort erbij, en misschien een die alles opruimt. 4. **Data per eigenaar wissen op de relay staat al open bij Umbrelapp.** Dit gereedschap maakt dat makkelijk te beproeven maar lost het niet op; het blijft schrijven in andermans schema (§8 van hetzelfde @@ -27,12 +30,23 @@ wel dat de samenstelling uit `PLAN.md` §4e bij elke verhoging opnieuw langs moet. Beslissen bij de eerste verhoging, niet nu. -6. **Waar staat de relay voor deze cliënt?** Het adres van de Umbrel staat nergens in deze repo en hoort - daar ook niet in: dit is een publieke repo (zie `.gitignore`, "Geheimen"). Dus een instelling die de - gebruiker zelf zet, met een voorbeeldbestand ernaast. Voor de hand ligt `.env.sample`, want dat patroon - staat al in de `.gitignore` van de repo-root. +6. ~~**Waar staat de relay voor deze cliënt?**~~ **Beslist op 09-09-2026:** `RELAY_URL` in `.env`, met + `.env.sample` als voorbeeld in de repo. Het adres van de Umbrel staat nergens in deze repo en hoort daar + ook niet in; dit is een publieke repo (zie `.gitignore`, "Geheimen"). **Wat er nog niet is, is de + waarde**, en dat is de volgende stap in [TAKEN.md](TAKEN.md). -7. **Het afsluiten is opgelost maar niet doorgrond.** De volgorde in `dispose` (afhankelijkheden, dan een +7. **Het bedieningsvlak tekent de hele lijst opnieuw.** Er zit nu een vergelijking voor: verandert de + status niet, dan gebeurt er niets. Dat was nodig omdat het veld met de omvang anders elke drie seconden + leegging terwijl je erin typt. Wordt de lijst langer of komt er meer per eigenaar bij, dan is dit het + punt waarop het opnieuw gaat schuren, en dan is per eigenaar bijwerken de juiste stap. Nu niet: dat is + machinerie voor een probleem dat er nog niet is. + +8. **`sync` en het bedieningsvlak weten niet of een verbinding er nog is.** Er staat "verbonden" zodra de + instantie gemaakt is, en Evolu meldt niet dat de relay weggevallen is (hij probeert het gewoon opnieuw). + Voor de proeven is `klop` het antwoord, maar op de pagina staat een groen bolletje dat meer belooft dan + het weet. Evolu heeft een `SyncState`; uitzoeken of daar iets bruikbaars in zit. + +9. **Het afsluiten is opgelost maar niet doorgrond.** De volgorde in `dispose` (afhankelijkheden, dan een tik doorlaten, dan de resources van de gedeelde worker, dan de runs) is gevonden door te proberen, niet door de bron van Evolu te lezen. Hij werkt en er is een reden bij opgeschreven, maar het is geen bewijs. Valt het bij een volgende versie opnieuw om, lees dan eerst `Task.js` in plaats van opnieuw te schuiven. diff --git a/Docs/Plannen/Actief/003-Testclient/PROGRESS.md b/Docs/Plannen/Actief/003-Testclient/PROGRESS.md index 3348e31..332e8c6 100644 --- a/Docs/Plannen/Actief/003-Testclient/PROGRESS.md +++ b/Docs/Plannen/Actief/003-Testclient/PROGRESS.md @@ -20,3 +20,27 @@ wegschrijven en teruglezen. De bytes komen er ongeschonden uit en het proces slu **Nog niet geprobeerd: praten met de relay.** Daar is het adres van de Umbrel voor nodig en dat komt van de gebruiker; het gaat niet in deze publieke repo. + +## 09-09-2026 - de cliënt staat: opdrachtregel, bedieningsvlak en twee starters + +Fase 2, 3 en 5 in één sessie. Er is een register (`owners.json` met naam, mnemonic en `OwnerId`), een +opdrachtregel met acht opdrachten, en een bedieningsvlak op `http://127.0.0.1:4380` met per eigenaar de +knoppen uit `PLAN.md` §4b. `Start.bat` en `Start.command` erbij op verzoek van de gebruiker: die draaien +`npm install` als het nodig is, waarschuwen als `.env` ontbreekt en openen de browser. + +`OPEN.md` punt 1 is opgelost voordat het een probleem werd. `src/probe.js` doet de WebSocket-upgrade met +`node:http` in plaats van met de WebSocket van Node, want die laatste geeft je bij een weigering een +`error` en geen statuscode. De opdracht `klop` en de knop "Aankloppen" tonen dus 101 of 401. + +Twee echte fouten gevonden en vastgezet in een toets. De eerste: de databasemap werd niet aangemaakt, en +omdat better-sqlite3 dat in een worker meldt was het symptoom een `lees` die nooit antwoordde. De tweede, +en die is er een om te onthouden: **`evolu.insert` geeft meteen een id terug en zet de schrijfactie in de +wachtrij van de worker**, dus wie vlak daarna afsluit is de rij kwijt. Het symptoom was een `lees` die +"nog geen blobs" meldde over een blob die zojuist bevestigd was. `schrijf` wacht nu op een query erachter, +en de toets sluit af en heropent. + +In de browser nagekeken: een blob schrijven en teruglezen werkt vanuit de pagina, de gegevens zijn dezelfde +als die van de opdrachtregel, en licht en donker kloppen allebei. + +**Wat er nu ligt te wachten is één regel in `.env`.** Alles wat de relay raakt is gebouwd en niets ervan is +beproefd, en dat blijft zo tot het adres er is. diff --git a/Docs/Plannen/Actief/003-Testclient/TAKEN.md b/Docs/Plannen/Actief/003-Testclient/TAKEN.md index 6d5d658..0268bc2 100644 --- a/Docs/Plannen/Actief/003-Testclient/TAKEN.md +++ b/Docs/Plannen/Actief/003-Testclient/TAKEN.md @@ -12,9 +12,10 @@ ## Volgende stap -- [ ] **De cliënt met de relay laten praten.** De samenstelling werkt lokaal; wat nog niet geprobeerd is, - is `transports` met `createOwnerWebSocketTransport` erin. **Hiervoor is het adres van de relay - nodig**, en dat komt van de gebruiker en gaat niet in de repo; zie [OPEN.md](OPEN.md) punt 6 +- [ ] **`.env` invullen met het adres van de relay, en dan fase 4.** Alles staat klaar: de cliënt, het + bedieningsvlak en de opdracht `klop` die de HTTP-status van de upgrade laat zien. Wat er nog nooit + gebeurd is, is een verbinding met een echte relay. **Eigenaar: gebruiker**, want het adres gaat niet + in deze publieke repo; kopieer `.env.sample` naar `.env` en zet `RELAY_URL` ## Fase 1 - de samenstelling (af) @@ -27,20 +28,34 @@ `transports: []`, een rij met een `Uint8Array`-kolom wegschrijven en teruglezen. De bytes komen er ongeschonden uit en het proces sluit schoon af, zonder defect en zonder te blijven hangen -## Fase 2 - de opdrachtregel +## Fase 2 - de opdrachtregel (af, 09-09-2026) -- [ ] Een eigenaar aanmaken en bewaren: mnemonic plus database in een eigen map, in een pad dat - gitignored is. Het `OwnerId` wordt afgedrukt, de mnemonic niet -- [ ] Een register van eigenaars: aanmaken, opsommen, teruglezen, weggooien -- [ ] Verbinden met de relay via `createOwnerWebSocketTransport`, met het adres uit een instelling -- [ ] Een blob schrijven met een instelbare omvang, en teruglezen -- [ ] De losse WebSocket-poging die de HTTP-statuscode wél te zien krijgt; zie [OPEN.md](OPEN.md) punt 1 +- [x] **Een eigenaar aanmaken en bewaren.** `src/register.js`: één `owners.json` met naam, mnemonic en + `OwnerId`, geschreven via een tijdelijk bestand en een hernoeming. Het `OwnerId` wordt afgedrukt, de + mnemonic nooit +- [x] **Aanmaken, opsommen, teruglezen, weggooien.** `nieuw`, `lijst`, `vergeet`. Vergeten is vergeten: + de lokale database blijft staan en op de relay verandert er niets, en dat zegt de uitdraai er ook bij +- [x] **Verbinden met de relay via `createOwnerWebSocketTransport`**, met het adres uit `.env` + (`src/config.js`, met `.env.sample` ernaast). **Gebouwd maar niet beproefd**, want er is nog geen + adres; dat is de volgende stap +- [x] **Een blob schrijven met een instelbare omvang, en teruglezen.** `schrijf` en `lees`, met + `--bytes` en `--lokaal` +- [x] **De losse WebSocket-poging die de HTTP-statuscode wél te zien krijgt:** `src/probe.js` en de + opdracht `klop`. Met `node:http` en niet met de WebSocket van Node, want die geeft je bij een + weigering een `error` en geen 401. Daarmee is [OPEN.md](OPEN.md) punt 1 opgelost vóórdat het een + probleem werd -## Fase 3 - het bedieningsvlak +## Fase 3 - het bedieningsvlak (af, 09-09-2026) -- [ ] De HTTP-server met de pagina, in de stijl van de twee statuspagina's -- [ ] De lijst met eigenaars, met per eigenaar de toestand en de knoppen uit [PLAN.md](PLAN.md) §4b -- [ ] Het log, met tijdstip per gebeurtenis +- [x] **De HTTP-server met de pagina**, op localhost. `src/ui.js` en `src/index.html`, geen framework en + geen bouwstap, net als de twee statuspagina's +- [x] **De lijst met eigenaars**, met per eigenaar de toestand, het aantal blobs en de knoppen uit + [PLAN.md](PLAN.md) §4b: aankloppen, verbinden, verbreken, blob schrijven met omvang, lezen, vergeten +- [x] **Het log**, met tijdstip per gebeurtenis +- [x] **`Start.bat` en `Start.command`**, op verzoek van de gebruiker. Ze draaien `npm install` als het + nodig is, waarschuwen als `.env` ontbreekt, openen de browser en houden het venster open bij een fout +- [x] **In de browser nagekeken** (09-09-2026): een blob schrijven en teruglezen werkt vanuit de pagina, + de gegevens zijn dezelfde als die van de opdrachtregel, en licht en donker kloppen allebei ## Fase 4 - de proeven @@ -58,12 +73,20 @@ ze komen na fase 2 en kunnen deels vóór fase 3. plan **Umbrelapp**. Dit is de opbrengst van het hele plan; zonder deze stap blijft het gereedschap zonder resultaat -## Fase 5 - de toetsen +## Fase 5 - de toetsen (af, 09-09-2026) Wat automatisch kan is klein, en [PLAN.md](PLAN.md) §7 zegt waarom. -- [ ] Het register: aanmaken, teruglezen, een naam die al bestaat, een half aangelegde map -- [ ] Een vaste mnemonic met een vast verwacht `OwnerId`. Dit is de toets die omvalt als Evolu onder ons - iets anders gaat doen -- [ ] Het lezen van de opdrachtregel -- [ ] De nieuwe toetsen mutatie-testen, zoals `HomeGit/CLAUDE.md` voorschrijft +- [x] **Het register**, in `tests/test_client_register.mjs`: aanmaken, teruglezen, een naam die al + bestaat, een onleesbaar bestand, een onbekende versie. Deze toets heeft géén pakketten nodig, want + `register.js`, `argumenten.js` en `config.js` raken Evolu niet aan +- [x] **Een vaste mnemonic met een vast verwacht `OwnerId`**, in `tests/test_client_lokaal.mjs`. Dit is de + toets die omvalt als Evolu onder ons iets anders gaat doen met de afleiding +- [x] **Het lezen van de opdrachtregel** en van `.env`, met de omzetting van `http://` naar `ws://` +- [x] **Afsluiten en heropenen**, en dat is er een die een echte fout vasthoudt: `evolu.insert` zet de + schrijfactie in de wachtrij en geeft meteen een id terug, dus wie vlak daarna afsluit is de rij kwijt. + `schrijf` wacht daarom op een query erachter +- [x] **Mutatie-getest**, drie keer: de polyfill uitzetten, de dubbele-naam-controle uitzetten, en de + omzetting van `http://` slopen. Alle drie lieten de júiste toets omvallen. De eerste liet zien dat de + suite bij een defect in een worker blíjft hangen in plaats van rood te worden, en daarom heeft + `test_client_lokaal.mjs` nu een wachthond van dertig seconden diff --git a/tests/test_client_lokaal.mjs b/tests/test_client_lokaal.mjs index a477892..9dcf335 100644 --- a/tests/test_client_lokaal.mjs +++ b/tests/test_client_lokaal.mjs @@ -104,9 +104,11 @@ let store = null; try { const defecten = []; + // Vastgehouden, want verderop wordt met dezelfde eigenaar heropend. + const eigenaar = nieuweEigenaar(); store = await openStore({ directory: map, - owner: nieuweEigenaar(), + owner: eigenaar, onDefect: (defect) => { defecten.push(defect); }, @@ -115,7 +117,7 @@ try { toets('zonder relayUrl is er geen transport', store.transportUrl === null); const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]); - const geschreven = store.schrijf('toets', bytes); + const geschreven = await store.schrijf('toets', bytes); toets('een blob wegschrijven geeft een id terug', typeof geschreven?.id === 'string'); const rijen = await store.lees(); @@ -131,6 +133,21 @@ try { store = null; await sluiten(); + // Opnieuw openen met dezelfde eigenaar en dezelfde map. Dit is de toets die + // een echte fout van 09-09-2026 vasthoudt: `evolu.insert` zet de schrijfactie + // in de wachtrij van de database-worker en geeft meteen een id terug, dus wie + // vlak daarna afsluit is de rij kwijt. Het symptoom was een `lees` die "nog + // geen blobs" meldde over een blob die zojuist bevestigd was, en dát is precies + // het soort fout dat een toets binnen één instantie niet vindt. + store = await openStore({ directory: map, owner: eigenaar }); + const naHeropenen = await store.lees(); + toets('de blob overleeft het afsluiten en heropenen', naHeropenen.length === 1); + toets('met hetzelfde label', naHeropenen[0]?.label === 'toets'); + + const nogmaalsSluiten = store.sluit; + store = null; + await nogmaalsSluiten(); + // Afsluiten hoort stil te zijn. Ging dat mis, dan bleef er een worker draaien // die zijn eigen run al kwijt was, en dat merk je pas als een proces blijft // hangen. Dat was de vierde valstrik uit PLAN.md §4e. diff --git a/tests/test_client_register.mjs b/tests/test_client_register.mjs new file mode 100644 index 0000000..3cdce3b --- /dev/null +++ b/tests/test_client_register.mjs @@ -0,0 +1,243 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// Toetst het register, de opdrachtregel en de instellingen van de testclient. +// +// Draaien: node tests/test_client_register.mjs +// +// Géén afhankelijkheden nodig, anders dan test_client_lokaal.mjs: register.js, +// argumenten.js en config.js raken Evolu niet aan. Dat is met opzet zo gebouwd +// en het is dezelfde scheiding die policy.js aan de relay-kant heeft: alles wat +// te toetsen valt zonder netwerk, staat los van alles wat dat niet is. +// +// Waar dit op let: het register bevat mnemonics. De duurste fout zit hier niet +// in een berekening maar in stil overschrijven of stil vervangen, en dat is dus +// wat hieronder het meeste aandacht krijgt. +// ═══════════════════════════════════════════════════════════════════════════════ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { isBruikbareNaam, normaliseer, openRegister } from '../tools/relay-client/src/register.js'; +import { leesArgumenten, OPDRACHTEN } from '../tools/relay-client/src/argumenten.js'; +import { leesConfig, leesEnvBestand, naarRelayUrl } from '../tools/relay-client/src/config.js'; + +let goed = 0; +let fout = 0; + +const toets = (omschrijving, voorwaarde) => { + if (voorwaarde) { + goed += 1; + return; + } + fout += 1; + console.log(`FOUT: ${omschrijving}`); +}; + +const gooit = (aan) => { + try { + aan(); + return false; + } catch { + return true; + } +}; + +const verseMap = () => mkdtempSync(join(tmpdir(), 'relay-client-register-')); +const mappen = []; +const map = () => { + const nieuw = verseMap(); + mappen.push(nieuw); + return nieuw; +}; + +// ── Namen ───────────────────────────────────────────────────────────────────── + +{ + for (const naam of ['a', 'proef-1', 'trezor_mac', 'A'.repeat(40)]) { + toets(`"${naam}" mag als naam`, isBruikbareNaam(naam)); + } + for (const naam of ['', ' ', 'met spatie', '../ontsnapt', 'pad/naam', 'A'.repeat(41), null, 42]) { + toets(`${JSON.stringify(naam)} mag niet als naam`, !isBruikbareNaam(naam)); + } +} + +// ── Aanmaken, teruglezen, weggooien ─────────────────────────────────────────── + +{ + const waar = map(); + const register = openRegister(waar); + + toets('een vers register is leeg', register.lijst().length === 0); + toets('en een onbekende naam geeft null', register.zoek('piet') === null); + + const toegevoegd = register.voegToe({ naam: 'piet', mnemonic: 'woord ' .repeat(11) + 'woord', ownerId: 'AAA' }); + toets('toevoegen geeft de regel terug', toegevoegd.naam === 'piet' && toegevoegd.ownerId === 'AAA'); + toets('met een tijdstip erbij', typeof toegevoegd.aangemaakt === 'string'); + toets('en hij staat in de lijst', register.lijst().length === 1); + toets('en is op naam te vinden', register.zoek('piet')?.ownerId === 'AAA'); + + // Het niet-gelukkige pad, en het belangrijkste van dit bestand: overschrijven + // zou de mnemonic van de vorige eigenaar weggooien. + toets( + 'dezelfde naam nog eens toevoegen wordt geweigerd', + gooit(() => register.voegToe({ naam: 'piet', mnemonic: 'iets anders', ownerId: 'BBB' })), + ); + toets('en de eerste staat er nog', register.zoek('piet')?.ownerId === 'AAA'); + + toets( + 'een onbruikbare naam wordt geweigerd', + gooit(() => register.voegToe({ naam: 'met spatie', mnemonic: 'x', ownerId: 'CCC' })), + ); + + toets('vergeten van een onbekende naam wordt geweigerd', gooit(() => register.vergeet('jan'))); + + const weg = register.vergeet('piet'); + toets('vergeten geeft de regel terug', weg.ownerId === 'AAA'); + toets('en daarna is het register leeg', register.lijst().length === 0); +} + +// ── Wat er op schijf komt, komt er weer af ──────────────────────────────────── + +{ + const waar = map(); + const eerste = openRegister(waar); + eerste.voegToe({ naam: 'een', mnemonic: 'mnemonic een', ownerId: 'ID1' }); + eerste.voegToe({ naam: 'twee', mnemonic: 'mnemonic twee', ownerId: 'ID2' }); + + const tweede = openRegister(waar); + toets('een tweede register leest hetzelfde bestand', tweede.lijst().length === 2); + toets('in dezelfde volgorde', tweede.lijst()[0].naam === 'een' && tweede.lijst()[1].naam === 'twee'); + toets('met de mnemonic erbij', tweede.zoek('twee')?.mnemonic === 'mnemonic twee'); + + // Een register dat je aanpast mag geen lijst teruggeven die meeverandert: + // dan zou een aanroeper per ongeluk in de staat kunnen schrijven. + const kopie = tweede.lijst(); + kopie[0].ownerId = 'GEHACKT'; + toets('de lijst is een kopie', tweede.zoek('een')?.ownerId === 'ID1'); +} + +// ── Een onleesbaar bestand wordt niet stil vervangen ────────────────────────── + +{ + const waar = map(); + const register = openRegister(waar); + register.voegToe({ naam: 'een', mnemonic: 'geheim', ownerId: 'ID1' }); + + const pad = join(waar, 'owners.json'); + const origineel = readFileSync(pad, 'utf8'); + + writeFileSync(pad, '{ dit is geen json', 'utf8'); + toets('onleesbare JSON wordt gemeld', gooit(() => openRegister(waar))); + toets('en het bestand blijft staan', readFileSync(pad, 'utf8') === '{ dit is geen json'); + + writeFileSync(pad, JSON.stringify({ version: 2, owners: [] }), 'utf8'); + toets('een onbekende versie wordt gemeld', gooit(() => openRegister(waar))); + + writeFileSync(pad, JSON.stringify({ version: 1, owners: [{ naam: 'een' }] }), 'utf8'); + toets('een halve regel wordt gemeld', gooit(() => openRegister(waar))); + + writeFileSync(pad, origineel, 'utf8'); + toets('en het originele bestand leest weer goed', openRegister(waar).zoek('een')?.ownerId === 'ID1'); +} + +// ── normaliseer, los ────────────────────────────────────────────────────────── + +{ + toets('null is geen staat', normaliseer(null) === null); + toets('een array is geen staat', normaliseer([]) === null); + toets('zonder owners is het geen staat', normaliseer({ version: 1 }) === null); + toets( + 'twee keer dezelfde naam is geen staat', + normaliseer({ + version: 1, + owners: [ + { naam: 'a', mnemonic: 'x', ownerId: '1' }, + { naam: 'a', mnemonic: 'y', ownerId: '2' }, + ], + }) === null, + ); + toets( + 'een lege mnemonic is geen staat', + normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: '', ownerId: '1' }] }) === null, + ); + toets( + 'een geldige staat komt er heel uit', + normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: 'x', ownerId: '1' }] })?.owners[0] + ?.naam === 'a', + ); +} + +// ── De opdrachtregel ────────────────────────────────────────────────────────── + +{ + toets('niets geeft geen opdracht en geen fout', (() => { + const uit = leesArgumenten([]); + return uit.fout === null && uit.opdracht === null; + })()); + + toets('een onbekende opdracht is een fout', leesArgumenten(['bestaatniet']).fout !== null); + + const nieuw = leesArgumenten(['nieuw', 'piet']); + toets('nieuw piet leest goed', nieuw.fout === null && nieuw.waarden.naam === 'piet'); + + toets('nieuw zonder naam is een fout', leesArgumenten(['nieuw']).fout !== null); + // Streng, en dat is de bedoeling: `schrijf piet mijn label` zou anders stil + // "mijn" als label nemen en je naar een blob laten kijken die anders heet. + toets('een argument te veel is een fout', leesArgumenten(['nieuw', 'piet', 'jan']).fout !== null); + + const schrijf = leesArgumenten(['schrijf', 'piet', 'mijn label', '--bytes', '512']); + toets('schrijf leest beide argumenten', schrijf.waarden.naam === 'piet' && schrijf.waarden.label === 'mijn label'); + toets('en de vlag ernaast', schrijf.vlaggen.bytes === '512'); + + toets('--bytes=512 leest ook', leesArgumenten(['lijst', '--bytes=512']).vlaggen.bytes === '512'); + toets('een losse vlag is true', leesArgumenten(['lijst', '--lokaal']).vlaggen.lokaal === true); + toets( + 'een losse vlag gevolgd door een vlag blijft true', + leesArgumenten(['lijst', '--lokaal', '--bytes=8']).vlaggen.lokaal === true, + ); + + toets('elke opdracht heeft uitleg', Object.values(OPDRACHTEN).every((v) => typeof v.uitleg === 'string' && v.uitleg.length > 0)); +} + +// ── De instellingen ─────────────────────────────────────────────────────────── + +{ + const gelezen = leesEnvBestand( + ['# commentaar', '', 'RELAY_URL=ws://voorbeeld:3852', 'MET_QUOTES="waarde"', 'ROMMEL', 'LEEG='].join('\n'), + ); + toets('een gewone regel leest', gelezen.RELAY_URL === 'ws://voorbeeld:3852'); + toets('aanhalingstekens gaan eraf', gelezen.MET_QUOTES === 'waarde'); + toets('een regel zonder = wordt overgeslagen', gelezen.ROMMEL === undefined); + toets('een lege waarde is een lege string', gelezen.LEEG === ''); + toets('commentaar telt niet mee', Object.keys(gelezen).length === 3); + + toets('ws:// blijft', naarRelayUrl('ws://umbrel.local:3852') === 'ws://umbrel.local:3852'); + toets('wss:// blijft', naarRelayUrl('wss://voorbeeld.nl') === 'wss://voorbeeld.nl'); + toets('http:// wordt ws://', naarRelayUrl('http://umbrel.local:3852') === 'ws://umbrel.local:3852'); + toets('https:// wordt wss://', naarRelayUrl('https://voorbeeld.nl') === 'wss://voorbeeld.nl'); + toets('zonder schema wordt ws://', naarRelayUrl('umbrel.local:3852') === 'ws://umbrel.local:3852'); + toets('spaties eromheen doen niets', naarRelayUrl(' umbrel.local:3852 ') === 'ws://umbrel.local:3852'); + toets('een leeg adres wordt geweigerd', gooit(() => naarRelayUrl(''))); + toets('een onbekend schema wordt geweigerd', gooit(() => naarRelayUrl('ftp://umbrel.local'))); + + // Een map zonder .env: dan is er geen relay-adres, en dat is geen fout maar een + // toestand die de UI moet kunnen tonen. + const zonder = leesConfig({ env: {}, wortel: map() }); + toets('zonder .env is er geen relay-adres', zonder.relayUrl === null); + toets('en staat de UI-poort op de standaard', zonder.uiPoort === 4380); + + const wortel = map(); + writeFileSync(join(wortel, '.env'), 'RELAY_URL=umbrel.local:3852\nUI_PORT=5000\n', 'utf8'); + const met = leesConfig({ env: {}, wortel }); + toets('een .env wordt gelezen', met.relayUrl === 'ws://umbrel.local:3852'); + toets('en de poort ook', met.uiPoort === 5000); + + const overstemd = leesConfig({ env: { RELAY_URL: 'ws://anders:1234' }, wortel }); + toets('de omgeving wint van het bestand', overstemd.relayUrl === 'ws://anders:1234'); +} + +for (const weg of mappen) rmSync(weg, { recursive: true, force: true }); + +console.log(''); +console.log(`${goed} goed, ${fout} fout`); +process.exit(fout === 0 ? 0 : 1); diff --git a/tools/relay-client/.env.sample b/tools/relay-client/.env.sample new file mode 100644 index 0000000..559bed6 --- /dev/null +++ b/tools/relay-client/.env.sample @@ -0,0 +1,21 @@ +# Instellingen van de testclient. Kopieer dit bestand naar .env en vul het in; +# .env staat in .gitignore en komt dus niet in de repo. +# +# Het adres van je Umbrel hoort hier en nergens anders: dit is een publieke app +# store, en een adres in de historie krijg je er niet meer uit. + +# Waar de relay luistert. Poort 3852 is wat de app publiceert. +# Voorbeelden: +# RELAY_URL=ws://umbrel.local:3852 +# RELAY_URL=ws://192.168.1.20:3852 +# RELAY_URL=wss://relay.voorbeeld.nl (achter een reverse proxy met TLS) +# +# ws:// of wss:// mag je weglaten; zonder schema wordt het ws://. +RELAY_URL=ws://umbrel.local:3852 + +# Waar het register en de databases komen te staan. Relatief aan +# tools/relay-client/. Hier staan mnemonics, dus deze map is gitignored. +#DATA_DIR=data + +# De poort van het bedieningsvlak in de browser. +#UI_PORT=4380 diff --git a/tools/relay-client/Start.bat b/tools/relay-client/Start.bat new file mode 100644 index 0000000..7a87607 --- /dev/null +++ b/tools/relay-client/Start.bat @@ -0,0 +1,55 @@ +@echo off +rem ============================================================================ +rem Start het bedieningsvlak van de testclient en opent de browser. +rem +rem Dubbelklikken werkt: het venster blijft open zolang de server draait, en +rem blijft ook open als er iets misgaat, want anders lees je de fout nooit. +rem +rem De macOS-tegenhanger is Start.command ernaast. +rem ============================================================================ + +setlocal +cd /d "%~dp0" + +where node >nul 2>nul +if errorlevel 1 ( + echo Node is niet gevonden. Deze client heeft Node 24 of hoger nodig. + echo Zie https://nodejs.org + echo. + pause + exit /b 1 +) + +if not exist "node_modules" ( + echo Pakketten ontbreken, npm install wordt gedraaid... + call npm install + if errorlevel 1 ( + echo. + echo npm install is mislukt. + pause + exit /b 1 + ) + echo. +) + +if not exist ".env" ( + echo Let op: er is geen .env, dus er is geen relay-adres. + echo Kopieer .env.sample naar .env en vul RELAY_URL in. + echo Zonder dat werkt alles behalve verbinden met de relay. + echo. +) + +rem De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem +rem dan in .env als UI_PORT en niet hier: dan weet de client het ook. +set UI_ADRES=http://127.0.0.1:4380 + +rem De browser gaat nu open, terwijl de server nog moet starten. Dat is goed: +rem het tabblad ververst zichzelf elke drie seconden, dus een eerste poging die +rem te vroeg komt herstelt vanzelf. +start "" "%UI_ADRES%" + +node src\cli.js ui + +echo. +echo De client is gestopt. +pause diff --git a/tools/relay-client/Start.command b/tools/relay-client/Start.command new file mode 100755 index 0000000..d96f426 --- /dev/null +++ b/tools/relay-client/Start.command @@ -0,0 +1,54 @@ +#!/bin/bash +# ============================================================================= +# Start het bedieningsvlak van de testclient en opent de browser. Voor macOS. +# +# Dubbelklikken in Finder werkt, maar alleen als het bestand uitvoerbaar is. +# Git bewaart dat bit, dus na een verse kloon hoort het te kloppen; is dat niet +# zo, dan eenmalig: chmod +x Start.command +# +# De Windows-tegenhanger is Start.bat ernaast. +# ============================================================================= + +set -u +cd "$(dirname "$0")" + +if ! command -v node >/dev/null 2>&1; then + echo "Node is niet gevonden. Deze client heeft Node 24 of hoger nodig." + echo "Zie https://nodejs.org" + echo + read -r -p "Enter om te sluiten." + exit 1 +fi + +if [ ! -d node_modules ]; then + echo "Pakketten ontbreken, npm install wordt gedraaid..." + if ! npm install; then + echo + echo "npm install is mislukt." + read -r -p "Enter om te sluiten." + exit 1 + fi + echo +fi + +if [ ! -f .env ]; then + echo "Let op: er is geen .env, dus er is geen relay-adres." + echo "Kopieer .env.sample naar .env en vul RELAY_URL in." + echo "Zonder dat werkt alles behalve verbinden met de relay." + echo +fi + +# De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem dan +# in .env als UI_PORT en niet hier: dan weet de client het ook. +UI_ADRES="http://127.0.0.1:4380" + +# De browser gaat nu open, terwijl de server nog moet starten. Dat is goed: het +# tabblad ververst zichzelf elke drie seconden, dus een eerste poging die te +# vroeg komt herstelt vanzelf. +open "$UI_ADRES" >/dev/null 2>&1 || true + +node src/cli.js ui + +echo +echo "De client is gestopt." +read -r -p "Enter om te sluiten." diff --git a/tools/relay-client/src/argumenten.js b/tools/relay-client/src/argumenten.js new file mode 100644 index 0000000..90a0312 --- /dev/null +++ b/tools/relay-client/src/argumenten.js @@ -0,0 +1,99 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// De opdrachtregel lezen. Meer niet. +// +// Apart van cli.js omdat dit het enige deel van de opdrachtregel is dat te +// toetsen valt zonder iets te starten. Dezelfde scheiding als policy.js aan de +// relay-kant: pure functies aan de ene kant, alles wat schijf of netwerk raakt +// aan de andere. +// ═══════════════════════════════════════════════════════════════════════════════ + +export const OPDRACHTEN = { + nieuw: { argumenten: ['naam'], uitleg: 'Maakt een nieuwe eigenaar met verse sleutels' }, + lijst: { argumenten: [], uitleg: 'Toont de eigenaars in het register' }, + schrijf: { argumenten: ['naam', 'label'], uitleg: 'Schrijft een blob weg voor deze eigenaar' }, + lees: { argumenten: ['naam'], uitleg: 'Toont de blobs van deze eigenaar' }, + klop: { argumenten: ['naam'], uitleg: 'Klopt aan bij de relay en toont de HTTP-status' }, + sync: { argumenten: ['naam'], uitleg: 'Verbindt en blijft draaien tot Ctrl-C' }, + vergeet: { argumenten: ['naam'], uitleg: 'Haalt een eigenaar uit het register' }, + ui: { argumenten: [], uitleg: 'Start het bedieningsvlak in de browser' }, +}; + +/** + * Leest `process.argv.slice(2)`. + * + * Geeft `{fout}` in plaats van te gooien: de aanroeper drukt dat af met de + * gebruiksaanwijzing eronder, en dat leest beter dan een stack trace. + * + * Vlaggen zijn `--naam waarde` of `--naam=waarde`. Een losse `--naam` is `true`. + */ +export const leesArgumenten = (argv) => { + const losse = []; + const vlaggen = {}; + + for (let i = 0; i < argv.length; i += 1) { + const stuk = argv[i]; + if (!stuk.startsWith('--')) { + losse.push(stuk); + continue; + } + const kaal = stuk.slice(2); + const isGelijk = kaal.indexOf('='); + if (isGelijk > 0) { + vlaggen[kaal.slice(0, isGelijk)] = kaal.slice(isGelijk + 1); + continue; + } + const volgende = argv[i + 1]; + if (volgende !== undefined && !volgende.startsWith('--')) { + vlaggen[kaal] = volgende; + i += 1; + } else { + vlaggen[kaal] = true; + } + } + + const [opdracht, ...rest] = losse; + + if (opdracht === undefined) return { fout: null, opdracht: null, waarden: {}, vlaggen }; + + const vorm = OPDRACHTEN[opdracht]; + if (!vorm) return { fout: `Onbekende opdracht "${opdracht}".`, opdracht: null, waarden: {}, vlaggen }; + + if (rest.length < vorm.argumenten.length) { + const ontbreekt = vorm.argumenten.slice(rest.length).join(', '); + return { fout: `"${opdracht}" mist: ${ontbreekt}.`, opdracht, waarden: {}, vlaggen }; + } + if (rest.length > vorm.argumenten.length) { + // Streng, en met opzet. `schrijf piet mijn label` ziet er goed uit en zou + // anders stil "mijn" als label nemen; dan sta je te kijken naar een blob die + // anders heet dan je typte. + return { + fout: `"${opdracht}" verwacht ${vorm.argumenten.length} argument(en), niet ${rest.length}. Gebruik aanhalingstekens om een label met spaties.`, + opdracht, + waarden: {}, + vlaggen, + }; + } + + const waarden = {}; + vorm.argumenten.forEach((naam, plek) => { + waarden[naam] = rest[plek]; + }); + + return { fout: null, opdracht, waarden, vlaggen }; +}; + +export const gebruiksaanwijzing = () => { + const regels = ['Gebruik: node src/cli.js [argumenten]', '']; + for (const [naam, vorm] of Object.entries(OPDRACHTEN)) { + const argumenten = vorm.argumenten.map((a) => `<${a}>`).join(' '); + regels.push(` ${`${naam} ${argumenten}`.trim().padEnd(24)}${vorm.uitleg}`); + } + regels.push(''); + regels.push('Vlaggen:'); + regels.push(' --bytes Omvang van de blob bij `schrijf` (standaard 64)'); + regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`'); + regels.push(' --poort Poort van het bedieningsvlak bij `ui`'); + regels.push(''); + regels.push('Het adres van de relay komt uit .env; zie .env.sample.'); + return regels.join('\n'); +}; diff --git a/tools/relay-client/src/cli.js b/tools/relay-client/src/cli.js new file mode 100644 index 0000000..843e693 --- /dev/null +++ b/tools/relay-client/src/cli.js @@ -0,0 +1,211 @@ +#!/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 { 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); + }, + }); + +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(); + } + }, + + klop: async ({ naam }) => { + eisRelay(); + const { regel } = haalEigenaarOp(naam); + console.log(`Aankloppen 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); + }, + + 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); +} diff --git a/tools/relay-client/src/client.js b/tools/relay-client/src/client.js index 103ada6..c57a703 100644 --- a/tools/relay-client/src/client.js +++ b/tools/relay-client/src/client.js @@ -119,8 +119,20 @@ export const openStore = async ({ directory, owner, relayUrl, onDefect, consoleL /** De URL waarmee verbonden wordt, of null bij een instantie zonder sync. */ transportUrl: transports[0]?.url ?? null, - /** Schrijft een blob weg. Geeft het id van de nieuwe rij terug. */ - schrijf: (label, body) => evolu.insert('blob', { label, body }), + /** + * Schrijft een blob weg. Geeft het id van de nieuwe rij terug. + * + * Asynchroon, en dat is geen stijlkeuze. `evolu.insert` geeft meteen een id + * terug en zet de schrijfactie in de wachtrij van de database-worker; sluit + * je vlak daarna af, dan is de rij nooit op schijf beland. Dat kostte op + * 09-09-2026 een `lees` die "nog geen blobs" meldde over een blob die + * zojuist bevestigd was. De query erachter dwingt de heenreis af. + */ + schrijf: async (label, body) => { + const geschreven = evolu.insert('blob', { label, body }); + await evolu.loadQuery(allesQuery); + return geschreven; + }, /** Alle blobs van deze eigenaar, oudste eerst. */ lees: () => evolu.loadQuery(allesQuery), diff --git a/tools/relay-client/src/config.js b/tools/relay-client/src/config.js new file mode 100644 index 0000000..a5132c6 --- /dev/null +++ b/tools/relay-client/src/config.js @@ -0,0 +1,99 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// Waar staat de relay, en waar mogen de gegevens staan. +// +// Het adres van de Umbrel staat NIET in deze repo en hoort daar ook niet in: +// dit is een publieke app store, en de .gitignore van de repo-root heeft er een +// eigen kopje over. Het komt dus uit de omgeving, met `.env` als de gewone weg +// en `.env.sample` als het voorbeeld dat wél in de repo staat. +// +// Geen dotenv-pakket: dat is een afhankelijkheid voor twintig regels, en die +// twintig regels zijn hier beter te lezen dan de instellingen van een pakket. +// ═══════════════════════════════════════════════════════════════════════════════ + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** De wortel van dit gereedschap, ongeacht vanwaar het gestart wordt. */ +export const WORTEL = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * Leest een `.env`-bestand. + * + * Regels van de vorm `NAAM=waarde`. Lege regels en regels die met `#` beginnen + * worden overgeslagen, en aanhalingstekens om de waarde gaan eraf. Meer niet: wie + * hier een regel met een dubbele bodem in zet, krijgt hem letterlijk terug. + */ +export const leesEnvBestand = (inhoud) => { + const uit = {}; + for (const regel of inhoud.split(/\r?\n/)) { + const kaal = regel.trim(); + if (kaal === '' || kaal.startsWith('#')) continue; + const isGelijk = kaal.indexOf('='); + if (isGelijk <= 0) continue; + const naam = kaal.slice(0, isGelijk).trim(); + let waarde = kaal.slice(isGelijk + 1).trim(); + if ( + (waarde.startsWith('"') && waarde.endsWith('"') && waarde.length >= 2) + || (waarde.startsWith("'") && waarde.endsWith("'") && waarde.length >= 2) + ) { + waarde = waarde.slice(1, -1); + } + uit[naam] = waarde; + } + return uit; +}; + +/** + * Maakt van een adres een WebSocket-URL, of gooit met uitleg. + * + * De relay is een WebSocket-server, dus `ws://` of `wss://`. Een `http://` dat + * iemand uit de adresbalk plakt wordt omgezet in plaats van geweigerd: dat is + * de meest voorkomende vergissing en er is niets dubbelzinnigs aan. + * + * Wat er wél uitkomt is een adres zonder schema (`umbrel.local:3852`), en dat + * krijgt `ws://`. Zonder poort blijft het zoals het is: achter een reverse proxy + * met TLS is `wss://host` zonder poort juist de goede vorm. + */ +export const naarRelayUrl = (adres) => { + if (typeof adres !== 'string' || adres.trim() === '') { + throw new Error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.'); + } + const kaal = adres.trim(); + + if (kaal.startsWith('ws://') || kaal.startsWith('wss://')) return kaal; + if (kaal.startsWith('http://')) return `ws://${kaal.slice('http://'.length)}`; + if (kaal.startsWith('https://')) return `wss://${kaal.slice('https://'.length)}`; + if (kaal.includes('://')) { + throw new Error(`"${kaal}" heeft een schema dat hier niets betekent. Gebruik ws:// of wss://.`); + } + return `ws://${kaal}`; +}; + +/** + * De instellingen: eerst de omgeving, dan `.env`, dan de standaardwaarden. + * + * De omgeving wint van het bestand, zodat je één keer iets anders kunt proberen + * zonder het bestand aan te raken. + */ +export const leesConfig = ({ env = process.env, wortel = WORTEL } = {}) => { + let uitBestand = {}; + try { + uitBestand = leesEnvBestand(readFileSync(join(wortel, '.env'), 'utf8')); + } catch (oorzaak) { + if (oorzaak.code !== 'ENOENT') throw oorzaak; + } + + const haal = (naam) => env[naam] ?? uitBestand[naam]; + + const adres = haal('RELAY_URL'); + + return { + /** Het adres van de relay, of `null` als het er niet is. */ + relayUrl: adres ? naarRelayUrl(adres) : null, + /** Waar het register en de databases staan. */ + dataDir: resolve(wortel, haal('DATA_DIR') ?? 'data'), + /** De poort van het bedieningsvlak. */ + uiPoort: Number.parseInt(haal('UI_PORT') ?? '4380', 10), + }; +}; diff --git a/tools/relay-client/src/evolu-node.js b/tools/relay-client/src/evolu-node.js index 5515902..a801769 100644 --- a/tools/relay-client/src/evolu-node.js +++ b/tools/relay-client/src/evolu-node.js @@ -26,6 +26,7 @@ // "Web and Node.js 24+" bij dit onderdeel met zoveel woorden. // ═══════════════════════════════════════════════════════════════════════════════ +import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -63,6 +64,12 @@ const createSqliteDriverIn = (directory) => (name, options) => * @param {{directory: string, consoleLevel?: string}} config */ export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect }) => { + // De map moet er zijn vóórdat better-sqlite3 hem opent; die maakt hem niet aan + // en gooit "Cannot open database because the directory does not exist". Dat + // gebeurt binnen een worker, dus zonder deze regel is het symptoom een + // `loadQuery` die nooit antwoordt en niet een fout die je kunt lezen. + mkdirSync(directory, { recursive: true }); + const console = createConsole({ level: consoleLevel }); const consoleStoreOutput = createConsoleStoreOutput(); diff --git a/tools/relay-client/src/index.html b/tools/relay-client/src/index.html new file mode 100644 index 0000000..1ff8de7 --- /dev/null +++ b/tools/relay-client/src/index.html @@ -0,0 +1,305 @@ + + + + + + + Relay-testclient + + + +
+

Relay-testclient

+
+ Relay: + +
+ +

Nieuwe eigenaar

+
+
+ + + +
+
+ +

Eigenaars

+
+ +

Log

+
+
+ + + + diff --git a/tools/relay-client/src/probe.js b/tools/relay-client/src/probe.js new file mode 100644 index 0000000..708216d --- /dev/null +++ b/tools/relay-client/src/probe.js @@ -0,0 +1,96 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// Aankloppen bij de relay en de HTTP-statuscode teruggeven. +// +// Dit bestaat omdat Evolu een weigering niet van een relay die plat ligt +// onderscheidt: de cliënt is local-first en probeert het gewoon opnieuw. Voor +// een gereedschap dat juist de allowlist moet beproeven is dat het verschil +// tussen een proef en een gok; zie het plan Testclient, OPEN.md punt 1. +// +// Het kan zo goedkoop omdat de relay zijn beslissing bij de WebSocket-upgrade +// neemt en niet bij een bericht: het `OwnerId` staat in de URL, en een weigering +// is een HTTP 401 op de upgrade. Zie §10 van +// Docs/Referenties/Upstream-evolu-relay.md. +// +// Met node:http en niet met de WebSocket van Node, en dat is de hele reden dat +// dit bestand er is: de WebSocket-API geeft je bij een weigering een `error` en +// geen statuscode, en 401 tegenover 404 tegenover "niets luistert" is precies +// wat je wilt weten. +// ═══════════════════════════════════════════════════════════════════════════════ + +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { randomBytes } from 'node:crypto'; + +/** + * De URL waarmee deze eigenaar zou verbinden. + * + * Dezelfde vorm die `createOwnerWebSocketTransport` maakt. Hij staat hier apart + * zodat er getoetst kan worden dat de twee niet uit elkaar lopen; loopt dat wel + * uit elkaar, dan klopt de proef niet en zie je dat nergens aan. + */ +export const upgradeUrl = (relayUrl, ownerId) => `${relayUrl}?ownerId=${ownerId}`; + +/** + * Klopt aan en geeft terug wat er gebeurde. + * + * @returns {Promise<{status: number|null, geaccepteerd: boolean, uitleg: string}>} + */ +export const klopAan = (relayUrl, ownerId, { timeoutMs = 5000 } = {}) => + new Promise((klaar) => { + let url; + try { + url = new URL(upgradeUrl(relayUrl, ownerId)); + } catch { + klaar({ status: null, geaccepteerd: false, uitleg: `"${relayUrl}" is geen bruikbaar adres` }); + return; + } + + const beveiligd = url.protocol === 'wss:'; + const verzoek = (beveiligd ? httpsRequest : httpRequest)({ + hostname: url.hostname, + port: url.port || (beveiligd ? 443 : 80), + path: `${url.pathname}${url.search}`, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': randomBytes(16).toString('base64'), + }, + }); + + let afgerond = false; + const antwoord = (uitkomst) => { + if (afgerond) return; + afgerond = true; + verzoek.destroy(); + klaar(uitkomst); + }; + + // Een geslaagde upgrade komt níet als 'response' binnen maar als 'upgrade'. + // Dat is meteen het antwoord: de relay heeft deze eigenaar toegelaten. + verzoek.on('upgrade', () => { + antwoord({ status: 101, geaccepteerd: true, uitleg: 'toegelaten' }); + }); + + verzoek.on('response', (res) => { + const status = res.statusCode ?? null; + antwoord({ + status, + geaccepteerd: false, + uitleg: + status === 401 + ? 'geweigerd: deze eigenaar mag er niet in' + : `geweigerd met status ${status}`, + }); + }); + + verzoek.on('error', (oorzaak) => { + antwoord({ status: null, geaccepteerd: false, uitleg: `geen verbinding: ${oorzaak.message}` }); + }); + + verzoek.setTimeout(timeoutMs, () => { + antwoord({ status: null, geaccepteerd: false, uitleg: `geen antwoord binnen ${timeoutMs} ms` }); + }); + + verzoek.end(); + }); diff --git a/tools/relay-client/src/register.js b/tools/relay-client/src/register.js new file mode 100644 index 0000000..78cd475 --- /dev/null +++ b/tools/relay-client/src/register.js @@ -0,0 +1,157 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// Het register: welke eigenaars bestaan er, en hoe kom je erbij terug. +// +// Eén bestand, `owners.json`, met per eigenaar een naam, zijn mnemonic en zijn +// `OwnerId`. De mnemonic is het enige dat werkelijk bewaard hoeft te worden; het +// `OwnerId` staat erbij omdat je het nodig hebt om een eigenaar op de +// statuspagina van de app terug te vinden, en omdat je het dan kunt opzoeken +// zonder Evolu te starten. +// +// **Dit is sleutelmateriaal.** Het heeft buiten deze proefopstelling geen +// waarde, maar het bestand staat in .gitignore en het hoort daar te blijven; zie +// het plan Testclient, PLAN.md §4a. +// +// Alles hier is een gewone functie op een pad. Geen Evolu, geen netwerk: dat is +// wat dit het enige deel van de cliënt maakt dat zonder relay en zonder +// fixtures te toetsen is, en het is dezelfde scheiding die policy.js aan de +// relay-kant heeft. +// ═══════════════════════════════════════════════════════════════════════════════ + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Een naam is een herkenpunt voor jezelf en gaat nooit de deur uit. + * + * Toch begrensd, want hij belandt in een JSON-bestand en straks op een pagina. + * Letters, cijfers, streepje en liggend streepje; dat is genoeg om "trezor-mac" + * of "proef_2" te kunnen schrijven en het sluit een naam met een schuine streep + * erin uit, die er als pad uit zou zien. + */ +const NAAM_PATROON = /^[A-Za-z0-9_-]{1,40}$/; + +export const isBruikbareNaam = (naam) => typeof naam === 'string' && NAAM_PATROON.test(naam); + +const LEEG = { version: 1, owners: [] }; + +/** + * Leest het bestand. Geeft `null` bij iets wat niet klopt. + * + * Bewust streng, en om dezelfde reden als in policy.js: een half begrepen + * bestand is gevaarlijker dan geen bestand. Hier zou dat betekenen dat een + * eigenaar stil verdwijnt terwijl zijn mnemonic wél op schijf staat, en dan is + * de proef die eroverheen liep niet meer te herhalen. + */ +export const normaliseer = (ruw) => { + if (ruw === null || typeof ruw !== 'object' || Array.isArray(ruw)) return null; + if (ruw.version !== 1) return null; + if (!Array.isArray(ruw.owners)) return null; + + const owners = []; + for (const eigenaar of ruw.owners) { + if (eigenaar === null || typeof eigenaar !== 'object') return null; + if (!isBruikbareNaam(eigenaar.naam)) return null; + if (typeof eigenaar.mnemonic !== 'string' || eigenaar.mnemonic.length === 0) return null; + if (typeof eigenaar.ownerId !== 'string' || eigenaar.ownerId.length === 0) return null; + if (owners.some((eerder) => eerder.naam === eigenaar.naam)) return null; + owners.push({ + naam: eigenaar.naam, + mnemonic: eigenaar.mnemonic, + ownerId: eigenaar.ownerId, + aangemaakt: typeof eigenaar.aangemaakt === 'string' ? eigenaar.aangemaakt : null, + }); + } + + return { version: 1, owners }; +}; + +/** + * Opent het register in `directory`, en maakt de map aan als hij er niet is. + * + * Een ontbrekend bestand is de begintoestand en geen fout. Een onleesbaar bestand + * wél: dat wordt gemeld en niet stil vervangen, want vervangen is hier hetzelfde + * als weggooien. + */ +export const openRegister = (directory) => { + mkdirSync(directory, { recursive: true }); + const pad = join(directory, 'owners.json'); + + let staat; + try { + staat = normaliseer(JSON.parse(readFileSync(pad, 'utf8'))); + } catch (oorzaak) { + if (oorzaak.code === 'ENOENT') { + staat = { ...LEEG, owners: [] }; + } else { + throw new Error(`${pad} is niet te lezen: ${oorzaak.message}`); + } + } + + if (staat === null) { + throw new Error( + `${pad} klopt niet. Er is niets vervangen; kijk er zelf naar, want hier staan mnemonics in.`, + ); + } + + // Schrijven gaat via een tijdelijk bestand en een hernoeming. Een half + // geschreven register kost mnemonics, en een hernoeming binnen dezelfde map is + // op elk besturingssysteem dat wij raken één handeling. + const bewaar = () => { + const tijdelijk = `${pad}.nieuw`; + writeFileSync(tijdelijk, `${JSON.stringify(staat, null, 2)}\n`, 'utf8'); + renameSync(tijdelijk, pad); + }; + + return { + pad, + + /** Alle eigenaars, in de volgorde waarin ze zijn aangemaakt. */ + lijst: () => staat.owners.map((eigenaar) => ({ ...eigenaar })), + + /** Eén eigenaar, of `null`. */ + zoek: (naam) => { + const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam); + return gevonden ? { ...gevonden } : null; + }, + + /** + * Zet een eigenaar in het register. + * + * Gooit bij een naam die niet kan of al bestaat. Overschrijven zou de + * mnemonic van de vorige eigenaar weggooien, en dat is precies het soort + * stille schade waar het commentaar bovenaan dit bestand over gaat. + */ + voegToe: ({ naam, mnemonic, ownerId, aangemaakt }) => { + if (!isBruikbareNaam(naam)) { + throw new Error(`"${naam}" kan niet als naam. Letters, cijfers, - en _, hoogstens 40 tekens.`); + } + if (staat.owners.some((eigenaar) => eigenaar.naam === naam)) { + throw new Error(`Er is al een eigenaar "${naam}".`); + } + const nieuw = { + naam, + mnemonic, + ownerId, + aangemaakt: aangemaakt ?? new Date().toISOString(), + }; + staat = { ...staat, owners: [...staat.owners, nieuw] }; + bewaar(); + return { ...nieuw }; + }, + + /** + * Haalt een eigenaar uit het register. Gooit als hij er niet in staat. + * + * Let op wat dit níet doet: de database van die eigenaar blijft staan, en op + * de relay verandert er helemaal niets. Vergeten is hier vergeten, geen + * wissen; zie OPEN.md punt 3 en 4 van het plan. + */ + vergeet: (naam) => { + const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam); + if (!gevonden) throw new Error(`Geen eigenaar "${naam}".`); + staat = { ...staat, owners: staat.owners.filter((eigenaar) => eigenaar.naam !== naam) }; + bewaar(); + return { ...gevonden }; + }, + }; +}; diff --git a/tools/relay-client/src/ui.js b/tools/relay-client/src/ui.js new file mode 100644 index 0000000..24ca055 --- /dev/null +++ b/tools/relay-client/src/ui.js @@ -0,0 +1,250 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// 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 = []; + + 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); + }, + }); + + 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); + 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; + } + } + 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, + }); + } + 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.'); + } + const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl))); + 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, + })), + }; + }, + + klop: 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); + meld(`${naam}: aankloppen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`); + return uitkomst; + }, + + vergeet: async ({ naam }) => { + await verbreek(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(() => {}); +};