Workflow-App/Workflow/scripts/_cipc_service.js

351 lines
11 KiB
JavaScript
Raw Normal View History

// 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":"<full .py 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.
return new Promise((resolve, reject) => {
let stdout = '', stderr = '', killed = false;
let proc;
try {
proc = spawn(AGENT_BROWSER_EXE, ['--session', SESSION, ...args], {
windowsHide: true,
});
} catch (e) { return reject(e); }
const timer = setTimeout(() => {
killed = true;
try { proc.kill('SIGTERM'); } catch {}
}, timeoutMs);
proc.stdout.on('data', (d) => (stdout += d));
proc.stderr.on('data', (d) => (stderr += d));
proc.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
proc.on('close', (code) => {
clearTimeout(timer);
if (killed) return reject(new Error('agent-browser timeout'));
// agent-browser returns 0 even on bad JSON-eval results — the
// actual error message is encoded in the JSON-stringified stdout.
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
});
});
}
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.)