// SX Site Renderer — OCaml subprocess driver for sandboxed page testing. // Communicates via epoch protocol (stdin/stdout), no HTTP server needed. // Usage: // const renderer = new SxRenderer(projectRoot); // await renderer.ready(); // const urls = await renderer.navUrls(); // [["href","label"], ...] // const html = await renderer.render(url); // complete HTML string // renderer.close(); const { spawn } = require('child_process'); const path = require('path'); class SxRenderer { constructor(projectRoot) { this.projectRoot = projectRoot; this.epoch = 0; this.pending = null; this.chunks = []; // Buffer chunks — avoids O(n²) string concat this.bufferLen = 0; // total bytes across chunks this.readyResolve = null; const exe = path.join(projectRoot, 'hosts/ocaml/_build/default/bin/sx_server.exe'); this.proc = spawn(exe, ['--site'], { cwd: projectRoot, env: { ...process.env, SX_PROJECT_DIR: projectRoot }, stdio: ['pipe', 'pipe', 'pipe'], }); this.proc.stdout.on('data', (chunk) => this._onData(chunk.toString())); // Drain stderr to prevent pipe deadlock with large stdout writes. // OCaml writes JIT logs to stderr during render — if the stderr pipe // fills up, stdout writes block and we deadlock. this.stderrBuf = ''; this.proc.stderr.on('data', (chunk) => { this.stderrBuf += chunk.toString(); }); this.proc.on('error', (err) => { if (this.pending) { this.pending.reject(new Error(`subprocess error: ${err.message}`)); this.pending = null; } }); this.proc.on('exit', (code) => { if (this.pending) { this.pending.reject(new Error(`subprocess exited with code ${code}`)); this.pending = null; } }); } /** Wait for the subprocess to finish loading all .sx files. */ ready() { return new Promise((resolve) => { this.readyResolve = resolve; }); } /** Render a page URL to complete HTML. */ async render(urlPath) { const escaped = urlPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return this._send(`(render-page "${escaped}")`); } /** Get all nav URLs as [[href, label], ...]. */ async navUrls() { const raw = await this._send('(nav-urls)'); return this._parsePairList(raw); } /** Evaluate an SX expression. */ async eval(expr) { return this._send(`(eval "${expr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`); } /** Get all page test specs as [[url, {has-text: [...], ...}], ...]. */ async pageTestSpecs() { const raw = await this.eval('(map (fn (k) (list k (get page-test-specs k))) (keys page-test-specs))'); return this._parsePageSpecs(raw); } /** Parse page test specs from SX. Returns Map. */ _parsePageSpecs(sx) { const specs = new Map(); // Each entry: ("url" {:has-text ("a" "b") :has-island ("c")}) const entryRe = /\("([^"]+)"\s+\{([^}]*)\}\)/g; let m; while ((m = entryRe.exec(sx))) { const url = m[1]; const body = m[2]; const spec = {}; const textMatch = body.match(/:has-text\s+\(([^)]*)\)/); if (textMatch) { spec.hasText = [...textMatch[1].matchAll(/"([^"]*)"/g)].map(x => x[1]); } const islandMatch = body.match(/:has-island\s+\(([^)]*)\)/); if (islandMatch) { spec.hasIsland = [...islandMatch[1].matchAll(/"([^"]*)"/g)].map(x => x[1]); } specs.set(url, spec); } return specs; } /** Kill the subprocess. */ close() { if (this.proc) { this.proc.kill(); this.proc = null; } } // --- internal --- _send(command) { return new Promise((resolve, reject) => { if (this.pending) { reject(new Error('concurrent send not supported')); return; } this.epoch++; this.pending = { epoch: this.epoch, resolve, reject }; this.proc.stdin.write(`(epoch ${this.epoch})\n${command}\n`); // Response may already be in buffer from a previous stdout chunk process.nextTick(() => this._tryResolve()); }); } _onData(chunk) { this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); this.bufferLen += chunk.length; // Check for (ready) — startup complete if (this.readyResolve) { const buf = this._peekBuffer(); const idx = buf.indexOf('(ready)'); if (idx !== -1) { this._consumeBytes(idx + 8); // skip (ready)\n this.readyResolve(); this.readyResolve = null; } } if (this.pending) this._tryResolve(); } /** Merge all chunks into a single Buffer for parsing. */ _peekBuffer() { if (this.chunks.length === 1) return this.chunks[0]; const merged = Buffer.concat(this.chunks); this.chunks = [merged]; return merged; } /** Remove the first n bytes from the buffer. */ _consumeBytes(n) { const buf = this._peekBuffer(); if (n >= buf.length) { this.chunks = []; this.bufferLen = 0; } else { this.chunks = [buf.slice(n)]; this.bufferLen = buf.length - n; } } _tryResolve() { if (!this.pending) return; const ep = this.pending.epoch; const buf = this._peekBuffer(); const str = buf.toString('utf8', 0, Math.min(buf.length, 40)); // just the header area // ok-len: length-prefixed binary response const lenPrefix = `(ok-len ${ep} `; const lenIdx = str.indexOf(lenPrefix); if (lenIdx !== -1) { const afterPrefix = lenIdx + lenPrefix.length; const closeParen = str.indexOf(')', afterPrefix); if (closeParen === -1) return; // incomplete header const n = parseInt(str.slice(afterPrefix, closeParen), 10); const dataStart = closeParen + 2; // skip ")\n" const dataEnd = dataStart + n; if (buf.length < dataEnd) return; // incomplete data — wait for more const data = buf.toString('utf8', dataStart, dataEnd); this._consumeBytes(dataEnd + 1); // skip trailing \n const { resolve } = this.pending; this.pending = null; resolve(data); return; } // ok: simple response (single line) const fullStr = buf.toString('utf8'); const okPrefix = `(ok ${ep} `; const okIdx = fullStr.indexOf(okPrefix); if (okIdx !== -1) { const eol = fullStr.indexOf('\n', okIdx); if (eol === -1) return; const line = fullStr.slice(okIdx + okPrefix.length, eol - 1); this._consumeBytes(eol + 1); const { resolve } = this.pending; this.pending = null; resolve(line); return; } // error const errPrefix = `(error ${ep} `; const errIdx = fullStr.indexOf(errPrefix); if (errIdx !== -1) { const eol = fullStr.indexOf('\n', errIdx); if (eol === -1) return; const msg = fullStr.slice(errIdx + errPrefix.length, eol - 1); this._consumeBytes(eol + 1); const { reject } = this.pending; this.pending = null; reject(new Error(`SX error: ${msg}`)); return; } } /** Parse an SX list of pairs: (("a" "b") ("c" "d")) → [["a","b"], ["c","d"]] */ _parsePairList(sx) { const pairs = []; const re = /\("([^"\\]*(?:\\.[^"\\]*)*)"\s+"([^"\\]*(?:\\.[^"\\]*)*)"\)/g; let m; while ((m = re.exec(sx))) { pairs.push([m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'), m[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\')]); } return pairs; } } module.exports = { SxRenderer };