SkateSpots/scripts/cordn-browser-probe.mjs
SkateSpots a9a5966714 Cordn native: browser probe, group-ref sharing, image messages
- scripts/cordn-browser-probe.mjs: headless-Chromium proof that the built
  native chunk runs in a real browser — imports the chunk, runs a session
  against the coordinator (publish kp, create group, send), then reopens from
  the Dexie/IndexedDB store. Not part of npm test (needs network + Chromium).
- Group ref: the panel shows/copies the group's cordn1... ref
  (encodeGroupRef from the crew coordinate + coordinator), so it can be opened
  in Staircase/cordn-web. Computed in the hook via the lazy native import.
- Images: attach button uploads to Blossom (useUploadFile) and sends the URL
  inside the encrypted MLS envelope (['image', url]); CordnChatMessage.imageUrl
  + ImageWithFallback rendering. Covered by the live test.
- Unread badge deliberately not built (needs a background signed request).

npm test + smoke + browser probe + 3 live scenarios all pass.
2026-10-12 22:42:44 +07:00

206 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Cordn-native browser probe.
*
* The Node live test proves the MLS logic; this proves the **browser runtime**
* of the same stack — the built native chunk actually loading in Chromium, then
* a real session against the coordinator over a browser WebSocket, with MLS
* crypto and the Dexie/IndexedDB store.
*
* It is deliberately NOT part of `npm test` (it needs network + Chromium):
*
* node scripts/cordn-browser-probe.mjs
*
* Exit 0 = the browser stack worked end to end.
*/
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import { existsSync, rmSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, extname, normalize } from 'node:path';
import { tmpdir } from 'node:os';
import net from 'node:net';
import { randomBytes } from 'node:crypto';
import WebSocket from 'ws';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = join(root, 'dist');
const assetsDir = join(distDir, 'assets');
const chromePath = process.env.CHROME_PATH || '/snap/bin/chromium';
const log = (m) => console.log(`[cordn-probe] ${m}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// The SkateSpots coordinator (public facts, same as src/lib/coordinator.ts).
const COORDINATOR_PUBKEY = '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693';
const COORDINATOR_RELAY = 'wss://relay.skate-spots.org';
if (!existsSync(join(distDir, 'index.html'))) {
console.error('[cordn-probe] dist/index.html missing — run `npm run build` first.');
process.exit(1);
}
if (!existsSync(chromePath)) {
console.error(`[cordn-probe] Chromium not found at ${chromePath}. Set CHROME_PATH.`);
process.exit(1);
}
// Find the lazy chunk that carries the native Cordn module (the barrel chunk —
// it contains the coordinator method strings and the store factory).
const assets = (await readdir(assetsDir)).filter((f) => f.endsWith('.js'));
let nativeChunk = null;
for (const f of assets) {
const text = await readFile(join(assetsDir, f), 'utf8');
if (text.includes('kp_publish') && text.includes('createCordnStore')) {
nativeChunk = f;
break;
}
}
if (!nativeChunk) {
console.error('[cordn-probe] could not find the native Cordn chunk in dist/assets (build it, and make sure something imports src/lib/cordn/native).');
process.exit(1);
}
log(`native chunk: ${nativeChunk}`);
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
'.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm',
};
const freePort = () => new Promise((res) => {
const s = net.createServer();
s.on('error', () => res(0));
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
});
const servePort = await freePort();
const server = createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
let filePath = join(distDir, normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
if (urlPath === '/' || !extname(filePath)) filePath = join(distDir, 'index.html');
let body;
try { body = await readFile(filePath); }
catch { filePath = join(distDir, 'index.html'); body = await readFile(filePath); }
res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(500); res.end('error');
}
});
await new Promise((r) => server.listen(servePort, '127.0.0.1', r));
const appUrl = `http://127.0.0.1:${servePort}/`;
const cdpPort = await freePort();
const profileDir = join(tmpdir(), `skatespots-cordn-probe-${process.pid}-${cdpPort}`);
const chrome = spawn(chromePath, [
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--disable-extensions', '--no-first-run', '--no-default-browser-check',
`--user-data-dir=${profileDir}`, `--remote-debugging-port=${cdpPort}`, appUrl,
], { stdio: ['ignore', 'ignore', 'pipe'] });
chrome.stderr.on('data', () => {});
const cleanup = () => {
try { chrome.kill('SIGKILL'); } catch {}
try { server.close(); } catch {}
try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
};
process.on('exit', cleanup);
async function findPageTarget() {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
const p = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
if (p) return p;
} catch {}
await sleep(300);
}
return null;
}
const target = await findPageTarget();
if (!target) { console.error('[cordn-probe] FAIL: no Chromium page target'); cleanup(); process.exit(1); }
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
let id = 0; const pend = new Map(); const exceptions = [];
ws.on('message', (d) => {
const m = JSON.parse(d);
if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); return; }
if (m.method === 'Runtime.exceptionThrown') {
const e = m.params.exceptionDetails;
exceptions.push(e.exception?.description || e.text || 'unknown exception');
}
});
const send = (method, params = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Page.enable');
// Wait for the app to mount so the module graph is warm.
for (let i = 0; i < 40; i++) {
const kids = (await send('Runtime.evaluate', { expression: 'document.getElementById("root")?.children.length ?? 0', returnByValue: true }))?.result?.result?.value;
if (kids > 0) break;
await sleep(300);
}
const privateKeyHex = randomBytes(32).toString('hex');
const accountKey = `probe-${Date.now()}`;
const groupId = `browser-probe-${Date.now()}`;
const probe = `(async () => {
const ns = await import('/assets/${nativeChunk}');
const m = ns.CordnSession ? ns : (ns.a || ns.default || ns);
const store = m.createCordnStore('${accountKey}');
const session = await m.CordnSession.open({
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
privateKeyHex: '${privateKeyHex}',
store,
});
await session.generateKeyPackage('probe');
await session.publishKeyPackage('probe');
const group = await session.createGroup({ groupId: '${groupId}', name: 'Browser probe' });
await session.send(group.gid, 'hello from headless chromium');
const local = session.messages(group.gid).map((x) => x.content);
await session.disconnect();
// Reopen with the same IndexedDB store: the group must restore.
const reopened = await m.CordnSession.open({
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
privateKeyHex: '${privateKeyHex}',
store: m.createCordnStore('${accountKey}'),
});
const restored = reopened.listGroups().length;
const restoredMsgs = reopened.listGroups()[0] ? reopened.messages(reopened.listGroups()[0].gid).length : 0;
await reopened.disconnect();
return { ok: true, pubkey: session.pubkey, gid: group.gid, local, restored, restoredMsgs };
})()`;
const result = await send('Runtime.evaluate', { expression: probe, returnByValue: true, awaitPromise: true });
const value = result?.result?.result?.value;
const thrown = result?.result?.exceptionDetails;
ws.close();
if (thrown) {
console.error('[cordn-probe] FAIL — the browser threw:');
console.error(' ' + (thrown.exception?.description || thrown.text));
process.exit(1);
}
if (exceptions.length > 0) {
console.error('[cordn-probe] FAIL — uncaught exceptions in the page:');
for (const e of exceptions) console.error(' • ' + e);
process.exit(1);
}
if (!value?.ok) {
console.error('[cordn-probe] FAIL — unexpected result:', JSON.stringify(value));
process.exit(1);
}
log(`PASS — native stack ran in Chromium (${COORDINATOR_RELAY})`);
log(` pubkey: ${value.pubkey}`);
log(` group: ${value.gid}`);
log(` sent locally: ${JSON.stringify(value.local)}`);
log(` restored (IDB): ${value.restored} group(s), ${value.restoredMsgs} message(s)`);
process.exit(0);