Files
UmbrelApps/tools/relay-client/src/client.js
T
HarmenandClaude Opus 5.5 85d85a1295 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>
2026-10-09 10:40:25 +02:00

259 lines
11 KiB
JavaScript

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