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

+27
View File
@@ -0,0 +1,27 @@
# Instellingen van de testclient. Kopieer dit bestand naar .env en vul het in;
# .env staat in .gitignore en komt dus niet in de repo.
#
# Het adres van je Umbrel hoort hier en nergens anders: dit is een publieke app
# store, en een adres in de historie krijg je er niet meer uit.
# Waar de relay luistert. Poort 3852 is wat de app publiceert.
# Voorbeelden:
# RELAY_URL=ws://umbrel.local:3852
# RELAY_URL=ws://192.168.1.20:3852
# RELAY_URL=wss://relay.voorbeeld.nl (achter een reverse proxy met TLS)
#
# ws:// of wss:// mag je weglaten; zonder schema wordt het ws://.
#
# LET OP, en dit kostte op 09-09-2026 de eerste poging: zet bij wss:// GEEN
# poort 3852 erachter. Die poort is waar de relay zelf luistert, in plat ws,
# binnen je netwerk. Je certificaat zit op de reverse proxy en die luistert op
# 443. wss://host:3852 vraagt dus om TLS op een poort waar geen TLS is, en van
# buiten is die poort bovendien dicht. Laat de poort weg.
RELAY_URL=ws://umbrel.local:3852
# Waar het register en de databases komen te staan. Relatief aan
# tools/relay-client/. Hier staan mnemonics, dus deze map is gitignored.
#DATA_DIR=data
# De poort van het bedieningsvlak in de browser.
#UI_PORT=4380
+55
View File
@@ -0,0 +1,55 @@
@echo off
rem ============================================================================
rem Start het bedieningsvlak van de testclient en opent de browser.
rem
rem Dubbelklikken werkt: het venster blijft open zolang de server draait, en
rem blijft ook open als er iets misgaat, want anders lees je de fout nooit.
rem
rem De macOS-tegenhanger is Start.command ernaast.
rem ============================================================================
setlocal
cd /d "%~dp0"
where node >nul 2>nul
if errorlevel 1 (
echo Node is niet gevonden. Deze client heeft Node 24 of hoger nodig.
echo Zie https://nodejs.org
echo.
pause
exit /b 1
)
if not exist "node_modules" (
echo Pakketten ontbreken, npm install wordt gedraaid...
call npm install
if errorlevel 1 (
echo.
echo npm install is mislukt.
pause
exit /b 1
)
echo.
)
if not exist ".env" (
echo Let op: er is geen .env, dus er is geen relay-adres.
echo Kopieer .env.sample naar .env en vul RELAY_URL in.
echo Zonder dat werkt alles behalve verbinden met de relay.
echo.
)
rem De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem
rem dan in .env als UI_PORT en niet hier: dan weet de client het ook.
set UI_ADRES=http://127.0.0.1:4380
rem De browser gaat nu open, terwijl de server nog moet starten. Dat is goed:
rem het tabblad ververst zichzelf elke drie seconden, dus een eerste poging die
rem te vroeg komt herstelt vanzelf.
start "" "%UI_ADRES%"
node src\cli.js ui
echo.
echo De client is gestopt.
pause
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# =============================================================================
# Start het bedieningsvlak van de testclient en opent de browser. Voor macOS.
#
# Dubbelklikken in Finder werkt, maar alleen als het bestand uitvoerbaar is.
# Git bewaart dat bit, dus na een verse kloon hoort het te kloppen; is dat niet
# zo, dan eenmalig: chmod +x Start.command
#
# De Windows-tegenhanger is Start.bat ernaast.
# =============================================================================
set -u
cd "$(dirname "$0")"
if ! command -v node >/dev/null 2>&1; then
echo "Node is niet gevonden. Deze client heeft Node 24 of hoger nodig."
echo "Zie https://nodejs.org"
echo
read -r -p "Enter om te sluiten."
exit 1
fi
if [ ! -d node_modules ]; then
echo "Pakketten ontbreken, npm install wordt gedraaid..."
if ! npm install; then
echo
echo "npm install is mislukt."
read -r -p "Enter om te sluiten."
exit 1
fi
echo
fi
if [ ! -f .env ]; then
echo "Let op: er is geen .env, dus er is geen relay-adres."
echo "Kopieer .env.sample naar .env en vul RELAY_URL in."
echo "Zonder dat werkt alles behalve verbinden met de relay."
echo
fi
# De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem dan
# in .env als UI_PORT en niet hier: dan weet de client het ook.
UI_ADRES="http://127.0.0.1:4380"
# De browser gaat nu open, terwijl de server nog moet starten. Dat is goed: het
# tabblad ververst zichzelf elke drie seconden, dus een eerste poging die te
# vroeg komt herstelt vanzelf.
open "$UI_ADRES" >/dev/null 2>&1 || true
node src/cli.js ui
echo
echo "De client is gestopt."
read -r -p "Enter om te sluiten."
+616
View File
@@ -0,0 +1,616 @@
{
"name": "whatsnext-relay-client",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "whatsnext-relay-client",
"version": "0.0.0",
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@evolu/common": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@evolu/common/-/common-8.7.0.tgz",
"integrity": "sha512-52bfvAJZaOmAAmiaOdkGn4MtYbIK1xf5sSb9fjzdRRAD6XbJtRT4yqlJ5MxBacsUHDtMXjUW02XgYlbUXyJ04A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@noble/ciphers": "^2.3.0",
"@noble/hashes": "^2.3.0",
"@scure/bip39": "^2.3.0",
"@standard-schema/spec": "^1.1.0",
"kysely": "^0.29.5",
"random": "5.5.1"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/@evolu/nodejs": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@evolu/nodejs/-/nodejs-3.1.0.tgz",
"integrity": "sha512-xw1eYjsWNbQfJsDQ0pTe/2iYrxuzDG0cHMhObyCbHwICdJzXvEH2iVGzw4qPfxW4fikZNcUTsAHyzblW8tJkqg==",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.11.1",
"ws": "^8.21.3"
},
"engines": {
"node": ">=24.0.0"
},
"peerDependencies": {
"@evolu/common": "^8.0.0",
"@evolu/oxlint-config": "^0.1.0",
"@evolu/typescript-config": "^0.1.0",
"oxlint": "^1.79.0",
"oxlint-tsgolint": "7.0.2001",
"typescript": ">=7.0.0",
"vite": ">=8.0.0",
"webpack": ">=5.108.4"
},
"peerDependenciesMeta": {
"@evolu/oxlint-config": {
"optional": true
},
"@evolu/typescript-config": {
"optional": true
},
"oxlint": {
"optional": true
},
"oxlint-tsgolint": {
"optional": true
},
"typescript": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz",
"integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/kysely": {
"version": "0.29.5",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz",
"integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.96.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/random": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/random/-/random-5.5.1.tgz",
"integrity": "sha512-2TIJKf/BGjy0FGeZaan2+a/vq37afbSyscquADMFC13KBjFmg+10T/TkACpP9GMHfbEruKYbHqq0rjCA9x8gNA==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "whatsnext-relay-client",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Testcliënt voor Evolu Relay: meerdere eigenaars, elk met eigen sleutels, om het toegangsbeleid en de opslag te beproeven.",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/cli.js"
},
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De opdrachtregel lezen. Meer niet.
//
// Apart van cli.js omdat dit het enige deel van de opdrachtregel is dat te
// toetsen valt zonder iets te starten. Dezelfde scheiding als policy.js aan de
// relay-kant: pure functies aan de ene kant, alles wat schijf of netwerk raakt
// aan de andere.
// ═══════════════════════════════════════════════════════════════════════════════
export const OPDRACHTEN = {
nieuw: { argumenten: ['naam'], uitleg: 'Maakt een nieuwe eigenaar met verse sleutels' },
lijst: { argumenten: [], uitleg: 'Toont de eigenaars in het register' },
schrijf: { argumenten: ['naam', 'label'], uitleg: 'Schrijft een blob weg voor deze eigenaar' },
lees: { argumenten: ['naam'], uitleg: 'Toont de blobs van deze eigenaar' },
test: { argumenten: ['naam'], uitleg: 'Test of de relay deze eigenaar toelaat, zonder te verbinden' },
spiegel: { argumenten: ['naam'], uitleg: 'Haalt deze eigenaar op in een lege database, via de relay' },
sync: { argumenten: ['naam'], uitleg: 'Verbindt en blijft draaien tot Ctrl-C' },
vergeet: { argumenten: ['naam'], uitleg: 'Haalt een eigenaar uit het register' },
ui: { argumenten: [], uitleg: 'Start het bedieningsvlak in de browser' },
};
/**
* Leest `process.argv.slice(2)`.
*
* Geeft `{fout}` in plaats van te gooien: de aanroeper drukt dat af met de
* gebruiksaanwijzing eronder, en dat leest beter dan een stack trace.
*
* Vlaggen zijn `--naam waarde` of `--naam=waarde`. Een losse `--naam` is `true`.
*/
export const leesArgumenten = (argv) => {
const losse = [];
const vlaggen = {};
for (let i = 0; i < argv.length; i += 1) {
const stuk = argv[i];
if (!stuk.startsWith('--')) {
losse.push(stuk);
continue;
}
const kaal = stuk.slice(2);
const isGelijk = kaal.indexOf('=');
if (isGelijk > 0) {
vlaggen[kaal.slice(0, isGelijk)] = kaal.slice(isGelijk + 1);
continue;
}
const volgende = argv[i + 1];
if (volgende !== undefined && !volgende.startsWith('--')) {
vlaggen[kaal] = volgende;
i += 1;
} else {
vlaggen[kaal] = true;
}
}
const [opdracht, ...rest] = losse;
if (opdracht === undefined) return { fout: null, opdracht: null, waarden: {}, vlaggen };
const vorm = OPDRACHTEN[opdracht];
if (!vorm) return { fout: `Onbekende opdracht "${opdracht}".`, opdracht: null, waarden: {}, vlaggen };
if (rest.length < vorm.argumenten.length) {
const ontbreekt = vorm.argumenten.slice(rest.length).join(', ');
return { fout: `"${opdracht}" mist: ${ontbreekt}.`, opdracht, waarden: {}, vlaggen };
}
if (rest.length > vorm.argumenten.length) {
// Streng, en met opzet. `schrijf piet mijn label` ziet er goed uit en zou
// anders stil "mijn" als label nemen; dan sta je te kijken naar een blob die
// anders heet dan je typte.
return {
fout: `"${opdracht}" verwacht ${vorm.argumenten.length} argument(en), niet ${rest.length}. Gebruik aanhalingstekens om een label met spaties.`,
opdracht,
waarden: {},
vlaggen,
};
}
const waarden = {};
vorm.argumenten.forEach((naam, plek) => {
waarden[naam] = rest[plek];
});
return { fout: null, opdracht, waarden, vlaggen };
};
export const gebruiksaanwijzing = () => {
const regels = ['Gebruik: node src/cli.js <opdracht> [argumenten]', ''];
for (const [naam, vorm] of Object.entries(OPDRACHTEN)) {
const argumenten = vorm.argumenten.map((a) => `<${a}>`).join(' ');
regels.push(` ${`${naam} ${argumenten}`.trim().padEnd(24)}${vorm.uitleg}`);
}
regels.push('');
regels.push('Vlaggen:');
regels.push(' --bytes <n> Omvang van de blob bij `schrijf` (standaard 64)');
regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`');
regels.push(' --wacht <n> Seconden wachten op de relay, bij `spiegel` (standaard 15)');
regels.push(' --poort <n> Poort van het bedieningsvlak bij `ui`');
regels.push('');
regels.push('Het adres van de relay komt uit .env; zie .env.sample.');
return regels.join('\n');
};
+288
View File
@@ -0,0 +1,288 @@
#!/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();
}
},
test: async ({ naam }) => {
eisRelay();
const { regel } = haalEigenaarOp(naam);
console.log(`Testen 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);
}
+258
View File
@@ -0,0 +1,258 @@
// ═══════════════════════════════════════════════════════════════════════════════
// 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();
},
};
};
+99
View File
@@ -0,0 +1,99 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Waar staat de relay, en waar mogen de gegevens staan.
//
// Het adres van de Umbrel staat NIET in deze repo en hoort daar ook niet in:
// dit is een publieke app store, en de .gitignore van de repo-root heeft er een
// eigen kopje over. Het komt dus uit de omgeving, met `.env` als de gewone weg
// en `.env.sample` als het voorbeeld dat wél in de repo staat.
//
// Geen dotenv-pakket: dat is een afhankelijkheid voor twintig regels, en die
// twintig regels zijn hier beter te lezen dan de instellingen van een pakket.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** De wortel van dit gereedschap, ongeacht vanwaar het gestart wordt. */
export const WORTEL = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Leest een `.env`-bestand.
*
* Regels van de vorm `NAAM=waarde`. Lege regels en regels die met `#` beginnen
* worden overgeslagen, en aanhalingstekens om de waarde gaan eraf. Meer niet: wie
* hier een regel met een dubbele bodem in zet, krijgt hem letterlijk terug.
*/
export const leesEnvBestand = (inhoud) => {
const uit = {};
for (const regel of inhoud.split(/\r?\n/)) {
const kaal = regel.trim();
if (kaal === '' || kaal.startsWith('#')) continue;
const isGelijk = kaal.indexOf('=');
if (isGelijk <= 0) continue;
const naam = kaal.slice(0, isGelijk).trim();
let waarde = kaal.slice(isGelijk + 1).trim();
if (
(waarde.startsWith('"') && waarde.endsWith('"') && waarde.length >= 2)
|| (waarde.startsWith("'") && waarde.endsWith("'") && waarde.length >= 2)
) {
waarde = waarde.slice(1, -1);
}
uit[naam] = waarde;
}
return uit;
};
/**
* Maakt van een adres een WebSocket-URL, of gooit met uitleg.
*
* De relay is een WebSocket-server, dus `ws://` of `wss://`. Een `http://` dat
* iemand uit de adresbalk plakt wordt omgezet in plaats van geweigerd: dat is
* de meest voorkomende vergissing en er is niets dubbelzinnigs aan.
*
* Wat er wél uitkomt is een adres zonder schema (`umbrel.local:3852`), en dat
* krijgt `ws://`. Zonder poort blijft het zoals het is: achter een reverse proxy
* met TLS is `wss://host` zonder poort juist de goede vorm.
*/
export const naarRelayUrl = (adres) => {
if (typeof adres !== 'string' || adres.trim() === '') {
throw new Error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
}
const kaal = adres.trim();
if (kaal.startsWith('ws://') || kaal.startsWith('wss://')) return kaal;
if (kaal.startsWith('http://')) return `ws://${kaal.slice('http://'.length)}`;
if (kaal.startsWith('https://')) return `wss://${kaal.slice('https://'.length)}`;
if (kaal.includes('://')) {
throw new Error(`"${kaal}" heeft een schema dat hier niets betekent. Gebruik ws:// of wss://.`);
}
return `ws://${kaal}`;
};
/**
* De instellingen: eerst de omgeving, dan `.env`, dan de standaardwaarden.
*
* De omgeving wint van het bestand, zodat je één keer iets anders kunt proberen
* zonder het bestand aan te raken.
*/
export const leesConfig = ({ env = process.env, wortel = WORTEL } = {}) => {
let uitBestand = {};
try {
uitBestand = leesEnvBestand(readFileSync(join(wortel, '.env'), 'utf8'));
} catch (oorzaak) {
if (oorzaak.code !== 'ENOENT') throw oorzaak;
}
const haal = (naam) => env[naam] ?? uitBestand[naam];
const adres = haal('RELAY_URL');
return {
/** Het adres van de relay, of `null` als het er niet is. */
relayUrl: adres ? naarRelayUrl(adres) : null,
/** Waar het register en de databases staan. */
dataDir: resolve(wortel, haal('DATA_DIR') ?? 'data'),
/** De poort van het bedieningsvlak. */
uiPoort: Number.parseInt(haal('UI_PORT') ?? '4380', 10),
};
};
+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]();
},
};
};
+324
View File
@@ -0,0 +1,324 @@
<!doctype html>
<!--
Het bedieningsvlak van de testclient.
Engels op het scherm zou hier misstaan: dit is gereedschap voor de eigen
werkbank en niet iets wat een bezoeker van de app store ziet. De taalgrens in
Werkafspraken.md loopt langs "ziet de gebruiker van de app dit", en dat is hier nee.
Geen framework en geen bouwstap, net als de statuspagina's van de twee apps.
-->
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Relay-testclient</title>
<style>
:root {
color-scheme: light dark;
--grond: #f6f6f4;
--kaart: #ffffff;
--rand: #e2e2dd;
--tekst: #1b1b19;
--zacht: #6b6b64;
--accent: #b45309;
--goed: #15803d;
--fout: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--grond: #17171a;
--kaart: #1f1f23;
--rand: #33333a;
--tekst: #ececea;
--zacht: #9a9a94;
--accent: #f59e0b;
--goed: #4ade80;
--fout: #f87171;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px 20px 64px;
background: var(--grond);
color: var(--tekst);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 940px; margin: 0 auto; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--zacht); font-weight: 600; }
.kop { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline;
margin-bottom: 20px; color: var(--zacht); font-size: 13px; }
.kaart { background: var(--kaart); border: 1px solid var(--rand);
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; }
.rij { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
.naam { font-weight: 600; font-size: 16px; }
.id { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
font-size: 12px; color: var(--zacht); word-break: break-all; }
.stip { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--zacht); margin-right: 6px; vertical-align: 1px; }
.stip.aan { background: var(--goed); }
.duw { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
button, input {
font: inherit; border-radius: 7px; border: 1px solid var(--rand);
background: var(--kaart); color: var(--tekst); padding: 5px 11px;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:disabled { opacity: 0.45; cursor: default; }
input { width: 8em; }
input.breed { width: 12em; }
.leeg, .melding { color: var(--zacht); font-size: 14px; }
.melding.fout { color: var(--fout); }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
td { padding: 3px 8px 3px 0; border-top: 1px solid var(--rand); }
td.getal { text-align: right; font-variant-numeric: tabular-nums; }
#log { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
font-size: 12px; max-height: 260px; overflow-y: auto; }
#log div { padding: 2px 0; border-top: 1px solid var(--rand); }
#log div:first-child { border-top: 0; }
#log time { color: var(--zacht); margin-right: 10px; }
</style>
</head>
<body>
<main>
<h1>Relay-testclient</h1>
<div class="kop">
<span>Relay: <b id="relay">…</b></span>
<span id="waarschuwing" class="melding fout" hidden>
Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.
</span>
</div>
<h2>Nieuwe eigenaar</h2>
<div class="kaart">
<div class="rij">
<input id="nieuwNaam" class="breed" placeholder="naam" maxlength="40" />
<button id="nieuwKnop">Aanmaken</button>
<span id="nieuwMelding" class="melding"></span>
</div>
</div>
<h2>Eigenaars</h2>
<div id="eigenaars"></div>
<h2>Log</h2>
<div class="kaart" id="log"></div>
</main>
<script>
const $ = (id) => document.getElementById(id);
let bezig = false;
// Wat er als laatste getekend is. De lijst wordt in zijn geheel opnieuw
// opgebouwd, dus zonder deze vergelijking zou elke drie seconden het veld
// met de omvang leeggaan terwijl je erin typt, en zou een knop onder je
// muis vervangen worden.
let laatsteStaat = '';
// De ingetypte omvang per eigenaar, zodat die een hertekening overleeft.
const omvangPer = new Map();
const roep = async (actie, waarden) => {
const antwoord = await fetch(`/api/${actie}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(waarden ?? {}),
});
const uit = await antwoord.json();
if (!antwoord.ok) throw new Error(uit.fout ?? 'Er ging iets mis.');
return uit;
};
const tijd = (iso) => (iso ? new Date(iso).toLocaleTimeString('nl-NL') : '');
const knop = (tekst, aan) => {
const el = document.createElement('button');
el.textContent = tekst;
el.addEventListener('click', async () => {
if (bezig) return;
bezig = true;
el.disabled = true;
try {
await aan();
} catch (oorzaak) {
alert(oorzaak.message);
} finally {
bezig = false;
el.disabled = false;
await ververs();
}
});
return el;
};
const kaartVoor = (eigenaar) => {
const kaart = document.createElement('div');
kaart.className = 'kaart';
const rij = document.createElement('div');
rij.className = 'rij';
const naam = document.createElement('span');
naam.className = 'naam';
naam.innerHTML = `<span class="stip${eigenaar.verbonden ? ' aan' : ''}"></span>`;
naam.append(eigenaar.naam);
rij.append(naam);
const toestand = document.createElement('span');
toestand.className = 'melding';
toestand.textContent = eigenaar.verbonden
? `${eigenaar.metRelay ? 'verbonden' : 'lokaal open'} sinds ${tijd(eigenaar.sinds)}${
eigenaar.blobs === null ? '' : ` · ${eigenaar.blobs} blob(s)`
}`
: 'niet verbonden';
rij.append(toestand);
if (eigenaar.test) {
const test = document.createElement('span');
test.className = eigenaar.test.geaccepteerd ? 'melding' : 'melding fout';
test.textContent = `getest ${tijd(eigenaar.test.tijd)}: ${eigenaar.test.status ?? '-'} ${eigenaar.test.uitleg}`;
rij.append(test);
}
const duw = document.createElement('div');
duw.className = 'duw';
const omvang = document.createElement('input');
omvang.value = omvangPer.get(eigenaar.naam) ?? '64';
omvang.title = 'Omvang van de blob in bytes';
omvang.style.width = '6em';
omvang.addEventListener('input', () => omvangPer.set(eigenaar.naam, omvang.value));
// Verbinden gaat uit zodra een test zegt dat deze eigenaar geweigerd
// wordt. Niet uit netheid: Evolu blijft bij een weigering eindeloos
// opnieuw proberen, en élke poging is een regel in de weigerlijst van de
// relay. Onbekend telt als toegestaan, want anders moet je eerst testen
// om iets te mogen, en dat is zelf ook een poging.
const verbindKnop = eigenaar.verbonden
? knop('Verbreken', () => roep('verbreek', { naam: eigenaar.naam }))
: knop('Verbinden', () => roep('verbind', { naam: eigenaar.naam }));
if (!eigenaar.verbonden && !eigenaar.magVerbinden) {
verbindKnop.disabled = true;
verbindKnop.title = 'De relay weigert deze eigenaar. Laat hem toe op de statuspagina van de app en test opnieuw.';
}
duw.append(
// Geen melding in een dialoogvenster: de uitkomst komt naast de naam te
// staan en in het log, en die blijven staan. Een dialoog moet je
// wegklikken en is daarna weg.
knop('Testen', () => roep('test', { naam: eigenaar.naam })),
verbindKnop,
omvang,
knop('Blob schrijven', () =>
roep('schrijf', { naam: eigenaar.naam, label: `blob ${tijd(new Date().toISOString())}`, bytes: omvang.value }),
),
knop('Lezen', async () => {
const uit = await roep('lees', { naam: eigenaar.naam });
toonRijen(kaart, uit.rijen);
}),
knop('Vergeten', async () => {
if (!confirm(`"${eigenaar.naam}" uit het register halen? De lokale database blijft staan.`)) return;
await roep('vergeet', { naam: eigenaar.naam });
}),
);
rij.append(duw);
kaart.append(rij);
const id = document.createElement('div');
id.className = 'id';
id.textContent = eigenaar.ownerId;
kaart.append(id);
return kaart;
};
const toonRijen = (kaart, rijen) => {
kaart.querySelector('table')?.remove();
const tabel = document.createElement('table');
if (rijen.length === 0) {
const cel = tabel.insertRow().insertCell();
cel.className = 'leeg';
cel.textContent = 'Nog geen blobs.';
}
for (const rij of rijen) {
const tr = tabel.insertRow();
tr.insertCell().textContent = rij.label;
const bytes = tr.insertCell();
bytes.className = 'getal';
bytes.textContent = `${rij.bytes} bytes`;
const id = tr.insertCell();
id.className = 'id';
id.textContent = rij.id;
}
kaart.append(tabel);
};
const ververs = async (altijd = false) => {
const ruw = await (await fetch('/api/status')).text();
if (!altijd && ruw === laatsteStaat) return;
laatsteStaat = ruw;
const staat = JSON.parse(ruw);
$('relay').textContent = staat.relayUrl ?? 'niet ingesteld';
$('waarschuwing').hidden = Boolean(staat.relayUrl);
const lijst = $('eigenaars');
lijst.textContent = '';
if (staat.eigenaars.length === 0) {
const leeg = document.createElement('div');
leeg.className = 'kaart leeg';
leeg.textContent = 'Nog geen eigenaars. Maak er hierboven een aan.';
lijst.append(leeg);
}
for (const eigenaar of staat.eigenaars) lijst.append(kaartVoor(eigenaar));
const log = $('log');
log.textContent = '';
if (staat.log.length === 0) {
log.className = 'kaart leeg';
log.textContent = 'Nog niets gebeurd.';
} else {
log.className = 'kaart';
for (const regel of staat.log) {
const el = document.createElement('div');
const t = document.createElement('time');
t.textContent = tijd(regel.tijd);
el.append(t, regel.tekst);
log.append(el);
}
}
};
$('nieuwKnop').addEventListener('click', async () => {
const naam = $('nieuwNaam').value.trim();
const melding = $('nieuwMelding');
melding.className = 'melding';
melding.textContent = '';
try {
const uit = await roep('nieuw', { naam });
$('nieuwNaam').value = '';
melding.textContent = `"${uit.naam}" aangemaakt.`;
await ververs();
} catch (oorzaak) {
melding.className = 'melding fout';
melding.textContent = oorzaak.message;
}
});
$('nieuwNaam').addEventListener('keydown', (gebeurtenis) => {
if (gebeurtenis.key === 'Enter') $('nieuwKnop').click();
});
void ververs();
// Verversen terwijl je met een knop bezig bent zou de kaart onder je muis
// vervangen, dus dat wordt overgeslagen.
setInterval(() => {
if (!bezig) void ververs();
}, 3000);
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Aankloppen bij de relay en de HTTP-statuscode teruggeven.
//
// Dit bestaat omdat Evolu een weigering niet van een relay die plat ligt
// onderscheidt: de cliënt is local-first en probeert het gewoon opnieuw. Voor
// een gereedschap dat juist de allowlist moet beproeven is dat het verschil
// tussen een proef en een gok; zie het plan Testclient, OPEN.md punt 1.
//
// Het kan zo goedkoop omdat de relay zijn beslissing bij de WebSocket-upgrade
// neemt en niet bij een bericht: het `OwnerId` staat in de URL, en een weigering
// is een HTTP 401 op de upgrade. Zie §10 van
// HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md.
//
// Met node:http en niet met de WebSocket van Node, en dat is de hele reden dat
// dit bestand er is: de WebSocket-API geeft je bij een weigering een `error` en
// geen statuscode, en 401 tegenover 404 tegenover "niets luistert" is precies
// wat je wilt weten.
// ═══════════════════════════════════════════════════════════════════════════════
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { randomBytes } from 'node:crypto';
/**
* De URL waarmee deze eigenaar zou verbinden.
*
* Dezelfde vorm die `createOwnerWebSocketTransport` maakt. Hij staat hier apart
* zodat er getoetst kan worden dat de twee niet uit elkaar lopen; loopt dat wel
* uit elkaar, dan klopt de proef niet en zie je dat nergens aan.
*/
export const upgradeUrl = (relayUrl, ownerId) => `${relayUrl}?ownerId=${ownerId}`;
/**
* Klopt aan en geeft terug wat er gebeurde.
*
* @returns {Promise<{status: number|null, geaccepteerd: boolean, uitleg: string}>}
*/
export const klopAan = (relayUrl, ownerId, { timeoutMs = 5000 } = {}) =>
new Promise((klaar) => {
let url;
try {
url = new URL(upgradeUrl(relayUrl, ownerId));
} catch {
klaar({ status: null, geaccepteerd: false, uitleg: `"${relayUrl}" is geen bruikbaar adres` });
return;
}
const beveiligd = url.protocol === 'wss:';
const verzoek = (beveiligd ? httpsRequest : httpRequest)({
hostname: url.hostname,
port: url.port || (beveiligd ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': randomBytes(16).toString('base64'),
},
});
let afgerond = false;
const antwoord = (uitkomst) => {
if (afgerond) return;
afgerond = true;
verzoek.destroy();
klaar(uitkomst);
};
// Een geslaagde upgrade komt níet als 'response' binnen maar als 'upgrade'.
// Dat is meteen het antwoord: de relay heeft deze eigenaar toegelaten.
verzoek.on('upgrade', () => {
antwoord({ status: 101, geaccepteerd: true, uitleg: 'toegelaten' });
});
verzoek.on('response', (res) => {
const status = res.statusCode ?? null;
antwoord({
status,
geaccepteerd: false,
uitleg:
status === 401
? 'geweigerd: deze eigenaar mag er niet in'
: `geweigerd met status ${status}`,
});
});
verzoek.on('error', (oorzaak) => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen verbinding: ${oorzaak.message}` });
});
verzoek.setTimeout(timeoutMs, () => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen antwoord binnen ${timeoutMs} ms` });
});
verzoek.end();
});
+157
View File
@@ -0,0 +1,157 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het register: welke eigenaars bestaan er, en hoe kom je erbij terug.
//
// Eén bestand, `owners.json`, met per eigenaar een naam, zijn mnemonic en zijn
// `OwnerId`. De mnemonic is het enige dat werkelijk bewaard hoeft te worden; het
// `OwnerId` staat erbij omdat je het nodig hebt om een eigenaar op de
// statuspagina van de app terug te vinden, en omdat je het dan kunt opzoeken
// zonder Evolu te starten.
//
// **Dit is sleutelmateriaal.** Het heeft buiten deze proefopstelling geen
// waarde, maar het bestand staat in .gitignore en het hoort daar te blijven; zie
// het plan Testclient, PLAN.md §4a.
//
// Alles hier is een gewone functie op een pad. Geen Evolu, geen netwerk: dat is
// wat dit het enige deel van de cliënt maakt dat zonder relay en zonder
// fixtures te toetsen is, en het is dezelfde scheiding die policy.js aan de
// relay-kant heeft.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Een naam is een herkenpunt voor jezelf en gaat nooit de deur uit.
*
* Toch begrensd, want hij belandt in een JSON-bestand en straks op een pagina.
* Letters, cijfers, streepje en liggend streepje; dat is genoeg om "trezor-mac"
* of "proef_2" te kunnen schrijven en het sluit een naam met een schuine streep
* erin uit, die er als pad uit zou zien.
*/
const NAAM_PATROON = /^[A-Za-z0-9_-]{1,40}$/;
export const isBruikbareNaam = (naam) => typeof naam === 'string' && NAAM_PATROON.test(naam);
const LEEG = { version: 1, owners: [] };
/**
* Leest het bestand. Geeft `null` bij iets wat niet klopt.
*
* Bewust streng, en om dezelfde reden als in policy.js: een half begrepen
* bestand is gevaarlijker dan geen bestand. Hier zou dat betekenen dat een
* eigenaar stil verdwijnt terwijl zijn mnemonic wél op schijf staat, en dan is
* de proef die eroverheen liep niet meer te herhalen.
*/
export const normaliseer = (ruw) => {
if (ruw === null || typeof ruw !== 'object' || Array.isArray(ruw)) return null;
if (ruw.version !== 1) return null;
if (!Array.isArray(ruw.owners)) return null;
const owners = [];
for (const eigenaar of ruw.owners) {
if (eigenaar === null || typeof eigenaar !== 'object') return null;
if (!isBruikbareNaam(eigenaar.naam)) return null;
if (typeof eigenaar.mnemonic !== 'string' || eigenaar.mnemonic.length === 0) return null;
if (typeof eigenaar.ownerId !== 'string' || eigenaar.ownerId.length === 0) return null;
if (owners.some((eerder) => eerder.naam === eigenaar.naam)) return null;
owners.push({
naam: eigenaar.naam,
mnemonic: eigenaar.mnemonic,
ownerId: eigenaar.ownerId,
aangemaakt: typeof eigenaar.aangemaakt === 'string' ? eigenaar.aangemaakt : null,
});
}
return { version: 1, owners };
};
/**
* Opent het register in `directory`, en maakt de map aan als hij er niet is.
*
* Een ontbrekend bestand is de begintoestand en geen fout. Een onleesbaar bestand
* wél: dat wordt gemeld en niet stil vervangen, want vervangen is hier hetzelfde
* als weggooien.
*/
export const openRegister = (directory) => {
mkdirSync(directory, { recursive: true });
const pad = join(directory, 'owners.json');
let staat;
try {
staat = normaliseer(JSON.parse(readFileSync(pad, 'utf8')));
} catch (oorzaak) {
if (oorzaak.code === 'ENOENT') {
staat = { ...LEEG, owners: [] };
} else {
throw new Error(`${pad} is niet te lezen: ${oorzaak.message}`);
}
}
if (staat === null) {
throw new Error(
`${pad} klopt niet. Er is niets vervangen; kijk er zelf naar, want hier staan mnemonics in.`,
);
}
// Schrijven gaat via een tijdelijk bestand en een hernoeming. Een half
// geschreven register kost mnemonics, en een hernoeming binnen dezelfde map is
// op elk besturingssysteem dat wij raken één handeling.
const bewaar = () => {
const tijdelijk = `${pad}.nieuw`;
writeFileSync(tijdelijk, `${JSON.stringify(staat, null, 2)}\n`, 'utf8');
renameSync(tijdelijk, pad);
};
return {
pad,
/** Alle eigenaars, in de volgorde waarin ze zijn aangemaakt. */
lijst: () => staat.owners.map((eigenaar) => ({ ...eigenaar })),
/** Eén eigenaar, of `null`. */
zoek: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
return gevonden ? { ...gevonden } : null;
},
/**
* Zet een eigenaar in het register.
*
* Gooit bij een naam die niet kan of al bestaat. Overschrijven zou de
* mnemonic van de vorige eigenaar weggooien, en dat is precies het soort
* stille schade waar het commentaar bovenaan dit bestand over gaat.
*/
voegToe: ({ naam, mnemonic, ownerId, aangemaakt }) => {
if (!isBruikbareNaam(naam)) {
throw new Error(`"${naam}" kan niet als naam. Letters, cijfers, - en _, hoogstens 40 tekens.`);
}
if (staat.owners.some((eigenaar) => eigenaar.naam === naam)) {
throw new Error(`Er is al een eigenaar "${naam}".`);
}
const nieuw = {
naam,
mnemonic,
ownerId,
aangemaakt: aangemaakt ?? new Date().toISOString(),
};
staat = { ...staat, owners: [...staat.owners, nieuw] };
bewaar();
return { ...nieuw };
},
/**
* Haalt een eigenaar uit het register. Gooit als hij er niet in staat.
*
* Let op wat dit níet doet: de database van die eigenaar blijft staan, en op
* de relay verandert er helemaal niets. Vergeten is hier vergeten, geen
* wissen; zie OPEN.md punt 3 en 4 van het plan.
*/
vergeet: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
if (!gevonden) throw new Error(`Geen eigenaar "${naam}".`);
staat = { ...staat, owners: staat.owners.filter((eigenaar) => eigenaar.naam !== naam) };
bewaar();
return { ...gevonden };
},
};
};
+312
View File
@@ -0,0 +1,312 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het bedieningsvlak: één pagina waarop je alle eigenaars ziet en aanstuurt.
//
// Wat dit scherm wél doet staat in het plan Testclient, PLAN.md §4b. Wat het
// NIET doet, en dat is geen tekortkoming maar de poortindeling van de app: de
// leerstand openzetten, iemand blokkeren en labelen. Die lopen via de agent-API
// van Evolu Relay, en die hangt achter de inlog van umbrelOS. Zet hier dus geen
// knoppen bij die dat proberen; ze kunnen niet werken.
//
// De server luistert op localhost en nergens anders. Hier zitten mnemonics
// achter, en dit is gereedschap op je eigen machine.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
import { klopAan } from './probe.js';
const HIER = dirname(fileURLToPath(import.meta.url));
const MAX_BODY_BYTES = 4096;
const MAX_LOG = 200;
export const startUi = async ({ config, register, databasesIn }) => {
/** De open verbindingen, per eigenaar. Alleen deze module raakt hem aan. */
const open = new Map();
const log = [];
/**
* De laatste uitkomst van "Testen", per eigenaar.
*
* Hierop wordt de knop "Verbinden" uitgezet. Onbekend telt als toegestaan, en
* dat is met opzet: anders moet je eerst testen voordat je iets mag, en een
* test is zelf een poging die in de weigerlijst van de relay belandt.
*/
const laatsteTest = new Map();
/**
* Hoeveel sockets er achter elkaar mislukt zijn, per eigenaar.
*
* Evolu is local-first en blijft eindeloos opnieuw proberen. Dat is bij een
* relay die even weg is precies goed, maar bij een eigenaar die geweigerd
* wordt is elke poging een regel in de weigerlijst van de relay. Vandaar de
* teller: na een paar mislukkingen wordt er één keer getest, en bij een
* weigering gaat de verbinding eruit in plaats van door te blijven kloppen.
*/
const socketFouten = new Map();
const FOUTEN_VOOR_TEST = 3;
const meld = (tekst) => {
log.unshift({ tijd: new Date().toISOString(), tekst });
if (log.length > MAX_LOG) log.length = MAX_LOG;
console.log(`${new Date().toLocaleTimeString()} ${tekst}`);
};
const verbind = async (naam, metRelay) => {
if (open.has(naam)) return open.get(naam);
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (metRelay && !config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const store = await openStore({
directory: databasesIn,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: metRelay ? config.relayUrl : null,
onDefect: (defect) => {
meld(`${naam}: defect uit een worker, zie de terminal`);
console.error(defect);
},
onWebSocket: (bericht) => {
if (bericht.wat === 'open') {
socketFouten.set(naam, 0);
meld(`${naam}: socket open`);
return;
}
if (bericht.wat !== 'dicht') return;
const aantal = (socketFouten.get(naam) ?? 0) + 1;
socketFouten.set(naam, aantal);
if (aantal !== FOUTEN_VOOR_TEST) return;
// Precies één keer testen, bij de derde mislukking. Daarna weten we of
// dit een weigering is of een relay die even weg is, en dat scheelt de
// relay een weigerlijst vol pogingen.
meld(`${naam}: ${aantal} sockets mislukt (${bericht.detail}), wordt getest`);
void (async () => {
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
if (uitkomst.geaccepteerd) {
meld(`${naam}: de relay laat hem toe, dus dit is iets anders dan een weigering`);
return;
}
meld(`${naam}: geweigerd (${uitkomst.uitleg}), verbinding wordt verbroken`);
await verbreek(naam);
})();
},
});
const ingang = { store, sinds: new Date().toISOString(), metRelay };
open.set(naam, ingang);
meld(metRelay ? `${naam} verbonden met ${store.transportUrl}` : `${naam} lokaal geopend`);
return ingang;
};
const verbreek = async (naam) => {
const ingang = open.get(naam);
if (!ingang) return;
open.delete(naam);
socketFouten.delete(naam);
await ingang.store.sluit();
meld(`${naam} losgekoppeld`);
};
const status = async () => {
const eigenaars = [];
for (const regel of register.lijst()) {
const ingang = open.get(regel.naam);
let blobs = null;
if (ingang) {
try {
blobs = (await ingang.store.lees()).length;
} catch {
blobs = null;
}
}
const test = laatsteTest.get(regel.naam) ?? null;
eigenaars.push({
naam: regel.naam,
ownerId: regel.ownerId,
aangemaakt: regel.aangemaakt,
verbonden: Boolean(ingang),
metRelay: ingang?.metRelay ?? false,
sinds: ingang?.sinds ?? null,
transportUrl: ingang?.store.transportUrl ?? null,
blobs,
test,
// Onbekend telt als toegestaan: eerst moeten testen om te mogen
// verbinden zou betekenen dat je altijd een poging in de weigerlijst
// van de relay zet voordat je iets kunt doen.
magVerbinden: test === null || test.geaccepteerd === true,
});
}
return { relayUrl: config.relayUrl, eigenaars, log };
};
// ── De afhandeling ──────────────────────────────────────────────────────────
const acties = {
nieuw: async ({ naam }) => {
const owner = nieuweEigenaar();
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
meld(`${regel.naam} aangemaakt, OwnerId ${regel.ownerId}`);
return { naam: regel.naam, ownerId: regel.ownerId };
},
verbind: async ({ naam }) => {
await verbind(naam, true);
return { verbonden: true };
},
verbreek: async ({ naam }) => {
await verbreek(naam);
return { verbonden: false };
},
schrijf: async ({ naam, label, bytes }) => {
const omvang = Number.parseInt(bytes ?? '64', 10);
if (!Number.isInteger(omvang) || omvang < 1 || omvang > 8 * 1024 * 1024) {
throw new Error('bytes moet tussen 1 en 8388608 liggen.');
}
// Schrijven opent zo nodig zelf een verbinding, dus dezelfde poort als bij
// de knop "Verbinden": een geweigerde eigenaar gaat lokaal verder in
// plaats van de weigerlijst van de relay te vullen.
const test = laatsteTest.get(naam);
const geweigerd = test != null && test.geaccepteerd === false;
const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl) && !geweigerd));
const body = new Uint8Array(omvang);
for (let i = 0; i < omvang; i += 1) body[i] = i % 256;
const geschreven = await ingang.store.schrijf(label || 'blob', body);
meld(`${naam}: blob van ${omvang} bytes weggeschreven (${geschreven.id})`);
return { id: geschreven.id, bytes: omvang };
},
lees: async ({ naam }) => {
const ingang = open.get(naam) ?? (await verbind(naam, false));
const rijen = await ingang.store.lees();
return {
rijen: rijen.map((rij) => ({
id: rij.id,
label: rij.label,
bytes: rij.body?.length ?? 0,
})),
};
},
test: async ({ naam }) => {
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (!config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
meld(`${naam}: testen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`);
return uitkomst;
},
vergeet: async ({ naam }) => {
await verbreek(naam);
laatsteTest.delete(naam);
const weg = register.vergeet(naam);
meld(`${weg.naam} uit het register gehaald`);
return { naam: weg.naam };
},
};
const leesBody = (verzoek) =>
new Promise((klaar, faal) => {
let ruw = '';
verzoek.on('data', (stuk) => {
ruw += stuk;
if (ruw.length > MAX_BODY_BYTES) {
faal(new Error('Verzoek te groot.'));
verzoek.destroy();
}
});
verzoek.on('end', () => {
try {
klaar(ruw === '' ? {} : JSON.parse(ruw));
} catch (oorzaak) {
faal(new Error(`Onleesbare JSON: ${oorzaak.message}`));
}
});
verzoek.on('error', faal);
});
const stuur = (antwoord, code, waarde) => {
const inhoud = JSON.stringify(waarde);
antwoord.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(inhoud),
});
antwoord.end(inhoud);
};
const server = createServer((verzoek, antwoord) => {
void (async () => {
const pad = (verzoek.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/';
if (verzoek.method === 'GET' && (pad === '/' || pad === '/index.html')) {
// Elke keer van schijf. Dit is gereedschap op je eigen machine, dus een
// pagina bijwerken en verversen hoort te werken zonder herstart.
const pagina = readFileSync(join(HIER, 'index.html'));
antwoord.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': pagina.length,
});
antwoord.end(pagina);
return;
}
if (verzoek.method === 'GET' && pad === '/api/status') {
stuur(antwoord, 200, await status());
return;
}
if (verzoek.method === 'POST' && pad.startsWith('/api/')) {
const actie = acties[pad.slice('/api/'.length)];
if (!actie) {
stuur(antwoord, 404, { fout: 'Onbekende actie.' });
return;
}
try {
stuur(antwoord, 200, await actie(await leesBody(verzoek)));
} catch (oorzaak) {
// 400 en niet 500: vrijwel alles wat hier misgaat is een naam die al
// bestaat of een relay die er niet is, en dat is geen serverfout.
stuur(antwoord, 400, { fout: oorzaak.message });
}
return;
}
stuur(antwoord, 404, { fout: 'Niet gevonden.' });
})();
});
await new Promise((klaar) => {
server.listen(config.uiPoort, '127.0.0.1', klaar);
});
const adres = `http://127.0.0.1:${config.uiPoort}`;
console.log('');
console.log(` Bedieningsvlak: ${adres}`);
console.log(` Relay: ${config.relayUrl ?? 'niet ingesteld (zie .env.sample)'}`);
console.log(` Register: ${register.pad}`);
console.log('');
console.log(' Ctrl-C om te stoppen.');
console.log('');
const stop = async () => {
console.log('');
console.log('Afsluiten...');
server.close();
for (const naam of [...open.keys()]) await verbreek(naam);
process.exit(0);
};
process.on('SIGINT', () => {
void stop();
});
await new Promise(() => {});
};