// scripts/_cipc_service.js // // Long-lived wrapper for the frozen CIPC BizPortal .py scripts. Architecture // A6 from the analysis: instead of Next.js `spawnSync`ing the .py per // request (which hangs the Node thread when BizPortal auth stalls), this // service owns one Python process at a time, keeps the agent-browser // session warm via a watchdog, and answers TCP requests from the dev // server. If the service is offline, the dev server falls back to the // legacy `spawnSync` path — so this is fully backwards-compatible. // // PROTOCOL (line-delimited JSON, one request per connection): // client → service: {"kind":"enterprise_no" | "director_id_no", // "query":"K2018435959" | "8709225206083"}\n // service → client: {"ok":true, "stdout":"", // "exitCode":0, // "duration_ms":73000}\n // or {"ok":false, "error":"..."}\n // // The service never modifies the .py files — it's a thin executor that: // 1. Ensures the agent-browser session is warm (logs in if not). // 2. Spawns the matching .py with one positional arg. // 3. Captures stdout/stderr. // 4. Returns the captured output for the route to parse + enrich. // // A 90-second hard timeout wraps the .py spawn so even if BizPortal // deadlocks the script, the client gets an error in <100s instead of // hanging forever. // // Watchdog: every 30s, checks the agent-browser URL; if it has decayed // away from bizprofile/user_profile, runs a login flow silently. const net = require('net'); const { spawn } = require('node:child_process'); const path = require('node:path'); const fs = require('node:fs'); const SCRIPTS_DIR = path.resolve(__dirname); const ENV_FILE = path.resolve(SCRIPTS_DIR, '..', '.env.local'); const VENV_PYTHON = path.join(SCRIPTS_DIR, '.venv', 'Scripts', 'python.exe'); const AGENT_BROWSER_EXE = 'C:\\Users\\SJ\\AppData\\Roaming\\npm\\node_modules\\agent-browser\\bin\\agent-browser-win32-x64.exe'; const SESSION = 'biz-search'; const SERVICE_HOST = '127.0.0.1'; const SERVICE_PORT = 6002; const PY_TIMEOUT_MS = 90_000; const WATCHDOG_INTERVAL_MS = 30_000; const LOG_PATH = path.join(SCRIPTS_DIR, '_cipc_service.log'); // Reads one key from the app's .env.local, so credentials stay out of source. function envValue(key) { try { for (const line of fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/)) { const t = line.trim(); if (t.startsWith(`${key}=`)) return t.slice(key.length + 1).trim(); } } catch (_) { // fall through to the explicit error below } throw new Error(`${key} not found in ${ENV_FILE}`); } // Login 3 — the one that was rotating successfully when Login 1 had its // subscription expire. Code and password both come from .env.local. const LOGIN_CREDS = { customer_code: envValue('CIPC_LOGIN3_CODE'), password: envValue('CIPC_LOGIN3_PASSWORD'), }; const LOGIN_URL = 'https://www.bizportal.gov.za/login.aspx'; const SCRIPTS = { enterprise_no: 'Bizportal Enterprise No. Search.py', director_id_no: 'Bizportal Directopr id no search.py', }; function log(level, msg, extra) { const line = `${new Date().toISOString()} [${level}] ${msg}` + (extra ? ' ' + JSON.stringify(extra) : ''); try { fs.appendFileSync(LOG_PATH, line + '\n'); } catch {} process.stdout.write(line + '\n'); } // ---------- Agent-browser helpers ---------- function abCmdParts(args) { return [AGENT_BROWSER_EXE, '--session', SESSION, ...args].map((c) => /[\s"]/.test(c) ? `"${c}"` : c ).join(' '); } function runAb(args, timeoutMs = 30_000) { // Runs a single agent-browser command by spawning the .exe directly // (no cmd.exe wrapper), which avoids quoting surprises on Windows. // Returns trimmed stdout; throws on timeout or spawn error. // // Why we settle on 'exit' rather than 'close': agent-browser is a // daemon-per-session CLI. When it has to START the session's browser // daemon (first call after a reboot, or after the daemon died), the new // daemon inherits the spawned CLI's stdout pipe. The pipe then never // reaches EOF, so Node's 'close' event never fires — even though the CLI // exited cleanly with code 0. Listening for 'close' used to permanently // wedge this mutex, so every later request queued forever and CIPC search // 500-ed for days with no log line to explain it. We now settle on 'exit' // after a short flush grace for stdio to drain, escalate the timeout kill // to SIGKILL, and guarantee an unconditional forced settle if neither fires // — so the promise cannot remain pending. return new Promise((resolve, reject) => { let stdout = '', stderr = '', settled = false, killEscalate = null; let proc; try { proc = spawn(AGENT_BROWSER_EXE, ['--session', SESSION, ...args], { windowsHide: true, }); } catch (e) { return reject(e); } const finish = (fn, val) => { if (settled) return; settled = true; clearTimeout(timer); if (killEscalate) clearTimeout(killEscalate); fn(val); }; const timer = setTimeout(() => { // SIGKILL maps to TerminateProcess on Windows and is a no-op if the // process has already exited, so we also schedule an unconditional // forced settle shortly after. try { proc.kill('SIGKILL'); } catch {} killEscalate = setTimeout( () => finish(reject, new Error(`agent-browser timeout (${timeoutMs}ms)`)), 2000 ); }, timeoutMs); proc.stdout.on('data', (d) => (stdout += d)); proc.stderr.on('data', (d) => (stderr += d)); proc.on('error', (e) => finish(reject, e)); proc.on('exit', (code) => { // 'close' can never fire reliably here (see header); give stdio a // brief moment to flush, then settle. agent-browser returns 0 even // on bad JSON-eval results — the actual error is in the stdout. setTimeout( () => finish(resolve, { stdout: stdout.trim(), stderr: stderr.trim(), code }), 250 ); }); }); } async function abEval(js, timeoutMs = 30_000) { const b64 = Buffer.from(js, 'utf-8').toString('base64'); const r = await runAb(['eval', '-b', b64], timeoutMs); // Strip surrounding JSON-string quotes if present. return r.stdout.replace(/^"|"$/g, ''); } // ---------- Login / warm-state management ---------- async function getUrl() { return abEval('window.location.href'); } async function isWarm() { try { const url = await getUrl(); return url.includes('bizprofile.aspx') || url.includes('user_profile.aspx'); } catch (e) { log('WARN', 'isWarm probe failed', { err: e.message }); return false; } } async function login() { log('INFO', 'logging in'); await runAb(['open', LOGIN_URL], 30_000); await sleep(4_000); await abEval( "document.getElementById('cntMain_chkId_ToggleButton').click(); 'T'" ); await sleep(2_000); const chk = await abEval( "!!document.getElementById('cntMain_txtCustCode') ? 'OK' : 'MISSING'" ); if (!chk.includes('OK')) { await abEval( "document.getElementById('cntMain_chkId_ToggleButton').click(); 'T2'" ); await sleep(2_000); } const js = [ "var el=document.getElementById('cntMain_txtCustCode');", `el.value='${LOGIN_CREDS.customer_code}';`, "el.dispatchEvent(new Event('input',{bubbles:true}));", "var el2=document.getElementById('cntMain_txtPassword');", `el2.value='${LOGIN_CREDS.password}';`, "el2.dispatchEvent(new Event('input',{bubbles:true}));", "document.getElementById('cntMain_btnLogin').click();", "'L'", ].join(''); await abEval(js); await sleep(18_000); const url = await getUrl(); log('INFO', 'login flow complete', { url: url.replace(/^"|"$/g, '') }); return url; } // ---------- Serialised mutex so watchdog + requests don't race ---------- let inFlight = false; const queue = []; async function serialised(fn) { return new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); drain(); }); } async function drain() { if (inFlight || queue.length === 0) return; inFlight = true; const { fn, resolve, reject } = queue.shift(); try { resolve(await fn()); } catch (e) { reject(e); } finally { inFlight = false; setImmediate(drain); } } async function ensureWarm() { return serialised(async () => { if (await isWarm()) return { warmed: false }; log('INFO', 'session cold, logging in'); await login(); return { warmed: true }; }); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } // ---------- .py execution ---------- function runPy(scriptName, arg) { const scriptPath = path.join(SCRIPTS_DIR, scriptName); // Spawn the python binary directly with the .py and query as positional // args — no cmd.exe shell, no quoting dance. This sidesteps the Windows // cmd-line escaping surprises we'd hit with `cmd /d /s /c "python.exe ..."`. return new Promise((resolve) => { const start = Date.now(); let stdout = '', stderr = '', killedByTimeout = false; let proc; try { proc = spawn(VENV_PYTHON, [scriptPath, arg], { cwd: SCRIPTS_DIR, windowsHide: true, }); } catch (e) { return resolve({ ok: false, error: `spawn failed: ${e.message}` }); } const timer = setTimeout(() => { killedByTimeout = true; try { proc.kill('SIGTERM'); } catch {} }, PY_TIMEOUT_MS); proc.stdout.on('data', (d) => (stdout += d)); proc.stderr.on('data', (d) => (stderr += d)); proc.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: `spawn error: ${e.message}` }); }); proc.on('close', (code) => { clearTimeout(timer); const duration = Date.now() - start; if (killedByTimeout) { resolve({ ok: false, error: `python timeout (${PY_TIMEOUT_MS}ms)`, duration_ms: duration, stdout, stderr, }); } else { resolve({ ok: code === 0, exitCode: code, stdout, stderr, duration_ms: duration, }); } }); }); } // ---------- TCP server ---------- async function handleRequest(req) { const { kind, query } = req || {}; if (!SCRIPTS[kind]) return { ok: false, error: `unknown kind: ${kind}` }; if (typeof query !== 'string' || !query.trim()) { return { ok: false, error: 'missing query' }; } const warmResult = await ensureWarm(); const scriptName = SCRIPTS[kind]; const pyResult = await runPy(scriptName, query); if (!pyResult.ok) { return { ok: false, error: pyResult.error || `python exited ${pyResult.exitCode}`, stdout: pyResult.stdout, stderr: pyResult.stderr, duration_ms: pyResult.duration_ms, warmed_up: warmResult.warmed, }; } return { ok: true, stdout: pyResult.stdout, exitCode: pyResult.exitCode, duration_ms: pyResult.duration_ms, warmed_up: warmResult.warmed, }; } function singleConnection(socket) { let buf = ''; socket.on('data', (chunk) => { buf += chunk.toString('utf-8'); let idx; while ((idx = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, idx); buf = buf.slice(idx + 1); if (!line) continue; handleRequestLine(line, socket); return; // one request per connection } }); socket.on('error', () => {}); socket.on('close', () => {}); } async function handleRequestLine(line, socket) { let req; try { req = JSON.parse(line); } catch (e) { socket.write(JSON.stringify({ ok: false, error: 'invalid json' }) + '\n'); return socket.end(); } try { const res = await handleRequest(req); socket.write(JSON.stringify(res) + '\n'); } catch (e) { socket.write(JSON.stringify({ ok: false, error: e.message || String(e) }) + '\n'); } finally { socket.end(); } } net.createServer(singleConnection).listen(SERVICE_PORT, SERVICE_HOST, () => { log('INFO', `service listening on ${SERVICE_HOST}:${SERVICE_PORT}`); }); // Watchdog: keep the session warm silently. setInterval(() => { serialised(async () => { if (!(await isWarm())) { log('INFO', 'watchdog: session decayed, re-logging in'); try { await login(); log('INFO', 'watchdog: re-login complete'); } catch (e) { log('ERROR', 'watchdog login failed', { err: e.message }); } } }).catch((e) => log('ERROR', 'watchdog tick crashed', { err: e.message })); }, WATCHDOG_INTERVAL_MS); log('INFO', 'service starting', { python: VENV_PYTHON, scripts_dir: SCRIPTS_DIR, watchdog_ms: WATCHDOG_INTERVAL_MS, py_timeout_ms: PY_TIMEOUT_MS, }); // Hard refuse to run twice — concurrency would corrupt the BizPortal session. // (Linux would use a flock; on Windows we use a TCP-port-already-bound check // via net.createServer().listen — calling .listen twice yields EADDRINUSE.)