From af3a183cdad59bee61ddbf1b9873d5a5e7556cdb Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:05:30 +0000 Subject: [PATCH] browser environment --- rfcs/2026-02-03-browser-tests.md | 208 ++++++++++++++++++ xftp-web/package.json | 12 +- xftp-web/src/agent.ts | 15 +- xftp-web/src/client.ts | 19 +- ...browser-upload---download-round-trip-1.png | Bin 0 -> 2081 bytes xftp-web/test/browser.test.ts | 14 ++ xftp-web/test/globalSetup.ts | 149 +++++++++++++ xftp-web/vitest.config.ts | 25 +++ 8 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 rfcs/2026-02-03-browser-tests.md create mode 100644 xftp-web/test/__screenshots__/browser.test.ts/browser-upload---download-round-trip-1.png create mode 100644 xftp-web/test/browser.test.ts create mode 100644 xftp-web/test/globalSetup.ts create mode 100644 xftp-web/vitest.config.ts diff --git a/rfcs/2026-02-03-browser-tests.md b/rfcs/2026-02-03-browser-tests.md new file mode 100644 index 000000000..2e08a2efb --- /dev/null +++ b/rfcs/2026-02-03-browser-tests.md @@ -0,0 +1,208 @@ +# Plan: Browser ↔ Haskell File Transfer Tests + +## Table of Contents +1. Goal +2. Current State +3. Implementation +4. Success Criteria +5. Files +6. Order + +## 1. Goal +Run browser upload/download tests in headless Chromium via Vitest, proving fetch-based transport works in real browser environment. + +## 2. Current State +- `client.ts`: Transport abstraction done — http2 for Node, fetch for browser ✓ +- `agent.ts`: Uses `node:crypto` (randomBytes) and `node:zlib` (deflateRawSync/inflateRawSync) — **won't run in browser** +- `XFTPWebTests.hs`: Cross-language tests exist (Haskell calls TS via Node.js) ✓ + +## 3. Implementation + +### 3.1 Make agent.ts isomorphic + +| Current (Node.js only) | Isomorphic replacement | +|------------------------|------------------------| +| `import crypto from "node:crypto"` | Remove import | +| `import zlib from "node:zlib"` | `import pako from "pako"` | +| `crypto.randomBytes(32)` | `crypto.getRandomValues(new Uint8Array(32))` | +| `zlib.deflateRawSync(buf)` | `pako.deflateRaw(buf)` | +| `zlib.inflateRawSync(buf)` | `pako.inflateRaw(buf)` | + +Note: `crypto.getRandomValues` available in both browser and Node.js (globalThis.crypto). + +### 3.2 Vitest browser mode setup + +`package.json` additions: +```json +"devDependencies": { + "vitest": "^3.0.0", + "@vitest/browser": "^3.0.0", + "playwright": "^1.50.0", + "@types/pako": "^2.0.3" +}, +"dependencies": { + "pako": "^2.1.0" +} +``` + +`vitest.config.ts`: +```typescript +import {defineConfig} from 'vitest/config' +import {readFileSync} from 'fs' +import {createHash} from 'crypto' + +// Compute fingerprint from ca.crt (same as Haskell's loadFileFingerprint) +const caCert = readFileSync('../tests/fixtures/ca.crt') +const fingerprint = createHash('sha256').update(caCert).digest('base64url') +const serverAddr = `xftp://${fingerprint}@localhost:7000` + +export default defineConfig({ + define: { + 'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr) + }, + test: { + browser: { + enabled: true, + provider: 'playwright', + instances: [{browser: 'chromium'}], + headless: true, + providerOptions: { + launch: {ignoreHTTPSErrors: true} + } + }, + globalSetup: './test/globalSetup.ts' + } +}) +``` + +### 3.3 Server startup + +`test/globalSetup.ts`: +```typescript +import {spawn, ChildProcess} from 'child_process' +import {resolve, join} from 'path' +import {mkdtempSync, writeFileSync, copyFileSync} from 'fs' +import {tmpdir} from 'os' + +let server: ChildProcess | null = null + +export async function setup() { + const fixtures = resolve(__dirname, '../../tests/fixtures') + + // Create temp directories + const cfgDir = mkdtempSync(join(tmpdir(), 'xftp-cfg-')) + const logDir = mkdtempSync(join(tmpdir(), 'xftp-log-')) + const filesDir = mkdtempSync(join(tmpdir(), 'xftp-files-')) + + // Copy certificates to cfgDir (xftp-server expects ca.crt, server.key, server.crt there) + copyFileSync(join(fixtures, 'ca.crt'), join(cfgDir, 'ca.crt')) + copyFileSync(join(fixtures, 'server.key'), join(cfgDir, 'server.key')) + copyFileSync(join(fixtures, 'server.crt'), join(cfgDir, 'server.crt')) + + // Write INI config file + const iniContent = `[STORE_LOG] +enable: off + +[TRANSPORT] +host: localhost +port: 7000 + +[FILES] +path: ${filesDir} + +[WEB] +cert: ${join(fixtures, 'web.crt')} +key: ${join(fixtures, 'web.key')} +` + writeFileSync(join(cfgDir, 'file-server.ini'), iniContent) + + // Spawn xftp-server with env vars + server = spawn('cabal', ['exec', 'xftp-server', '--', 'start'], { + env: { + ...process.env, + XFTP_SERVER_CFG_PATH: cfgDir, + XFTP_SERVER_LOG_PATH: logDir + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + // Wait for "Listening on port 7000..." + await waitForServerReady(server) +} + +export async function teardown() { + server?.kill('SIGTERM') + await new Promise(r => setTimeout(r, 500)) +} + +function waitForServerReady(proc: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server start timeout')), 15000) + proc.stdout?.on('data', (data: Buffer) => { + if (data.toString().includes('Listening on port')) { + clearTimeout(timeout) + resolve() + } + }) + proc.stderr?.on('data', (data: Buffer) => { + console.error('[xftp-server]', data.toString()) + }) + proc.on('error', reject) + proc.on('exit', (code) => { + clearTimeout(timeout) + if (code !== 0) reject(new Error(`Server exited with code ${code}`)) + }) + }) +} +``` + +Server env vars (from `apps/xftp-server/Main.hs` + `getEnvPath`): +- `XFTP_SERVER_CFG_PATH` — directory containing `file-server.ini` and certs (`ca.crt`, `server.key`, `server.crt`) +- `XFTP_SERVER_LOG_PATH` — directory for logs + +### 3.4 Browser test + +`test/browser.test.ts`: +```typescript +import {test, expect} from 'vitest' +import {encryptFileForUpload, uploadFile, downloadFile} from '../src/agent.js' +import {parseXFTPServer} from '../src/protocol/address.js' + +const server = parseXFTPServer(import.meta.env.XFTP_SERVER) + +test('browser upload + download round-trip', async () => { + const data = new Uint8Array(50000) + crypto.getRandomValues(data) + const encrypted = encryptFileForUpload(data, 'test.bin') + const {rcvDescription} = await uploadFile(server, encrypted) + const {content} = await downloadFile(rcvDescription) + expect(content).toEqual(data) +}) +``` + +## 4. Success Criteria + +1. `npm run build` — agent.ts compiles without node: imports +2. `cabal test --test-option='--match=/XFTP Web Client/'` — existing Node.js tests still pass +3. `npm run test:browser` — browser round-trip test passes in headless Chromium + +## 5. Files to Create/Modify + +**Modify:** +- `xftp-web/package.json` — add vitest, @vitest/browser, playwright, pako, @types/pako +- `xftp-web/src/agent.ts` — replace node:crypto, node:zlib with isomorphic alternatives + +**Create:** +- `xftp-web/vitest.config.ts` — browser mode config +- `xftp-web/test/globalSetup.ts` — xftp-server lifecycle +- `xftp-web/test/browser.test.ts` — browser round-trip test + +## 6. Order of Implementation + +1. **Add pako dependency** — `npm install pako @types/pako` +2. **Make agent.ts isomorphic** — replace node:crypto, node:zlib +3. **Verify Node.js tests pass** — `cabal test --test-option='--match=/XFTP Web Client/'` +4. **Set up Vitest** — add devDeps, create vitest.config.ts +5. **Create globalSetup.ts** — write INI config, spawn xftp-server +6. **Write browser test** — upload + download round-trip +7. **Verify browser test passes** — `npm run test:browser` diff --git a/xftp-web/package.json b/xftp-web/package.json index 8ac5a54b8..628bf038d 100644 --- a/xftp-web/package.json +++ b/xftp-web/package.json @@ -8,15 +8,21 @@ "scripts": { "postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs", "build": "tsc", - "test": "node --experimental-vm-modules node_modules/.bin/jest" + "test": "node --experimental-vm-modules node_modules/.bin/jest", + "test:browser": "vitest" }, "devDependencies": { "@types/libsodium-wrappers-sumo": "^0.7.8", "@types/node": "^20.0.0", - "typescript": "^5.4.0" + "@types/pako": "^2.0.3", + "@vitest/browser": "^3.0.0", + "playwright": "^1.50.0", + "typescript": "^5.4.0", + "vitest": "^3.0.0" }, "dependencies": { "@noble/curves": "^1.9.7", - "libsodium-wrappers-sumo": "^0.7.13" + "libsodium-wrappers-sumo": "^0.7.13", + "pako": "^2.1.0" } } diff --git a/xftp-web/src/agent.ts b/xftp-web/src/agent.ts index 48a85bb3c..991919d5f 100644 --- a/xftp-web/src/agent.ts +++ b/xftp-web/src/agent.ts @@ -3,8 +3,7 @@ // Combines all building blocks: encryption, chunking, XFTP client commands, // file descriptions, and DEFLATE-compressed URI encoding. -import crypto from "node:crypto" -import zlib from "node:zlib" +import pako from "pako" import {encryptFile, encodeFileHeader} from "./crypto/file.js" import {generateEd25519KeyPair, encodePubKeyEd25519, encodePrivKeyEd25519, decodePrivKeyEd25519, ed25519KeyPairFromSeed} from "./crypto/keys.js" import {sha512} from "./crypto/digest.js" @@ -61,13 +60,13 @@ export interface DownloadResult { export function encodeDescriptionURI(fd: FileDescription): string { const yaml = encodeFileDescription(fd) - const compressed = zlib.deflateRawSync(Buffer.from(yaml)) - return base64urlEncode(new Uint8Array(compressed)) + const compressed = pako.deflateRaw(new TextEncoder().encode(yaml)) + return base64urlEncode(compressed) } export function decodeDescriptionURI(fragment: string): FileDescription { const compressed = base64urlDecode(fragment) - const yaml = zlib.inflateRawSync(Buffer.from(compressed)).toString() + const yaml = new TextDecoder().decode(pako.inflateRaw(compressed)) const fd = decodeFileDescription(yaml) const err = validateFileDescription(fd) if (err) throw new Error("decodeDescriptionURI: " + err) @@ -77,8 +76,10 @@ export function decodeDescriptionURI(fragment: string): FileDescription { // ── Upload ────────────────────────────────────────────────────── export function encryptFileForUpload(source: Uint8Array, fileName: string): EncryptedFileInfo { - const key = new Uint8Array(crypto.randomBytes(32)) - const nonce = new Uint8Array(crypto.randomBytes(24)) + const key = new Uint8Array(32) + const nonce = new Uint8Array(24) + crypto.getRandomValues(key) + crypto.getRandomValues(nonce) const fileHdr = encodeFileHeader({fileName, fileExtra: null}) const fileSize = BigInt(fileHdr.length + source.length) const payloadSize = Number(fileSize) + fileSizeLen + authTagSize diff --git a/xftp-web/src/client.ts b/xftp-web/src/client.ts index bb9eb5722..cff312f1d 100644 --- a/xftp-web/src/client.ts +++ b/xftp-web/src/client.ts @@ -86,8 +86,19 @@ function createBrowserTransport(baseUrl: string): Transport { // ── Connect + handshake ─────────────────────────────────────────── +// Browser HTTP/2 connections are pooled per origin — the server binds a session +// to the TLS connection, so a second handshake on the same connection fails. +// Cache clients by baseUrl in browser environments to reuse the session. +const browserClients = new Map() + export async function connectXFTP(server: XFTPServer): Promise { const baseUrl = "https://" + server.host + ":" + server.port + + if (!isNode) { + const cached = browserClients.get(baseUrl) + if (cached) return cached + } + const transport = await createTransport(baseUrl) try { @@ -118,7 +129,9 @@ export async function connectXFTP(server: XFTPServer): Promise { const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash})) if (ack.length !== 0) throw new Error("connectXFTP: non-empty handshake ack") - return {baseUrl, sessionId: hs.sessionId, xftpVersion, transport} + const client = {baseUrl, sessionId: hs.sessionId, xftpVersion, transport} + if (!isNode) browserClients.set(baseUrl, client) + return client } catch (e) { transport.close() throw e @@ -211,5 +224,7 @@ export async function pingXFTP(c: XFTPClient): Promise { // ── Close ───────────────────────────────────────────────────────── export function closeXFTP(c: XFTPClient): void { - c.transport.close() + // In the browser, HTTP/2 connections are pooled per origin — closing is + // a no-op since the connection persists and the session must stay cached. + if (isNode) c.transport.close() } diff --git a/xftp-web/test/__screenshots__/browser.test.ts/browser-upload---download-round-trip-1.png b/xftp-web/test/__screenshots__/browser.test.ts/browser-upload---download-round-trip-1.png new file mode 100644 index 0000000000000000000000000000000000000000..850d5b364ecb7abd932e8634bc40b802f9c6729f GIT binary patch literal 2081 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1N${k7srr_Id3i-3NkQo zFetMB4!f1z>jZpKSx4OZtDo-cm!S>~5byWJ>rG#o|~!Dwa}EeS?T#L? { + const data = new Uint8Array(50000) + crypto.getRandomValues(data) + const encrypted = encryptFileForUpload(data, 'test.bin') + const {rcvDescription} = await uploadFile(server, encrypted) + const {content} = await downloadFile(rcvDescription) + expect(content).toEqual(data) +}) diff --git a/xftp-web/test/globalSetup.ts b/xftp-web/test/globalSetup.ts new file mode 100644 index 000000000..0488e16a6 --- /dev/null +++ b/xftp-web/test/globalSetup.ts @@ -0,0 +1,149 @@ +import {spawn, execSync, ChildProcess} from 'child_process' +import {createHash} from 'crypto' +import {createConnection} from 'net' +import {resolve, join} from 'path' +import {readFileSync, mkdtempSync, writeFileSync, copyFileSync, existsSync, unlinkSync} from 'fs' +import {tmpdir} from 'os' + +const XFTP_PORT = 7000 +const LOCK_FILE = join(tmpdir(), 'xftp-test-server.pid') + +let server: ChildProcess | null = null +let isOwner = false + +export async function setup() { + // Vitest browser mode calls globalSetup twice; only the first should start the server + if (existsSync(LOCK_FILE)) { + const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10) + try { + process.kill(pid, 0) // check if process exists + } catch (_) { + // Stale lock from a crashed run — clean up + unlinkSync(LOCK_FILE) + // Kill any orphaned xftp-server on our port + try { execSync(`kill $(lsof -ti :${XFTP_PORT}) 2>/dev/null`, {stdio: 'ignore'}) } catch (_) {} + await new Promise(r => setTimeout(r, 500)) + } + } + if (existsSync(LOCK_FILE)) { + await waitForPort(XFTP_PORT) + return + } + writeFileSync(LOCK_FILE, String(process.pid)) + isOwner = true + + const fixtures = resolve(__dirname, '../../tests/fixtures') + + // Create temp directories + const cfgDir = mkdtempSync(join(tmpdir(), 'xftp-cfg-')) + const logDir = mkdtempSync(join(tmpdir(), 'xftp-log-')) + const filesDir = mkdtempSync(join(tmpdir(), 'xftp-files-')) + + // Copy certificates to cfgDir (xftp-server expects ca.crt, server.key, server.crt there) + copyFileSync(join(fixtures, 'ca.crt'), join(cfgDir, 'ca.crt')) + copyFileSync(join(fixtures, 'server.key'), join(cfgDir, 'server.key')) + copyFileSync(join(fixtures, 'server.crt'), join(cfgDir, 'server.crt')) + + // Write fingerprint file (checkSavedFingerprint reads this on startup) + // Fingerprint = SHA-256 of DER-encoded certificate (not PEM) + const pem = readFileSync(join(fixtures, 'ca.crt'), 'utf-8') + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64') + const fp = createHash('sha256').update(der).digest('base64').replace(/\+/g, '-').replace(/\//g, '_') + writeFileSync(join(cfgDir, 'fingerprint'), fp + '\n') + + // Write INI config file + const iniContent = `[STORE_LOG] +enable: off + +[TRANSPORT] +host: localhost +port: ${XFTP_PORT} + +[FILES] +path: ${filesDir} + +[WEB] +cert: ${join(fixtures, 'web.crt')} +key: ${join(fixtures, 'web.key')} +` + writeFileSync(join(cfgDir, 'file-server.ini'), iniContent) + + // Resolve binary path once (avoids cabal rebuild check on every run) + const serverBin = execSync('cabal -v0 list-bin xftp-server', {encoding: 'utf-8'}).trim() + + // Spawn xftp-server directly + server = spawn(serverBin, ['start'], { + env: { + ...process.env, + XFTP_SERVER_CFG_PATH: cfgDir, + XFTP_SERVER_LOG_PATH: logDir + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + server.stderr?.on('data', (data: Buffer) => { + console.error('[xftp-server]', data.toString()) + }) + + // Poll-connect until the server is actually listening + await waitForServerReady(server, XFTP_PORT) +} + +export async function teardown() { + if (isOwner) { + try { unlinkSync(LOCK_FILE) } catch (_) {} + if (server) { + server.kill('SIGTERM') + await new Promise(resolve => { + server!.on('exit', () => resolve()) + setTimeout(resolve, 3000) + }) + } + } +} + +function waitForServerReady(proc: ChildProcess, port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout(() => { + settled = true + reject(new Error('Server start timeout')) + }, 15000) + const settle = (fn: () => void) => { if (!settled) { settled = true; clearTimeout(timeout); fn() } } + proc.on('error', (e) => settle(() => reject(e))) + proc.on('exit', (code) => { + if (code !== 0) settle(() => reject(new Error(`Server exited with code ${code}`))) + }) + // printXFTPConfig prints "Listening on port" BEFORE bind, so poll-connect + const poll = () => { + if (settled) return + const sock = createConnection({port, host: 'localhost'}, () => { + sock.destroy() + settle(() => resolve()) + }) + sock.on('error', () => { + sock.destroy() + setTimeout(poll, 100) + }) + } + setTimeout(poll, 200) + }) +} + +function waitForPort(port: number): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + 15000 + const poll = () => { + if (Date.now() > deadline) return reject(new Error('Timed out waiting for server')) + const sock = createConnection({port, host: 'localhost'}, () => { + sock.destroy() + resolve() + }) + sock.on('error', () => { + sock.destroy() + setTimeout(poll, 100) + }) + } + poll() + }) +} diff --git a/xftp-web/vitest.config.ts b/xftp-web/vitest.config.ts new file mode 100644 index 000000000..f4b66a981 --- /dev/null +++ b/xftp-web/vitest.config.ts @@ -0,0 +1,25 @@ +import {defineConfig} from 'vitest/config' +import {readFileSync} from 'fs' +import {createHash} from 'crypto' + +// Compute fingerprint from ca.crt (SHA-256 of DER, same as Haskell's loadFileFingerprint) +const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8') +const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64') +const fingerprint = createHash('sha256').update(der).digest('base64').replace(/\+/g, '-').replace(/\//g, '_') +const serverAddr = `xftp://${fingerprint}@localhost:7000` + +export default defineConfig({ + define: { + 'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr) + }, + test: { + include: ['test/**/*.test.ts'], + browser: { + enabled: true, + provider: 'playwright', + instances: [{browser: 'chromium'}], + headless: true + }, + globalSetup: './test/globalSetup.ts' + } +})