import { watch, readFileSync } from 'fs'; import { readdir, readFile, stat } from 'fs/promises'; import { resolve, basename, extname, join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); function loadEnv(): void { const envPath = resolve(__dirname, '.env'); try { const content = readFileSync(envPath, 'utf-8'); for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const [key, value] = trimmed.split('=', 2); if (key && value !== undefined) { process.env[key.trim()] = value.trim().replace(/^"|"$/g, ''); } } console.log('Loaded configuration from .env'); } catch { console.error('FATAL: Could not read .env file.'); process.exit(1); } } loadEnv(); const { INGEST_BASE_URL, API_TOKEN } = process.env; if (!INGEST_BASE_URL || !API_TOKEN) { console.error('FATAL: INGEST_BASE_URL and API_TOKEN must be defined in .env'); process.exit(1); } const WATCH_PATH = resolve(__dirname, '..', '..', '..', 'script-output', 'signal_export'); const DEBOUNCE_DELAY_MS = 250; const debounceTimers = new Map(); async function sendToWebhook(filePath: string, mtimeMs: number): Promise { const combinatorName = basename(filePath, extname(filePath)); const url = `${INGEST_BASE_URL}/api/ingest/${encodeURIComponent(combinatorName)}?token=${API_TOKEN}`; try { const content = await readFile(filePath, 'utf-8'); if (!content.trim()) return; JSON.parse(content); // validate before sending console.log(`Sending '${combinatorName}' (mtime: ${new Date(mtimeMs).toISOString()})`); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-File-Mtime': String(mtimeMs), }, body: content, }); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } console.log(`OK '${combinatorName}' → ${response.status}`); } catch (err: any) { if (err instanceof SyntaxError) { console.error(`Skip '${combinatorName}': invalid JSON`); } else { console.error(`Error '${combinatorName}':`, err.message); } } } function handleFileChange(filename: string | null): void { if (!filename?.endsWith('.json')) return; const existing = debounceTimers.get(filename); if (existing) clearTimeout(existing); const timer = setTimeout(async () => { const fullPath = join(WATCH_PATH, filename); try { const stats = await stat(fullPath); if (stats.isFile()) { await sendToWebhook(fullPath, stats.mtimeMs); } } catch { // file removed before processing — ignore } finally { debounceTimers.delete(filename); } }, DEBOUNCE_DELAY_MS); debounceTimers.set(filename, timer); } async function initialScan(): Promise { console.log(`Initial scan: ${WATCH_PATH}`); try { const files = (await readdir(WATCH_PATH)).filter(f => f.endsWith('.json')); console.log(`Found ${files.length} file(s)`); for (const file of files) { const fullPath = join(WATCH_PATH, file); const stats = await stat(fullPath); await sendToWebhook(fullPath, stats.mtimeMs); } } catch (err: any) { console.error(`Initial scan failed: ${err.message}`); } } (async () => { await initialScan(); console.log(`Watching: ${WATCH_PATH}`); try { watch(WATCH_PATH, (eventType, filename) => { if (filename) { console.log(`[${new Date().toLocaleTimeString()}] ${eventType}: ${filename}`); handleFileChange(filename); } }); console.log('Watcher running. Ctrl+C to stop.'); } catch (err: any) { console.error(`FATAL: Watcher failed: ${err.message}`); process.exit(1); } })();