UmbrelApps: community app store whatsnext
Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
85d85a1295
63 files changed
+13987
No files matched your search
@@ -0,0 +1,101 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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' },
|
||||
test: { argumenten: ['naam'], uitleg: 'Test of de relay deze eigenaar toelaat, zonder te verbinden' },
|
||||
spiegel: { argumenten: ['naam'], uitleg: 'Haalt deze eigenaar op in een lege database, via de relay' },
|
||||
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 <opdracht> [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 <n> Omvang van de blob bij `schrijf` (standaard 64)');
|
||||
regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`');
|
||||
regels.push(' --wacht <n> Seconden wachten op de relay, bij `spiegel` (standaard 15)');
|
||||
regels.push(' --poort <n> 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');
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/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 <naam>` 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);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// De testcliënt zelf: eigenaars aanmaken en blobs door de relay duwen.
|
||||
//
|
||||
// Dit is de publieke kant van dit gereedschap. De opdrachtregel en straks de
|
||||
// pagina praten hiermee en niet rechtstreeks met Evolu; `evolu-node.js` ernaast
|
||||
// doet alleen de platformafhankelijkheden en heeft geen weet van eigenaars.
|
||||
//
|
||||
// Eén eigenaar is één mnemonic plus één database. Evolu leidt de bestandsnaam af
|
||||
// uit `appName` en de eigenaar, dus alle eigenaars kunnen in dezelfde map staan
|
||||
// zonder elkaar te raken; wat ze deelt is het proces, en dat is precies wat de
|
||||
// gedeelde worker aankan (zie evolu-node.js).
|
||||
//
|
||||
// Wat hier NIET in hoort: iets dat de gegevens van Trezor Suite probeert te
|
||||
// lezen. Deze cliënt maakt eigen sleutels en leest zijn eigen gegevens; zie de
|
||||
// niet-doelen in het plan Testclient.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import {
|
||||
AppName,
|
||||
createAppOwner,
|
||||
createEvolu,
|
||||
createOwnerSecret,
|
||||
createOwnerWebSocketTransport,
|
||||
createQueryBuilder,
|
||||
createRandomBytes,
|
||||
createRun,
|
||||
id,
|
||||
mnemonicToOwnerSecret,
|
||||
NonEmptyTrimmedString100,
|
||||
Uint8Array as Uint8ArrayType,
|
||||
} from '@evolu/common';
|
||||
import { installPolyfills } from '@evolu/common/polyfills';
|
||||
|
||||
import { createNodeEvoluDeps } from './evolu-node.js';
|
||||
|
||||
// Node 24 mist `Map.prototype.getOrInsertComputed`, dat Evolu wel gebruikt.
|
||||
// Zonder deze aanroep valt niet het aanmaken om maar de eerste schrijfactie, en
|
||||
// dat is precies het soort fout dat je pas op het verkeerde moment vindt. Hier en
|
||||
// niet in het startbestand, zodat elke ingang van dit gereedschap hem meekrijgt.
|
||||
installPolyfills();
|
||||
|
||||
/**
|
||||
* De naam waaronder deze cliënt zijn databases wegzet.
|
||||
*
|
||||
* Onderdeel van de bestandsnaam, dus veranderen betekent dat elke eigenaar zijn
|
||||
* lokale gegevens kwijt is. Dat maakt op de relay niets uit (die kent alleen het
|
||||
* `OwnerId`), maar de geschiedenis moet dan opnieuw opgehaald worden.
|
||||
*/
|
||||
export const APP_NAME = AppName.orThrow('WhatsnextRelayClient');
|
||||
|
||||
/**
|
||||
* Het schema: één tabel met een label en een blob.
|
||||
*
|
||||
* Bewust klein. Wat hier beproefd wordt is de relay, niet een gegevensmodel. Het
|
||||
* label is er om in een uitdraai te kunnen zien wélke rij je terugkrijgt, en de
|
||||
* blob is waar de omvang in zit die de bytegrens van de relay moet raken.
|
||||
*/
|
||||
export const Schema = {
|
||||
blob: {
|
||||
id: id('Blob'),
|
||||
label: NonEmptyTrimmedString100,
|
||||
body: Uint8ArrayType,
|
||||
},
|
||||
};
|
||||
|
||||
const allesQuery = createQueryBuilder(Schema)((db) =>
|
||||
db.selectFrom('blob').selectAll().orderBy('createdAt'),
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// De gedeelde afhankelijkheden: één stel per proces, voor alle eigenaars samen.
|
||||
//
|
||||
// Dat is geen zuinigheid maar een eis. `createEvoluDeps` vraagt via
|
||||
// `navigator.locks` het slot "tab" aan en kondigt de houder ervan aan als "tab
|
||||
// leader"; de database-worker van een instantie breekt af met "Expected tab
|
||||
// leader port." als die aankondiging er niet is. Eén slot betekent één houder,
|
||||
// dus een tweede stel afhankelijkheden in hetzelfde proces wordt nooit leider en
|
||||
// de tweede eigenaar valt om.
|
||||
//
|
||||
// Precies dat gebeurde op 09-09-2026 bij de gebruiker, bij de tweede eigenaar.
|
||||
// Het stond al als valstrik 2 in PLAN.md §4e en in het commentaar van
|
||||
// evolu-node.js ("Eén keer aanroepen en hergebruiken voor alle eigenaars"), en
|
||||
// het was er alsnog in geslopen omdat `openStore` per aanroep een eigen stel
|
||||
// maakte. In een browser is dit vanzelf goed: daar is er één tabblad met één
|
||||
// gedeelde worker voor alle instanties.
|
||||
//
|
||||
// Meegeteld wordt hoeveel stores er open zijn, zodat het pas wordt opgeruimd als
|
||||
// de laatste sluit.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let gedeeld = null;
|
||||
|
||||
/** Wie er een defect wil horen. Per open store één, en die gaat er bij het sluiten uit. */
|
||||
const defectMelders = new Set();
|
||||
|
||||
/** Wie er wil weten welke WebSocket-URL er werkelijk geopend wordt. */
|
||||
const socketMelders = new Set();
|
||||
|
||||
const haalGedeeldeDeps = (directory, consoleLevel) => {
|
||||
if (gedeeld === null) {
|
||||
const { deps, dispose } = createNodeEvoluDeps({
|
||||
directory,
|
||||
consoleLevel,
|
||||
onDefect: (defect) => {
|
||||
// Naar iedereen die luistert. Een defect komt uit een gedeelde worker en
|
||||
// is dus niet aan één eigenaar toe te schrijven; doen alsof van wel zou
|
||||
// het onderzoek de verkeerde kant op sturen.
|
||||
for (const melder of defectMelders) melder(defect);
|
||||
},
|
||||
onWebSocket: (url) => {
|
||||
for (const melder of socketMelders) melder(url);
|
||||
},
|
||||
});
|
||||
gedeeld = { directory, deps, dispose, aantal: 0 };
|
||||
} else if (gedeeld.directory !== directory) {
|
||||
throw new Error(
|
||||
`Er is al een gegevensmap open (${gedeeld.directory}). Eén map per proces; `
|
||||
+ 'sluit eerst alle stores voordat je een andere map gebruikt.',
|
||||
);
|
||||
}
|
||||
gedeeld.aantal += 1;
|
||||
return gedeeld.deps;
|
||||
};
|
||||
|
||||
const laatGedeeldeDepsLos = async () => {
|
||||
if (gedeeld === null) return;
|
||||
gedeeld.aantal -= 1;
|
||||
if (gedeeld.aantal > 0) return;
|
||||
const weg = gedeeld;
|
||||
gedeeld = null;
|
||||
await weg.dispose();
|
||||
};
|
||||
|
||||
/** Maakt een nieuwe eigenaar met verse sleutels. */
|
||||
export const nieuweEigenaar = () =>
|
||||
createAppOwner(createOwnerSecret({ randomBytes: createRandomBytes() }));
|
||||
|
||||
/**
|
||||
* Herstelt een eigenaar uit zijn mnemonic.
|
||||
*
|
||||
* Dit is de enige weg terug: de mnemonic is wat bewaard wordt, het `OwnerId` is
|
||||
* er een afgeleide van. Een onleesbare mnemonic gooit, en dat hoort: stil een
|
||||
* andere eigenaar maken zou een proef opleveren die nergens over gaat.
|
||||
*/
|
||||
export const eigenaarUitMnemonic = (mnemonic) => createAppOwner(mnemonicToOwnerSecret(mnemonic));
|
||||
|
||||
/**
|
||||
* Opent de opslag van één eigenaar.
|
||||
*
|
||||
* `relayUrl` weglaten of leeg laten geeft een instantie zonder synchronisatie.
|
||||
* Dat is niet alleen voor toetsen nuttig: het is ook hoe je een eigenaar met
|
||||
* gegevens vult vóórdat je hem voor het eerst laat verbinden, en dat is wat de
|
||||
* proef "toelaten en opnieuw verbinden" nodig heeft.
|
||||
*
|
||||
* @param {{directory: string, owner: object, relayUrl?: string, onDefect?: Function, consoleLevel?: string}} config
|
||||
*/
|
||||
export const openStore = async ({
|
||||
directory,
|
||||
owner,
|
||||
relayUrl,
|
||||
onDefect,
|
||||
onWebSocket,
|
||||
consoleLevel = 'error',
|
||||
}) => {
|
||||
const deps = haalGedeeldeDeps(directory, consoleLevel);
|
||||
if (onDefect) defectMelders.add(onDefect);
|
||||
if (onWebSocket) socketMelders.add(onWebSocket);
|
||||
|
||||
// Wél een eigen run per store, want die bezit de instantie: één eigenaar
|
||||
// sluiten hoort de andere niet te raken. Alleen de afhankelijkheden eronder
|
||||
// zijn gedeeld.
|
||||
const run = createRun(deps);
|
||||
|
||||
// Het OwnerId gaat mee in de URL, en dat is wat de relay bij de
|
||||
// WebSocket-upgrade leest om te beslissen of deze eigenaar erin mag. Zonder dat
|
||||
// is er niets te beslissen en is de hele allowlist niet te beproeven; zie §10
|
||||
// van HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md.
|
||||
const transports = relayUrl
|
||||
? [createOwnerWebSocketTransport({ url: relayUrl, ownerId: owner.id })]
|
||||
: [];
|
||||
|
||||
// De instantie start ZONDER sync en de transport gaat er daarna met `useOwner`
|
||||
// in, ook al is het de eigen appOwner. Dat is geen omweg maar het enige wat
|
||||
// losmaken werkelijk losmaakt.
|
||||
//
|
||||
// Waarom: de socket zit niet in deze instantie maar in de gedeelde worker, en
|
||||
// die blijft staan zolang er nog één store open is (zie `gedeeld` bovenaan).
|
||||
// Een transport die je in de config meegeeft, kun je daarna nergens meer
|
||||
// opzeggen; het gevolg was dat de relay een eigenaar bleef zien nadat je hem
|
||||
// in de cliënt had losgekoppeld of zelfs vergeten, tot je het proces stopte.
|
||||
// Gemeld door de gebruiker op 10-09-2026. `useOwner` geeft een opzegging terug
|
||||
// en Evolu telt de verwijzingen naar een transport, dus de laatste opzegging
|
||||
// sluit de socket.
|
||||
const uitkomst = await run(
|
||||
createEvolu(Schema, { appName: APP_NAME, appOwner: owner, transports: [] }),
|
||||
);
|
||||
|
||||
if (!uitkomst.ok) {
|
||||
await run[Symbol.asyncDispose]();
|
||||
if (onDefect) defectMelders.delete(onDefect);
|
||||
if (onWebSocket) socketMelders.delete(onWebSocket);
|
||||
await laatGedeeldeDepsLos();
|
||||
throw new Error(`Evolu wilde niet starten: ${JSON.stringify(uitkomst.error)}`);
|
||||
}
|
||||
|
||||
const evolu = uitkomst.value;
|
||||
|
||||
// `useOwner` eist een niet-lege lijst, dus alleen als er een relay is.
|
||||
let zegSyncOp = transports.length > 0 ? evolu.useOwner(owner, transports) : null;
|
||||
|
||||
return {
|
||||
ownerId: owner.id,
|
||||
/** 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.
|
||||
*
|
||||
* 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),
|
||||
|
||||
/** Voor wie meer wil dan schrijven en lezen. */
|
||||
evolu,
|
||||
|
||||
/**
|
||||
* Sluit af. Eerst de run, dan de afhankelijkheden: de run bezit de instantie
|
||||
* en die gebruikt de workers die in de afhankelijkheden zitten. Andersom
|
||||
* eindigt elk afsluiten met een SuppressedError zonder oorzaak.
|
||||
*
|
||||
* De gedeelde afhankelijkheden gaan pas weg als de láátste store sluit; zie
|
||||
* de uitleg bovenaan bij `gedeeld`.
|
||||
*/
|
||||
sluit: async () => {
|
||||
// Eerst de sync opzeggen en dan de run weggooien. Andersom is de instantie
|
||||
// al weg als de opzegging aankomt.
|
||||
if (zegSyncOp) {
|
||||
zegSyncOp();
|
||||
zegSyncOp = null;
|
||||
}
|
||||
await run[Symbol.asyncDispose]();
|
||||
if (onDefect) defectMelders.delete(onDefect);
|
||||
if (onWebSocket) socketMelders.delete(onWebSocket);
|
||||
await laatGedeeldeDepsLos();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Een Evolu-cliënt op Node in elkaar zetten.
|
||||
//
|
||||
// Dit is het stuk waarvan het plan zei dat het eerst geverifieerd moest worden,
|
||||
// en het antwoord was niet wat we hoopten: **`@evolu/nodejs` bevat geen cliënt.**
|
||||
// Het pakket levert de relay plus losse bouwstenen (de better-sqlite3-driver, een
|
||||
// BroadcastChannel, een klok), maar er is geen `createEvoluDeps` voor Node zoals
|
||||
// `@evolu/web` die voor de browser heeft. Wie op Node een cliënt wil, zet de
|
||||
// afhankelijkheden zelf in elkaar, en dat is wat hier gebeurt.
|
||||
//
|
||||
// Nagetrokken op 09-09-2026 in @evolu/common 8.7.0 en @evolu/nodejs 3.1.0. Wat
|
||||
// `createEvolu` verlangt is `EvoluPlatformDeps`, en dat is de som van zes dingen:
|
||||
//
|
||||
// createDbWorker de database-worker
|
||||
// sharedWorker de gedeelde worker, die de synchronisatie doet
|
||||
// createBroadcastChannel, createMessageChannel, lockManager, reloadApp
|
||||
//
|
||||
// Alles draait hier in één proces. De "worker" en de "shared worker" zijn dus de
|
||||
// in-memory varianten uit `@evolu/common`, en dat is precies waar die voor
|
||||
// bestaan: hun eigen documentatie noemt ze de terugvaloptie voor platforms zonder
|
||||
// echte workers. Voor een testcliënt is dat geen concessie, want er is niets te
|
||||
// winnen met een tweede draad.
|
||||
//
|
||||
// `lockManager` komt van `navigator.locks`. Dat is er sinds Node 24, en het is de
|
||||
// reden dat `package.json` die ondergrens stelt; de documentatie van Evolu noemt
|
||||
// "Web and Node.js 24+" bij dit onderdeel met zoveel woorden.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
createConsole,
|
||||
createConsoleStoreOutput,
|
||||
createMessageChannel,
|
||||
createMessagePort,
|
||||
createRun,
|
||||
createSharedWorker,
|
||||
createWebSocket,
|
||||
createWorker,
|
||||
} from '@evolu/common';
|
||||
import { createEvoluDeps, initSharedWorker, startDbWorker } from '@evolu/common/local-first';
|
||||
import { createBetterSqliteDriver, createBroadcastChannel } from '@evolu/nodejs';
|
||||
|
||||
/**
|
||||
* De SQLite-driver, maar met alle bestanden in één map.
|
||||
*
|
||||
* `createBetterSqliteDriver` maakt `${name}.db` in de werkmap, waarbij `name` de
|
||||
* instantienaam is die Evolu uit `appName` en de eigenaar afleidt. Elke eigenaar
|
||||
* krijgt dus al zijn eigen bestand; wat ontbreekt is een map om ze in te zetten.
|
||||
* Dit voorvoegsel doet dat zonder `process.chdir`, want dat is procesbreed en
|
||||
* deze cliënt draait meerdere eigenaars naast elkaar.
|
||||
*/
|
||||
const createSqliteDriverIn = (directory) => (name, options) =>
|
||||
createBetterSqliteDriver(join(directory, name), options);
|
||||
|
||||
/**
|
||||
* Zet de afhankelijkheden neer die `createEvolu` op Node nodig heeft.
|
||||
*
|
||||
* Geeft naast de afhankelijkheden een `dispose` terug. Die hoort aangeroepen te
|
||||
* worden bij het afsluiten: de runs die hieronder gemaakt worden bezitten de
|
||||
* workers, en een worker die blijft staan houdt het proces open.
|
||||
*
|
||||
* @param {{directory: string, consoleLevel?: string}} config
|
||||
*/
|
||||
export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect, onWebSocket }) => {
|
||||
// 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();
|
||||
|
||||
// De basis die zowel de database-worker als de gedeelde worker verlangt.
|
||||
// `consoleStoreOutputEntry` is hoe een worker zijn logregels naar buiten geeft;
|
||||
// wij lezen dat niet uit, maar de afhankelijkheid moet er zijn.
|
||||
const workerDeps = {
|
||||
console,
|
||||
consoleStoreOutputEntry: consoleStoreOutput.entry,
|
||||
createMessagePort,
|
||||
};
|
||||
|
||||
const lockManager = globalThis.navigator?.locks;
|
||||
if (!lockManager) {
|
||||
throw new Error(
|
||||
'navigator.locks ontbreekt. Dit vraagt Node 24 of hoger; zie de kop van dit bestand.',
|
||||
);
|
||||
}
|
||||
|
||||
// Een defect in een worker is standaard een uitzondering uit een microtaak, en
|
||||
// die komt dus nergens terecht waar je hem kunt lezen: het proces valt om met
|
||||
// "An error was suppressed during disposal" en zonder oorzaak. Deze cliënt
|
||||
// bestaat om fouten zichtbaar te maken, dus dat wordt hier omgeleid.
|
||||
const reportDefect = (defect) => {
|
||||
if (onDefect) {
|
||||
onDefect(defect);
|
||||
return;
|
||||
}
|
||||
console.error('[evolu] defect', defect);
|
||||
};
|
||||
|
||||
// Twee runs, want de twee workers hebben verschillende afhankelijkheden. Ze
|
||||
// worden hier vastgehouden zodat `dispose` ze allebei kan opruimen.
|
||||
const dbRun = createRun({
|
||||
...workerDeps,
|
||||
createBroadcastChannel,
|
||||
lockManager,
|
||||
createSqliteDriver: createSqliteDriverIn(directory),
|
||||
reportDefect,
|
||||
});
|
||||
|
||||
// De WebSocket met een luikje erin. Wat Evolu werkelijk opent is namelijk niet
|
||||
// af te leiden uit wat je hem meegeeft: de `OwnerId` in de URL bepaalt wie de
|
||||
// relay ziet, en dat is precies de vraag waar dit gereedschap over gaat. Zonder
|
||||
// dit luikje kun je alleen gissen waarom er een geweigerde eigenaar op de
|
||||
// statuspagina verschijnt.
|
||||
//
|
||||
// Niet alleen welke URL, maar ook wat ermee gebeurt. Een relay die de socket
|
||||
// meteen weer dichtgooit ziet er van de cliëntkant hetzelfde uit als een relay
|
||||
// die traag is: Evolu probeert het gewoon opnieuw. De sluitcode is wat die twee
|
||||
// uit elkaar houdt.
|
||||
const gevolgdeWebSocket = onWebSocket
|
||||
? (url, options = {}) => {
|
||||
onWebSocket({ wat: 'openen', url });
|
||||
return createWebSocket(url, {
|
||||
...options,
|
||||
onOpen: () => {
|
||||
onWebSocket({ wat: 'open', url });
|
||||
options.onOpen?.();
|
||||
},
|
||||
onError: (fout) => {
|
||||
onWebSocket({ wat: 'fout', url, detail: fout?.type ?? String(fout) });
|
||||
options.onError?.(fout);
|
||||
},
|
||||
onClose: (gebeurtenis) => {
|
||||
onWebSocket({
|
||||
wat: 'dicht',
|
||||
url,
|
||||
detail: `code ${gebeurtenis?.code ?? '?'}${gebeurtenis?.reason ? ` ${gebeurtenis.reason}` : ''}`,
|
||||
});
|
||||
options.onClose?.(gebeurtenis);
|
||||
},
|
||||
});
|
||||
}
|
||||
: createWebSocket;
|
||||
|
||||
const sharedRun = createRun({
|
||||
...workerDeps,
|
||||
createBroadcastChannel,
|
||||
createMessageChannel,
|
||||
createWebSocket: gevolgdeWebSocket,
|
||||
lockManager,
|
||||
reportDefect,
|
||||
});
|
||||
|
||||
const createDbWorker = () =>
|
||||
createWorker((self) => {
|
||||
void dbRun(startDbWorker(self));
|
||||
});
|
||||
|
||||
// `initSharedWorker` geeft een AsyncDisposableStack terug, en die moet iemand
|
||||
// bezitten. Laat je hem vallen, dan wordt hij pas opgeruimd als de run eronder
|
||||
// al weg is, en dat is precies de "Cannot use a disposed object" die deze
|
||||
// cliënt bij elk afsluiten liet zien.
|
||||
let sharedWorkerResources = null;
|
||||
const sharedWorker = createSharedWorker((self) => {
|
||||
void Promise.resolve(sharedRun(initSharedWorker(self))).then((result) => {
|
||||
if (result.ok) sharedWorkerResources = result.value;
|
||||
});
|
||||
});
|
||||
|
||||
// En dan door `createEvoluDeps` heen, want dat is geen optionele verpakking.
|
||||
// Dáár wordt de gedeelde worker aan de database-worker geknoopt en wordt de
|
||||
// "tab leader" aangekondigd; zonder die stap breekt de gedeelde worker af met
|
||||
// "Expected tab leader port." zodra de eerste schrijfactie binnenkomt. Dat is
|
||||
// de tweede valstrik van vandaag: de typen laten `EvoluPlatformDeps` toe waar
|
||||
// `EvoluDeps` hoort, dus het compileert en het faalt pas als je iets doet.
|
||||
//
|
||||
// Eén keer aanroepen en hergebruiken voor alle eigenaars, zoals de
|
||||
// documentatie voorschrijft. Dat is ook precies goed voor deze cliënt: in een
|
||||
// browser bedient één gedeelde worker alle instanties van alle tabbladen, en
|
||||
// hier bedient hij alle eigenaars in dit proces.
|
||||
const evoluDeps = createEvoluDeps({
|
||||
console,
|
||||
createDbWorker,
|
||||
createBroadcastChannel,
|
||||
createMessageChannel,
|
||||
lockManager,
|
||||
// Een cliënt zonder venster heeft niets te herladen. Evolu roept dit aan als
|
||||
// hij de app wil laten herstarten; hier is dat een logregel waard en verder
|
||||
// niets, want het proces herstarten zou de proef die loopt weggooien.
|
||||
reloadApp: () => {
|
||||
console.warn('[evolu] reloadApp aangeroepen, genegeerd in deze cliënt');
|
||||
},
|
||||
sharedWorker,
|
||||
});
|
||||
|
||||
return {
|
||||
deps: evoluDeps,
|
||||
/**
|
||||
* Ruimt de workers op. Asynchroon, en dat moet.
|
||||
*
|
||||
* Het afsluiten van een worker is zelf een taak op de run die hem draagt.
|
||||
* Wordt die run meteen weggegooid, dan loopt dat afsluiten tegen "Cannot use
|
||||
* a disposed object" aan, en dat komt naar buiten als een SuppressedError
|
||||
* zonder oorzaak. De volgorde is dus: eerst de afhankelijkheden, dan de runs
|
||||
* afwachten in plaats van weggooien.
|
||||
*/
|
||||
dispose: async () => {
|
||||
evoluDeps[Symbol.dispose]();
|
||||
// De in-memory workers bezorgen hun berichten asynchroon, dus op dit punt
|
||||
// staat er nog post in de wacht die bij aankomst zijn eigen run gebruikt.
|
||||
// Even doorlaten voordat die runs weggaan.
|
||||
await new Promise((klaar) => {
|
||||
setImmediate(klaar);
|
||||
});
|
||||
if (sharedWorkerResources) await sharedWorkerResources[Symbol.asyncDispose]();
|
||||
await sharedRun[Symbol.asyncDispose]();
|
||||
await dbRun[Symbol.asyncDispose]();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Het bedieningsvlak van de testclient.
|
||||
|
||||
Engels op het scherm zou hier misstaan: dit is gereedschap voor de eigen
|
||||
werkbank en niet iets wat een bezoeker van de app store ziet. De taalgrens in
|
||||
Werkafspraken.md loopt langs "ziet de gebruiker van de app dit", en dat is hier nee.
|
||||
|
||||
Geen framework en geen bouwstap, net als de statuspagina's van de twee apps.
|
||||
-->
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Relay-testclient</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--grond: #f6f6f4;
|
||||
--kaart: #ffffff;
|
||||
--rand: #e2e2dd;
|
||||
--tekst: #1b1b19;
|
||||
--zacht: #6b6b64;
|
||||
--accent: #b45309;
|
||||
--goed: #15803d;
|
||||
--fout: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--grond: #17171a;
|
||||
--kaart: #1f1f23;
|
||||
--rand: #33333a;
|
||||
--tekst: #ececea;
|
||||
--zacht: #9a9a94;
|
||||
--accent: #f59e0b;
|
||||
--goed: #4ade80;
|
||||
--fout: #f87171;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 20px 64px;
|
||||
background: var(--grond);
|
||||
color: var(--tekst);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main { max-width: 940px; margin: 0 auto; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--zacht); font-weight: 600; }
|
||||
.kop { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline;
|
||||
margin-bottom: 20px; color: var(--zacht); font-size: 13px; }
|
||||
.kaart { background: var(--kaart); border: 1px solid var(--rand);
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; }
|
||||
.rij { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
|
||||
.naam { font-weight: 600; font-size: 16px; }
|
||||
.id { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||
font-size: 12px; color: var(--zacht); word-break: break-all; }
|
||||
.stip { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--zacht); margin-right: 6px; vertical-align: 1px; }
|
||||
.stip.aan { background: var(--goed); }
|
||||
.duw { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
button, input {
|
||||
font: inherit; border-radius: 7px; border: 1px solid var(--rand);
|
||||
background: var(--kaart); color: var(--tekst); padding: 5px 11px;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
input { width: 8em; }
|
||||
input.breed { width: 12em; }
|
||||
.leeg, .melding { color: var(--zacht); font-size: 14px; }
|
||||
.melding.fout { color: var(--fout); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
|
||||
td { padding: 3px 8px 3px 0; border-top: 1px solid var(--rand); }
|
||||
td.getal { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
#log { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||
font-size: 12px; max-height: 260px; overflow-y: auto; }
|
||||
#log div { padding: 2px 0; border-top: 1px solid var(--rand); }
|
||||
#log div:first-child { border-top: 0; }
|
||||
#log time { color: var(--zacht); margin-right: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Relay-testclient</h1>
|
||||
<div class="kop">
|
||||
<span>Relay: <b id="relay">…</b></span>
|
||||
<span id="waarschuwing" class="melding fout" hidden>
|
||||
Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2>Nieuwe eigenaar</h2>
|
||||
<div class="kaart">
|
||||
<div class="rij">
|
||||
<input id="nieuwNaam" class="breed" placeholder="naam" maxlength="40" />
|
||||
<button id="nieuwKnop">Aanmaken</button>
|
||||
<span id="nieuwMelding" class="melding"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Eigenaars</h2>
|
||||
<div id="eigenaars"></div>
|
||||
|
||||
<h2>Log</h2>
|
||||
<div class="kaart" id="log"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let bezig = false;
|
||||
|
||||
// Wat er als laatste getekend is. De lijst wordt in zijn geheel opnieuw
|
||||
// opgebouwd, dus zonder deze vergelijking zou elke drie seconden het veld
|
||||
// met de omvang leeggaan terwijl je erin typt, en zou een knop onder je
|
||||
// muis vervangen worden.
|
||||
let laatsteStaat = '';
|
||||
|
||||
// De ingetypte omvang per eigenaar, zodat die een hertekening overleeft.
|
||||
const omvangPer = new Map();
|
||||
|
||||
const roep = async (actie, waarden) => {
|
||||
const antwoord = await fetch(`/api/${actie}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(waarden ?? {}),
|
||||
});
|
||||
const uit = await antwoord.json();
|
||||
if (!antwoord.ok) throw new Error(uit.fout ?? 'Er ging iets mis.');
|
||||
return uit;
|
||||
};
|
||||
|
||||
const tijd = (iso) => (iso ? new Date(iso).toLocaleTimeString('nl-NL') : '');
|
||||
|
||||
const knop = (tekst, aan) => {
|
||||
const el = document.createElement('button');
|
||||
el.textContent = tekst;
|
||||
el.addEventListener('click', async () => {
|
||||
if (bezig) return;
|
||||
bezig = true;
|
||||
el.disabled = true;
|
||||
try {
|
||||
await aan();
|
||||
} catch (oorzaak) {
|
||||
alert(oorzaak.message);
|
||||
} finally {
|
||||
bezig = false;
|
||||
el.disabled = false;
|
||||
await ververs();
|
||||
}
|
||||
});
|
||||
return el;
|
||||
};
|
||||
|
||||
const kaartVoor = (eigenaar) => {
|
||||
const kaart = document.createElement('div');
|
||||
kaart.className = 'kaart';
|
||||
|
||||
const rij = document.createElement('div');
|
||||
rij.className = 'rij';
|
||||
|
||||
const naam = document.createElement('span');
|
||||
naam.className = 'naam';
|
||||
naam.innerHTML = `<span class="stip${eigenaar.verbonden ? ' aan' : ''}"></span>`;
|
||||
naam.append(eigenaar.naam);
|
||||
rij.append(naam);
|
||||
|
||||
const toestand = document.createElement('span');
|
||||
toestand.className = 'melding';
|
||||
toestand.textContent = eigenaar.verbonden
|
||||
? `${eigenaar.metRelay ? 'verbonden' : 'lokaal open'} sinds ${tijd(eigenaar.sinds)}${
|
||||
eigenaar.blobs === null ? '' : ` · ${eigenaar.blobs} blob(s)`
|
||||
}`
|
||||
: 'niet verbonden';
|
||||
rij.append(toestand);
|
||||
|
||||
if (eigenaar.test) {
|
||||
const test = document.createElement('span');
|
||||
test.className = eigenaar.test.geaccepteerd ? 'melding' : 'melding fout';
|
||||
test.textContent = `getest ${tijd(eigenaar.test.tijd)}: ${eigenaar.test.status ?? '-'} ${eigenaar.test.uitleg}`;
|
||||
rij.append(test);
|
||||
}
|
||||
|
||||
const duw = document.createElement('div');
|
||||
duw.className = 'duw';
|
||||
|
||||
const omvang = document.createElement('input');
|
||||
omvang.value = omvangPer.get(eigenaar.naam) ?? '64';
|
||||
omvang.title = 'Omvang van de blob in bytes';
|
||||
omvang.style.width = '6em';
|
||||
omvang.addEventListener('input', () => omvangPer.set(eigenaar.naam, omvang.value));
|
||||
|
||||
// Verbinden gaat uit zodra een test zegt dat deze eigenaar geweigerd
|
||||
// wordt. Niet uit netheid: Evolu blijft bij een weigering eindeloos
|
||||
// opnieuw proberen, en élke poging is een regel in de weigerlijst van de
|
||||
// relay. Onbekend telt als toegestaan, want anders moet je eerst testen
|
||||
// om iets te mogen, en dat is zelf ook een poging.
|
||||
const verbindKnop = eigenaar.verbonden
|
||||
? knop('Verbreken', () => roep('verbreek', { naam: eigenaar.naam }))
|
||||
: knop('Verbinden', () => roep('verbind', { naam: eigenaar.naam }));
|
||||
|
||||
if (!eigenaar.verbonden && !eigenaar.magVerbinden) {
|
||||
verbindKnop.disabled = true;
|
||||
verbindKnop.title = 'De relay weigert deze eigenaar. Laat hem toe op de statuspagina van de app en test opnieuw.';
|
||||
}
|
||||
|
||||
duw.append(
|
||||
// Geen melding in een dialoogvenster: de uitkomst komt naast de naam te
|
||||
// staan en in het log, en die blijven staan. Een dialoog moet je
|
||||
// wegklikken en is daarna weg.
|
||||
knop('Testen', () => roep('test', { naam: eigenaar.naam })),
|
||||
verbindKnop,
|
||||
omvang,
|
||||
knop('Blob schrijven', () =>
|
||||
roep('schrijf', { naam: eigenaar.naam, label: `blob ${tijd(new Date().toISOString())}`, bytes: omvang.value }),
|
||||
),
|
||||
knop('Lezen', async () => {
|
||||
const uit = await roep('lees', { naam: eigenaar.naam });
|
||||
toonRijen(kaart, uit.rijen);
|
||||
}),
|
||||
knop('Vergeten', async () => {
|
||||
if (!confirm(`"${eigenaar.naam}" uit het register halen? De lokale database blijft staan.`)) return;
|
||||
await roep('vergeet', { naam: eigenaar.naam });
|
||||
}),
|
||||
);
|
||||
|
||||
rij.append(duw);
|
||||
kaart.append(rij);
|
||||
|
||||
const id = document.createElement('div');
|
||||
id.className = 'id';
|
||||
id.textContent = eigenaar.ownerId;
|
||||
kaart.append(id);
|
||||
|
||||
return kaart;
|
||||
};
|
||||
|
||||
const toonRijen = (kaart, rijen) => {
|
||||
kaart.querySelector('table')?.remove();
|
||||
const tabel = document.createElement('table');
|
||||
if (rijen.length === 0) {
|
||||
const cel = tabel.insertRow().insertCell();
|
||||
cel.className = 'leeg';
|
||||
cel.textContent = 'Nog geen blobs.';
|
||||
}
|
||||
for (const rij of rijen) {
|
||||
const tr = tabel.insertRow();
|
||||
tr.insertCell().textContent = rij.label;
|
||||
const bytes = tr.insertCell();
|
||||
bytes.className = 'getal';
|
||||
bytes.textContent = `${rij.bytes} bytes`;
|
||||
const id = tr.insertCell();
|
||||
id.className = 'id';
|
||||
id.textContent = rij.id;
|
||||
}
|
||||
kaart.append(tabel);
|
||||
};
|
||||
|
||||
const ververs = async (altijd = false) => {
|
||||
const ruw = await (await fetch('/api/status')).text();
|
||||
if (!altijd && ruw === laatsteStaat) return;
|
||||
laatsteStaat = ruw;
|
||||
const staat = JSON.parse(ruw);
|
||||
|
||||
$('relay').textContent = staat.relayUrl ?? 'niet ingesteld';
|
||||
$('waarschuwing').hidden = Boolean(staat.relayUrl);
|
||||
|
||||
const lijst = $('eigenaars');
|
||||
lijst.textContent = '';
|
||||
if (staat.eigenaars.length === 0) {
|
||||
const leeg = document.createElement('div');
|
||||
leeg.className = 'kaart leeg';
|
||||
leeg.textContent = 'Nog geen eigenaars. Maak er hierboven een aan.';
|
||||
lijst.append(leeg);
|
||||
}
|
||||
for (const eigenaar of staat.eigenaars) lijst.append(kaartVoor(eigenaar));
|
||||
|
||||
const log = $('log');
|
||||
log.textContent = '';
|
||||
if (staat.log.length === 0) {
|
||||
log.className = 'kaart leeg';
|
||||
log.textContent = 'Nog niets gebeurd.';
|
||||
} else {
|
||||
log.className = 'kaart';
|
||||
for (const regel of staat.log) {
|
||||
const el = document.createElement('div');
|
||||
const t = document.createElement('time');
|
||||
t.textContent = tijd(regel.tijd);
|
||||
el.append(t, regel.tekst);
|
||||
log.append(el);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$('nieuwKnop').addEventListener('click', async () => {
|
||||
const naam = $('nieuwNaam').value.trim();
|
||||
const melding = $('nieuwMelding');
|
||||
melding.className = 'melding';
|
||||
melding.textContent = '';
|
||||
try {
|
||||
const uit = await roep('nieuw', { naam });
|
||||
$('nieuwNaam').value = '';
|
||||
melding.textContent = `"${uit.naam}" aangemaakt.`;
|
||||
await ververs();
|
||||
} catch (oorzaak) {
|
||||
melding.className = 'melding fout';
|
||||
melding.textContent = oorzaak.message;
|
||||
}
|
||||
});
|
||||
|
||||
$('nieuwNaam').addEventListener('keydown', (gebeurtenis) => {
|
||||
if (gebeurtenis.key === 'Enter') $('nieuwKnop').click();
|
||||
});
|
||||
|
||||
void ververs();
|
||||
// Verversen terwijl je met een knop bezig bent zou de kaart onder je muis
|
||||
// vervangen, dus dat wordt overgeslagen.
|
||||
setInterval(() => {
|
||||
if (!bezig) void ververs();
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
// HomeGit/Docs/Referenties/UmbrelApps/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();
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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(() => {});
|
||||
};
|
||||
Reference in new issue
Block a user