Files
UmbrelApps/tools/relay-client/src/cli.js
T

289 lines
11 KiB
JavaScript
Raw Normal View History

#!/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();
}
},
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);
},
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);
}