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