224 lines
9.6 KiB
JavaScript
224 lines
9.6 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 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]();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|