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:
HarmenandClaude Opus 5.5 committed 2026-10-09 10:40:25 +02:00
commit 85d85a1295
63 files changed
+13987

No files matched your search

+224
View File
@@ -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]();
},
};
};