browser environment

This commit is contained in:
Evgeny @ SimpleX Chat
2026-02-04 12:05:30 +00:00
parent 3eb6f40f54
commit af3a183cda
8 changed files with 430 additions and 12 deletions

View File

@@ -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<void> {
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`

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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<string, XFTPClient>()
export async function connectXFTP(server: XFTPServer): Promise<XFTPClient> {
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<XFTPClient> {
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<void> {
// ── 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()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,14 @@
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)
})

View File

@@ -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<void>(resolve => {
server!.on('exit', () => resolve())
setTimeout(resolve, 3000)
})
}
}
}
function waitForServerReady(proc: ChildProcess, port: number): Promise<void> {
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<void> {
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()
})
}

25
xftp-web/vitest.config.ts Normal file
View File

@@ -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'
}
})