Skip to content

Commit 6091b12

Browse files
committed
feat: add background monitor service for payments and escrows
- Long-running service polls /api/cron/monitor-payments every 15s - Exponential backoff on errors (up to 5min) - 30s startup delay for Next.js boot - Runs alongside Next.js via start.sh with signal forwarding - Replaces Vercel cron config (doesn't work on Railway) - Only logs when there's actual activity
1 parent c9403dd commit 6091b12

File tree

2 files changed

+130
-3
lines changed

2 files changed

+130
-3
lines changed

scripts/monitor-service.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env -S npx tsx
2+
/**
3+
* Payment & Escrow Monitor Service
4+
*
5+
* Long-running background service that polls the monitor endpoint.
6+
* Runs alongside the Next.js app on Railway.
7+
*
8+
* Interval: 15 seconds (configurable via MONITOR_INTERVAL_MS)
9+
*/
10+
11+
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || "http://localhost:3000";
12+
const API_KEY = process.env.INTERNAL_API_KEY || "";
13+
const INTERVAL_MS = parseInt(process.env.MONITOR_INTERVAL_MS || "15000");
14+
const STARTUP_DELAY_MS = parseInt(process.env.MONITOR_STARTUP_DELAY_MS || "30000");
15+
16+
let running = true;
17+
let consecutiveErrors = 0;
18+
const MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 min max backoff
19+
20+
async function runCycle(): Promise<void> {
21+
try {
22+
const controller = new AbortController();
23+
const timeout = setTimeout(() => controller.abort(), 30000);
24+
25+
const res = await fetch(`${APP_URL}/api/cron/monitor-payments`, {
26+
method: "GET",
27+
headers: { Authorization: `Bearer ${API_KEY}` },
28+
signal: controller.signal,
29+
});
30+
31+
clearTimeout(timeout);
32+
33+
if (res.ok) {
34+
const data = await res.json();
35+
consecutiveErrors = 0;
36+
37+
// Only log if something happened
38+
const escrow = data.escrow || {};
39+
const stats = data.stats || {};
40+
const ln = data.lightning || {};
41+
const inv = data.invoices || {};
42+
43+
const activity = (escrow.funded || 0) + (escrow.expired || 0) +
44+
(stats.confirmed || 0) + (ln.settled || 0) + (inv.detected || 0);
45+
46+
if (activity > 0) {
47+
console.log(`[Monitor] Activity:`, JSON.stringify({
48+
escrow: escrow.funded || escrow.expired ? escrow : undefined,
49+
payments: stats.confirmed ? stats : undefined,
50+
lightning: ln.settled ? ln : undefined,
51+
invoices: inv.detected ? inv : undefined,
52+
}));
53+
}
54+
} else {
55+
const text = await res.text().catch(() => "");
56+
console.error(`[Monitor] HTTP ${res.status}: ${text.slice(0, 200)}`);
57+
consecutiveErrors++;
58+
}
59+
} catch (err: any) {
60+
if (err.name === "AbortError") {
61+
console.error("[Monitor] Request timed out (30s)");
62+
} else {
63+
console.error(`[Monitor] Error: ${err.message}`);
64+
}
65+
consecutiveErrors++;
66+
}
67+
}
68+
69+
async function main() {
70+
console.log(`[Monitor] Starting payment monitor service`);
71+
console.log(`[Monitor] URL: ${APP_URL}/api/cron/monitor-payments`);
72+
console.log(`[Monitor] Interval: ${INTERVAL_MS}ms, startup delay: ${STARTUP_DELAY_MS}ms`);
73+
74+
if (!API_KEY) {
75+
console.error("[Monitor] WARNING: INTERNAL_API_KEY not set — requests will be unauthorized");
76+
}
77+
78+
// Wait for the app to start up
79+
await new Promise(r => setTimeout(r, STARTUP_DELAY_MS));
80+
console.log("[Monitor] Startup delay complete, beginning monitor cycles");
81+
82+
while (running) {
83+
await runCycle();
84+
85+
// Exponential backoff on consecutive errors (15s → 30s → 60s → ... → 5min)
86+
const backoff = consecutiveErrors > 0
87+
? Math.min(INTERVAL_MS * Math.pow(2, consecutiveErrors - 1), MAX_BACKOFF_MS)
88+
: INTERVAL_MS;
89+
90+
if (backoff > INTERVAL_MS) {
91+
console.log(`[Monitor] Backing off: ${Math.round(backoff / 1000)}s (${consecutiveErrors} consecutive errors)`);
92+
}
93+
94+
await new Promise(r => setTimeout(r, backoff));
95+
}
96+
}
97+
98+
process.on("SIGTERM", () => { running = false; console.log("[Monitor] Shutting down..."); });
99+
process.on("SIGINT", () => { running = false; console.log("[Monitor] Shutting down..."); });
100+
101+
main().catch(err => {
102+
console.error("[Monitor] Fatal:", err);
103+
process.exit(1);
104+
});

scripts/start.sh

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
#!/bin/sh
2-
# Railway start script
2+
# Railway start script — runs Next.js app + payment monitor service
33

4-
# Keep process resilient and avoid frequent OOM exits under production load
54
export NODE_OPTIONS="--max-old-space-size=1024 --unhandled-rejections=warn"
6-
exec pnpm start
5+
6+
# Start the monitor service in the background
7+
npx tsx scripts/monitor-service.ts &
8+
MONITOR_PID=$!
9+
10+
# Trap signals to clean up both processes
11+
cleanup() {
12+
echo "[start.sh] Shutting down..."
13+
kill $MONITOR_PID 2>/dev/null
14+
wait $MONITOR_PID 2>/dev/null
15+
exit 0
16+
}
17+
trap cleanup SIGTERM SIGINT
18+
19+
# Start Next.js in the foreground
20+
pnpm start &
21+
NEXT_PID=$!
22+
23+
# Wait for either to exit
24+
wait $NEXT_PID
25+
EXIT_CODE=$?
26+
27+
# If Next.js dies, kill the monitor too
28+
kill $MONITOR_PID 2>/dev/null
29+
exit $EXIT_CODE

0 commit comments

Comments
 (0)