// server.js — robust HTTP→TCP bridge for ABS (ABS_POLL, LOG, BTI, RPI) // Node 18+ (CommonJS) const express = require("express"); const net = require("net"); const ABS_HOST = process.env.ABS_HOST || "192.0.0.14"; const ABS_PORT = Number(process.env.ABS_PORT || 7000); const ABS_POLL = 178; const LOG = 100; const LOGBT = 6013; const BTI = 6014; const RPI = 6015; const SUCCESS = 0; const PKT_SIZE = 512; const PAYLOAD_PER_PKT = PKT_SIZE - 1; const DEFAULT_TIMEOUT_MS = Number(process.env.BRIDGE_TIMEOUT_MS || 20000); const RETRIES = Number(process.env.BRIDGE_RETRIES || 2); const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500); const app = express(); function readJsonBody(req) { return new Promise((resolve) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { if (!chunks.length) return resolve({}); const txt = Buffer.concat(chunks).toString("utf8").trim(); if (!txt) return resolve({}); try { resolve(JSON.parse(txt)); } catch { resolve({}); } }); }); } // ---------- helpers ---------- function writeFixedAscii(buf, offset, s, len) { const str = (s ?? "").toString(); for (let i = 0; i < len; i++) buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00; return offset + len; } function readFixedAscii(buf, offset, len) { let end = offset; const max = offset + len; while (end < max && buf[end] !== 0) end++; const raw = buf.subarray(offset, end).toString("ascii"); return { value: raw.trimEnd(), next: offset + len }; } function hexSlice(buf, off, len) { const s = Math.max(0, off); const e = Math.min(buf.length, off + len); return buf.subarray(s, e).toString("hex"); } function remaining(buf, off){ return Math.max(0, buf.length - off); } // ---------- packetization ---------- function absSend(sock, payload) { return new Promise((resolve, reject) => { let off = 0; function sendNext() { const remaining = payload.length - off; const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0)); const pkt = Buffer.alloc(PKT_SIZE, 0); pkt[0] = (off + toCopy >= payload.length) ? 48 : 49; if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy); off += toCopy; sock.write(pkt, (err) => { if (err) return reject(err); if (pkt[0] === 48) return resolve(); sendNext(); }); } if (!payload || payload.length === 0) { const pkt = Buffer.alloc(PKT_SIZE, 0); pkt[0] = 48; sock.write(pkt, (err) => err ? reject(err) : resolve()); } else { sendNext(); } }); } function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) { return new Promise((resolve, reject) => { const chunks = []; let buf = Buffer.alloc(0); const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null; function done(err) { if (timer) clearTimeout(timer); sock.off("data", onData); if (err) reject(err); else resolve(Buffer.concat(chunks)); } function onData(data) { buf = Buffer.concat([buf, data]); while (buf.length >= PKT_SIZE) { const pkt = buf.subarray(0, PKT_SIZE); buf = buf.subarray(PKT_SIZE); const flag = pkt[0]; chunks.push(pkt.subarray(1)); if (flag === 48) return done(); } } sock.on("data", onData); sock.on("error", (e) => done(e)); sock.on("end", () => done(new Error("Socket ended before final '0' packet"))); }); } async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) { const sock = new net.Socket(); await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej)); try { await absSend(sock, payload || Buffer.alloc(0)); const replyConcat = await absRecv(sock, timeoutMs); sock.end(); return replyConcat; } catch (e) { try { sock.destroy(); } catch {} throw e; } } async function withRetry(fn, tries = RETRIES, delayMs = RETRY_DELAY_MS) { let lastErr; for (let i=0;isetTimeout(r, delayMs)); } } throw lastErr; } // ---------- MFC-style password "encryption" ---------- function encryptPasswordLikeMFC(plain) { const sec = new Date().getUTCSeconds(); const key = Number(String(sec)[0] || "0"); let out = ""; const s = (plain ?? "").toString(); for (let i = 0; i < s.length; i++) { const ch = s[i]; const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0; out += String((d + key) % 10); } return { enc: out + String(key), key, utcSeconds: sec }; } // ---------- headers ---------- function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) { const b = Buffer.alloc(24, 0); let o = 0; b.writeInt32LE(nTxnCd, o); o += 4; b.writeInt32LE(nOpCd, o); o += 4; b.writeInt32LE(nNumRecsSent, o); o += 4; b.writeInt32LE(nNumRecsRqrd, o); o += 4; b.writeInt32LE(nTxnId, o); o += 4; b.writeUInt8(cBtMake & 0xff, o); return b; } /** Smart header parser: test 18 & 24, choose plausible. */ function parseRcvHeaderFlexible(buf) { if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`); function readAs(len) { let o = 0; const nTxnCd = buf.readInt32LE(o); o += 4; const nRetCd = buf.readInt32LE(o); o += 4; const nNumRecs = buf.readInt32LE(o); o += 4; const nTxnId = buf.readInt32LE(o); o += 4; const cBtMake = buf.readUInt8(o); return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: len }; } const h18 = readAs(18); const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000); let h24=null, ok24=false; if (buf.length >= 24) { h24 = readAs(24); ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000); } if (ok18 && !ok24) return h18; if (!ok18 && ok24) return h24; if (ok18 && ok24) return h18; // prefer 18 to avoid over-advancing return h18; } function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) { const b = Buffer.alloc(18, 0); let o = 0; b.writeInt32LE(nTxnCd, o); o += 4; b.writeInt32LE(nRetCd, o); o += 4; b.writeInt32LE(nNumRecs, o); o += 4; b.writeInt32LE(nTxnId, o); o += 4; b.writeUInt8(cBtMake & 0xff, o); return b; } // ---------- aligners / detectors ---------- function looksLikeHeaderAt(buf, off){ if (remaining(buf, off) < 18) return false; try { const nTxnCd = buf.readInt32LE(off); return (nTxnCd >= 50 && nTxnCd < 10000); } catch { return false; } } /** align to plausible header; try +0 / +4 */ function alignToPossibleHeader(reply, off) { if (looksLikeHeaderAt(reply, off)) return off; if (remaining(reply, off) >= 22 && looksLikeHeaderAt(reply, off + 4)) return off + 4; return off; } /** align to where ASCII year "20" begins for login body */ function alignToAsciiYear(reply, off) { const tryOffsets = [0, 2, 4, -4]; for (const d of tryOffsets) { const o = off + d; if (o >= 0 && o + 1 < reply.length) { const b0 = reply[o], b1 = reply[o+1]; if (b0 === 0x32 && b1 === 0x30) return o; // '2''0' if (reply[o] === 0x2f && o >= 4 && reply[o-4] === 0x32 && reply[o-3] === 0x30) return o-4; } } return off; } function adjustForOptionalOp(replyBuf, bodyOffset) { if (replyBuf.length >= bodyOffset + 4) { const maybeOp = replyBuf.readInt32LE(bodyOffset); if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) { return { offset: bodyOffset + 4, extraOp: maybeOp }; } else { const looksLikeYear = replyBuf[bodyOffset] === 0x32 && replyBuf[bodyOffset + 1] === 0x30; const looksLikeYearFwd = replyBuf[bodyOffset + 4] === 0x32 && replyBuf[bodyOffset + 5] === 0x30; if (!looksLikeYear && looksLikeYearFwd) return { offset: bodyOffset + 4, extraOp: null }; } } if (bodyOffset >= 4 && replyBuf[bodyOffset] === 0x2f) { const couldBeYear = replyBuf[bodyOffset - 4] === 0x32 && replyBuf[bodyOffset - 3] === 0x30; if (couldBeYear) return { offset: bodyOffset - 4, extraOp: null }; } return { offset: bodyOffset, extraOp: null }; } function safeAdvanceOrBreak(buf, off, want, outObj){ if (remaining(buf, off) < want){ outObj._truncated = true; return false; } return true; } // ---------- parsers ---------- function packSndLog({ usrId = "", opCard, passwd, btId }) { const b = Buffer.alloc(38, 0); let o = 0; o = writeFixedAscii(b, o, usrId, 5); o = writeFixedAscii(b, o, opCard, 17); o = writeFixedAscii(b, o, passwd, 11); o = writeFixedAscii(b, o, btId, 5); return b; } function parseRcvLog(buf, offset = 0) { let o = offset; const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next; const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next; const t3 = readFixedAscii(buf, o, 5 ); const cUsrId = t3.value; o = t3.next; const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next; const t5 = readFixedAscii(buf, o, 5 ); const cSupId = t5.value; o = t5.next; const t6 = readFixedAscii(buf, o, 5 ); const cUsrTyp = t6.value; o = t6.next; function rf() { const v = buf.readFloatLE(o); o += 4; return v; } function ri() { const v = buf.readInt32LE(o); o += 4; return v; } const fOpenBal = rf(); const fTktSalesByVoucher = rf(); const fTktSalesByCash = rf(); const fTktSalesByMemCard = rf(); const nTktSalesByVoucherCount = ri(); const nTktSalesByCashCount = ri(); const nTktSalesByMemCardCount = ri(); const fPayoutByVoucher = rf(); const fPayoutByCash = rf(); const fPayoutByMemCard = rf(); const nPayoutByVoucherCount = ri(); const nPayoutByCashCount = ri(); const nPayoutByMemCardCount = ri(); const fCancelByVoucher = rf(); const fCancelByCash = rf(); const fCancelByMemCard = rf(); const nCancelByVoucherCount = ri(); const nCancelByCashCount = ri(); const nCancelByMemCardCount = ri(); const fDeposit = rf(); const fWithdrawAmt = rf(); const fVoucherSales = rf(); const fVoucherEncash = rf(); const fCloseBal = rf(); const fSaleTarget = rf(); return { cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp, fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard, nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount, fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard, nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount, fCancelByVoucher, fCancelByCash, fCancelByMemCard, nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount, fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash, fCloseBal, fSaleTarget, size: o - offset }; } // function parseRcvBtiMst(buf, offset) { // let o = offset; // const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next; // const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next; // const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next; // const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next; // const cBtMode = String.fromCharCode(buf[o]); o += 1; // const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next; // // Debug: raw hex of these fields // console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex")); // const fMinSaleBet = buf.readInt32LE(o); o += 4; // console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex")); // const fMaxSaleBet = buf.readInt32LE(o); o += 4; // console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex")); // const fPayMaxAmt = buf.readInt32LE(o); o += 4; // return { // cBtId, cBtName, cEnclCd, cGrpCd, // cBtMode, cMoneyTyp, // fMinSaleBet, fMaxSaleBet, fPayMaxAmt, // size: o - offset // }; // } function parseRcvBtiMst(buf, offset) { let o = offset; const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next; const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next; const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next; const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next; const cBtMode = String.fromCharCode(buf[o]); o += 1; const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next; // align to 4-byte boundary (for float fields) o = (o + 3) & ~3; console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex")); const fMinSaleBet = buf.readFloatLE(o); o += 4; console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex")); const fMaxSaleBet = buf.readFloatLE(o); o += 4; console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex")); const fPayMaxAmt = buf.readFloatLE(o); o += 4; return { cBtId, cBtName, cEnclCd, cGrpCd, cBtMode, cMoneyTyp, fMinSaleBet, fMaxSaleBet, fPayMaxAmt, size: o - offset }; } function parseRcvBtiDtl(buf, offset) { let o = offset; const cDtlTyp = String.fromCharCode(buf[o]); o += 1; // If not enough bytes to try the happy path, take what's available if (remaining(buf, o) < 4) { const avail = Math.max(0, remaining(buf, o)); const aRem = readFixedAscii(buf, o, avail); const cPoolOrVenue = aRem.value; o = aRem.next; const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : ""; if (o < buf.length) o += 1; return { cDtlTyp, cPoolOrVenue, cDtlSts, size: o - offset }; } // Try 3-byte pool/venue first (most common) const a3 = readFixedAscii(buf, o, 3); const cPoolOrVenue3 = a3.value; const after3 = a3.next; // Candidate status byte (must be printable ASCII — typical statuses are letters/digits) const statusCandidate = buf[after3]; const isPrintable = (statusCandidate >= 0x20 && statusCandidate <= 0x7E); if (isPrintable) { o = after3; const cDtlSts = String.fromCharCode(buf[o]); o += 1; return { cDtlTyp, cPoolOrVenue: cPoolOrVenue3, cDtlSts, size: o - offset }; } // Fallback: read 4-byte pool/venue (older/odd layouts) const a4 = readFixedAscii(buf, o, 4); const cPoolOrVenue4 = a4.value; o = a4.next; const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : ""; if (o < buf.length) o += 1; return { cDtlTyp, cPoolOrVenue: cPoolOrVenue4, cDtlSts, size: o - offset }; } function parseRcvRpi1(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; const cVenueNum = buf.readUInt8(o); o += 1; const cVenueSts = String.fromCharCode(buf[o]); o += 1; const aR = readFixedAscii(buf, o, 15); const cRaces = aR.value; o = aR.next; const aAd = readFixedAscii(buf, o, 41); const cAdvertisement = aAd.value; o = aAd.next; return { nRaceDt, cVenueNum, cVenueSts, cRaces, cAdvertisement, size: o - offset }; } function parseRcvRpi2(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; const cVenueNum = buf.readUInt8(o); o += 1; const cRaceNum = buf.readUInt8(o); o += 1; const aH = readFixedAscii(buf, o, 25); const cHorses = aH.value; o = aH.next; const nRaceStartTm = buf.readInt32LE(o); o += 4; return { nRaceDt, cVenueNum, cRaceNum, cHorses, nRaceStartTm, size: o - offset }; } function parseRcvRpi3(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; const cVenueNum = buf.readUInt8(o); o += 1; const cRaceNum = buf.readUInt8(o); o += 1; const aP = readFixedAscii(buf, o, 9); const cPools = aP.value; o = aP.next; return { nRaceDt, cVenueNum, cRaceNum, cPools, size: o - offset }; } function parseRcvRpi4(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; const cVenueNum = buf.readUInt8(o); o += 1; const cRaceNum = buf.subarray(o, o + 3).toString("ascii").replace(/\0/g, ""); o += 3; const aSel = readFixedAscii(buf, o, 11); const cSelectRaceNum = aSel.value; o = aSel.next; const cPoolSts = String.fromCharCode(buf[o]); o += 1; return { nRaceDt, cVenueNum, cRaceNum, cSelectRaceNum, cPoolSts, size: o - offset }; } function parseRcvPvm(buf, offset) { let o = offset; const a1 = readFixedAscii(buf, o, 4); const cRaceVenue = a1.value; o = a1.next; const a2 = readFixedAscii(buf, o, 4); const cPoolId = a2.value; o = a2.next; const fUnitPrice = buf.readFloatLE(o); o += 4; const fTaxRate = buf.readFloatLE(o); o += 4; const fDeductRate = buf.readFloatLE(o); o += 4; const a3 = readFixedAscii(buf, o, 31); const cRemarks = a3.value; o = a3.next; return { cRaceVenue, cPoolId, fUnitPrice, fTaxRate, fDeductRate, cRemarks, size: o - offset }; } // ---------- routes ---------- app.get("/", (_req, res) => res.send("ABS bridge is up")); app.get("/health", (_req, res) => { const s = new net.Socket(); s.setTimeout(1500); s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); }); s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); }); s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); }); }); // ---------- ABS_POLL ---------- app.post("/abs/poll", async (req, res) => { try { const body = await readJsonBody(req); const btMake = (typeof body.btMake === "string") ? body.btMake.charCodeAt(0) : (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00); const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake }); const reply = await withRetry(() => absRoundtrip(header, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); const hdr = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24))); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}`, sentHeaderHex: header.toString("hex"), replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex"), parsedRcvHeaderRaw: hdr, normalizedRcvHeader: { ...hdr, size: 18 }, normalizedHeaderHex: buildRcvHeader18(hdr).toString("hex"), success: hdr.nRetCd === SUCCESS }); } catch (e) { res.status(502).json({ ok: false, error: String(e) }); } }); // ---------- LOGIN ---------- app.post("/login", async (req, res) => { try { const body = await readJsonBody(req); const opCard = (body.opCard ?? "").toString(); const btId = (body.btId ?? "").toString(); const usrId = (body.usrId ?? "").toString(); const plain = (body.password ?? ""); const givenEnc = (body.passwordEnc ?? ""); if (!opCard || !btId || (!plain && !givenEnc)) { return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); } let encInfo = null; let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc; const btMake = (typeof body.btMake === "string") ? body.btMake.charCodeAt(0) : (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00); const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake }); const sndLog = packSndLog({ usrId, opCard, passwd, btId }); const sendBuf = Buffer.concat([sndHeader, sndLog]); const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); const rcvHeader = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24))); const json = { ok: true, target: `${ABS_HOST}:${ABS_PORT}`, sentHeaderHex: sndHeader.toString("hex"), sentBodyHex: sndLog.toString("hex"), replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex"), rcvHeaderRaw: rcvHeader, success: rcvHeader.nRetCd === SUCCESS }; if (!givenEnc && encInfo) { json.encryption = { used: true, utcSeconds: encInfo.utcSeconds, key: encInfo.key, encPasswd: passwd }; } else if (givenEnc) { json.encryption = { used: false, providedPasswordEnc: true }; } // locate login body start let bodyOffset = rcvHeader.size; bodyOffset = adjustForOptionalOp(reply, bodyOffset).offset; bodyOffset = alignToAsciiYear(reply, bodyOffset); json.offsetProbe = { bodyOffset, around: reply.subarray(Math.max(0, bodyOffset - 8), Math.min(reply.length, bodyOffset + 48)).toString("ascii") }; if (json.success && reply.length >= bodyOffset + 20) { try { json.log = parseRcvLog(reply, bodyOffset); } catch (e) { json.parseLogError = String(e); } } res.json(json); } catch (e) { res.status(502).json({ ok: false, error: String(e) }); } }); // ---------- DOWNLOAD BT INFO (LOG/BTI) ---------- app.post("/download/btinfo", async (req, res) => { try { const body = await readJsonBody(req); const opCard = (body.opCard ?? "").toString(); const btId = (body.btId ?? "").toString(); const usrId = (body.usrId ?? "").toString(); const plain = (body.password ?? ""); const encPass = (body.passwordEnc ?? ""); if (!opCard || !btId || (!plain && !encPass)) { return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); } const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc; const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: BTI, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: 0 }); const sndLog = packSndLog({ usrId, opCard, passwd, btId }); const sendBuf = Buffer.concat([sndHeader, sndLog]); const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') }; let offset = 0; if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no header" }); // const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); // result.primaryHeader = hdr; // offset += hdr.size; const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); result.primaryHeader = hdr; // Advance to end of header *and* align to the natural 4-byte boundary // (C structs are commonly padded so sizeof(sRcvHeader) will often be 20, not 18) const hdrLenAligned = (hdr.size + 3) & ~3; // e.g. 18 -> 20, 24 -> 24 // safety: ensure we have at least hdrLenAligned bytes in reply if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) { result.error = "truncated: incomplete header (after alignment)"; return res.json(result); } offset = hdrLenAligned; if (hdr.nRetCd !== SUCCESS) { result.error = `server returned nRetCd=${hdr.nRetCd}`; return res.json(result); } if (!safeAdvanceOrBreak(reply, offset, 5+31+5+4+1+4+4+4+4, result)) { result.error = "truncated before BTI master"; return res.json(result); } const btiMst = parseRcvBtiMst(reply, offset); offset += btiMst.size; result.btiMst = btiMst; const details = []; let readCount = 0; while (readCount < hdr.nNumRecs) { if (looksLikeHeaderAt(reply, offset)) break; // if (!safeAdvanceOrBreak(reply, offset, 1+4+1, result)) break; if (!safeAdvanceOrBreak(reply, offset, 1+3+1, result)) break; const dt = parseRcvBtiDtl(reply, offset); offset += dt.size; details.push(dt); readCount++; } result.btiDetails = details; result.btiReadCount = readCount; result.parsedBytes = offset; res.json(result); } catch (e) { res.status(502).json({ ok: false, error: String(e), stack: e.stack }); } }); // ---------- DOWNLOAD RP INFO (LOG/RPI) ---------- app.post("/download/rpinfo", async (req, res) => { try { const body = await readJsonBody(req); const debug = String((req.query.debug ?? "")).toLowerCase() === "1"; const opCard = (body.opCard ?? "").toString(); const btId = (body.btId ?? "").toString(); const usrId = (body.usrId ?? "").toString(); const plain = (body.password ?? ""); const encPass = (body.passwordEnc ?? ""); if (!opCard || !btId || (!plain && !encPass)) { return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); } const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc; const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: 0 }); const sndLog = packSndLog({ usrId, opCard, passwd, btId }); const sendBuf = Buffer.concat([sndHeader, sndLog]); const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') }; let offset = 0; if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no top header" }); const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24))); offset += topHdr.size; result.topHeader = topHdr; if (topHdr.nRetCd !== SUCCESS) { result.error = `server returned nRetCd=${topHdr.nRetCd}`; return res.json(result); } if (debug) result.debug_afterTopHdr = hexSlice(reply, 0, 128); // Helper to parse a section safely function parseSection(minRecBytes, recParser, outArrName, outHeaderName) { if (remaining(reply, offset) < 18) { result._truncated = true; return { header:null, items:[], read:0 }; } offset = alignToPossibleHeader(reply, offset); const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24))); offset += hdr.size; const adj = adjustForOptionalOp(reply, offset); offset = adj.offset; const items = []; let read = 0; while (read < hdr.nNumRecs) { if (looksLikeHeaderAt(reply, offset)) break; if (!safeAdvanceOrBreak(reply, offset, minRecBytes, result)) break; const rec = recParser(reply, offset); offset += rec.size; items.push(rec); read++; } result[outHeaderName] = hdr; result[outArrName] = items; result[outArrName.replace(/s?$/, "ReadCount")] = read; return { header: hdr, items, read }; } // RPI1 .. RPI4 .. PVM parseSection(4+1+1+15+41, parseRcvRpi1, "rpi1", "rpi1Header"); if (debug) result.debug_afterRpi1 = hexSlice(reply, Math.max(0, offset-64), 128); parseSection(4+1+1+25+4, parseRcvRpi2, "rpi2", "rpi2Header"); if (debug) result.debug_afterRpi2 = hexSlice(reply, Math.max(0, offset-64), 128); parseSection(4+1+1+9, parseRcvRpi3, "rpi3", "rpi3Header"); if (debug) result.debug_afterRpi3 = hexSlice(reply, Math.max(0, offset-64), 128); parseSection(4+1+3+11+1, parseRcvRpi4, "rpi4", "rpi4Header"); if (debug) result.debug_afterRpi4 = hexSlice(reply, Math.max(0, offset-64), 128); parseSection(4+4+4+4+4+31, parseRcvPvm, "pvms", "pvmHeader"); result.parsedBytes = offset; // Build summary const raceCard = {}; for (const v of result.rpi1 || []) { const key = `${v.nRaceDt}_${v.cVenueNum}`; raceCard[key] = { date: v.nRaceDt, venueNum: v.cVenueNum, status: v.cVenueSts, races: {}, advertisement: v.cAdvertisement }; } for (const r of result.rpi2 || []) { const key = `${r.nRaceDt}_${r.cVenueNum}`; const raceKey = `${r.cRaceNum}`; if (!raceCard[key]) raceCard[key] = { date: r.nRaceDt, venueNum: r.cVenueNum, races: {} }; raceCard[key].races[raceKey] = { raceNum: r.cRaceNum, horses: (r.cHorses || "").split(/\s+/).filter(Boolean), startTime: r.nRaceStartTm, pools: [] }; } for (const p of result.rpi3 || []) { const key = `${p.nRaceDt}_${p.cVenueNum}`; const raceKey = `${p.cRaceNum}`; if (raceCard[key] && raceCard[key].races[raceKey]) raceCard[key].races[raceKey].pools = (p.cPools || "").split("").filter(Boolean); } for (const s of result.rpi4 || []) { const key = `${s.nRaceDt}_${s.cVenueNum}`; const raceKey = `${s.cRaceNum}`; if (raceCard[key] && raceCard[key].races[raceKey]) { raceCard[key].races[raceKey].poolStatus = s.cPoolSts; raceCard[key].races[raceKey].selectRaceNum = s.cSelectRaceNum; } } result.raceCard = raceCard; res.json(result); } catch (e) { res.status(502).json({ ok: false, error: String(e), stack: e.stack }); } }); // ---- start HTTP server ---- const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080); app.listen(HTTP_PORT, () => { console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`); console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`); });