274 lines
6.6 KiB
JavaScript
274 lines
6.6 KiB
JavaScript
import { createServer } from 'node:http'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { extname, join, normalize } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const port = Number(process.env.PORT ?? 8090)
|
|
const host = process.env.HOST ?? '0.0.0.0'
|
|
const forwardUrl = process.env.FORWARD_URL
|
|
const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 5 * 1024 * 1024)
|
|
const maxEvents = Number(process.env.MAX_EVENTS ?? 500)
|
|
const publicDir = join(fileURLToPath(new URL('.', import.meta.url)), 'public')
|
|
|
|
const events = []
|
|
const clients = new Set()
|
|
|
|
let receivedBatches = 0
|
|
let receivedItems = 0
|
|
let receivedBytes = 0
|
|
let startedAt = Date.now()
|
|
|
|
const contentTypes = {
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
}
|
|
|
|
function sendJson(res, status, body) {
|
|
res.writeHead(status, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'access-control-allow-origin': '*',
|
|
})
|
|
res.end(JSON.stringify(body))
|
|
}
|
|
|
|
function readRequestBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = []
|
|
let total = 0
|
|
|
|
req.on('data', (chunk) => {
|
|
total += chunk.length
|
|
|
|
if (total > maxBodyBytes) {
|
|
reject(new Error(`Request body is larger than ${maxBodyBytes} bytes`))
|
|
req.destroy()
|
|
return
|
|
}
|
|
|
|
chunks.push(chunk)
|
|
})
|
|
|
|
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
function normalizePayload(payload) {
|
|
const items = Array.isArray(payload) ? payload : [payload]
|
|
return items.map((item, index) => ({
|
|
edge: typeof item?.edge === 'string' ? item.edge : '',
|
|
timestamp:
|
|
typeof item?.timestamp === 'number' && Number.isFinite(item.timestamp)
|
|
? item.timestamp
|
|
: Date.now(),
|
|
tag: typeof item?.tag === 'string' ? item.tag : `item.${index + 1}`,
|
|
value:
|
|
typeof item?.value === 'number' && Number.isFinite(item.value)
|
|
? item.value
|
|
: Number(item?.value ?? 0),
|
|
raw: item,
|
|
}))
|
|
}
|
|
|
|
function buildSnapshot() {
|
|
return {
|
|
...buildSummary(),
|
|
events,
|
|
}
|
|
}
|
|
|
|
function buildSummary() {
|
|
const latest = events[events.length - 1]
|
|
const now = Date.now()
|
|
const uptimeSeconds = Math.round((now - startedAt) / 1000)
|
|
const recent = events.filter((event) => now - event.receivedAt <= 60_000)
|
|
|
|
return {
|
|
startedAt,
|
|
uptimeSeconds,
|
|
receivedBatches,
|
|
receivedItems,
|
|
receivedBytes,
|
|
recentBatchesPerMinute: recent.length,
|
|
maxEvents,
|
|
latest,
|
|
}
|
|
}
|
|
|
|
function broadcast(message) {
|
|
const data = `data: ${JSON.stringify(message)}\n\n`
|
|
|
|
for (const client of clients) {
|
|
client.write(data)
|
|
}
|
|
}
|
|
|
|
async function handleIngest(req, res) {
|
|
let body
|
|
|
|
try {
|
|
body = await readRequestBody(req)
|
|
} catch (err) {
|
|
sendJson(res, 413, { ok: false, error: err.message })
|
|
return
|
|
}
|
|
|
|
let payload
|
|
|
|
try {
|
|
payload = JSON.parse(body.toString('utf8'))
|
|
} catch {
|
|
sendJson(res, 400, { ok: false, error: 'Expected JSON payload' })
|
|
return
|
|
}
|
|
|
|
const items = normalizePayload(payload)
|
|
const event = {
|
|
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
receivedAt: Date.now(),
|
|
bytes: body.length,
|
|
count: items.length,
|
|
forward: forwardUrl ? { status: 'pending', url: forwardUrl } : undefined,
|
|
items,
|
|
payload,
|
|
}
|
|
|
|
events.push(event)
|
|
while (events.length > maxEvents) {
|
|
events.shift()
|
|
}
|
|
|
|
receivedBatches += 1
|
|
receivedItems += items.length
|
|
receivedBytes += body.length
|
|
|
|
console.log('ingest.received', {
|
|
id: event.id,
|
|
bytes: event.bytes,
|
|
count: event.count,
|
|
})
|
|
|
|
if (forwardUrl) {
|
|
try {
|
|
const forwardResponse = await fetch(forwardUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
body,
|
|
})
|
|
|
|
event.forward = {
|
|
status: forwardResponse.ok ? 'ok' : 'error',
|
|
url: forwardUrl,
|
|
httpStatus: forwardResponse.status,
|
|
statusText: forwardResponse.statusText,
|
|
}
|
|
} catch (err) {
|
|
event.forward = {
|
|
status: 'error',
|
|
url: forwardUrl,
|
|
error: err.message,
|
|
}
|
|
}
|
|
}
|
|
|
|
broadcast({ type: 'ingest', event, summary: buildSummary() })
|
|
sendJson(res, 202, { ok: true, id: event.id, count: event.count })
|
|
}
|
|
|
|
function handleEvents(req, res) {
|
|
res.writeHead(200, {
|
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
'cache-control': 'no-cache, no-transform',
|
|
connection: 'keep-alive',
|
|
'access-control-allow-origin': '*',
|
|
})
|
|
|
|
res.write(`data: ${JSON.stringify({ type: 'snapshot', snapshot: buildSnapshot() })}\n\n`)
|
|
clients.add(res)
|
|
|
|
req.on('close', () => {
|
|
clients.delete(res)
|
|
})
|
|
}
|
|
|
|
function resetMonitor(res) {
|
|
events.length = 0
|
|
receivedBatches = 0
|
|
receivedItems = 0
|
|
receivedBytes = 0
|
|
startedAt = Date.now()
|
|
broadcast({ type: 'snapshot', snapshot: buildSnapshot() })
|
|
sendJson(res, 200, { ok: true })
|
|
}
|
|
|
|
async function serveStatic(req, res) {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
|
|
const requestedPath = url.pathname === '/' ? '/index.html' : url.pathname
|
|
const normalized = normalize(requestedPath)
|
|
.replace(/^([/\\])+/, '')
|
|
.replace(/^(\.\.[/\\])+/, '')
|
|
const filePath = join(publicDir, normalized)
|
|
|
|
try {
|
|
const file = await readFile(filePath)
|
|
res.writeHead(200, {
|
|
'content-type': contentTypes[extname(filePath)] ?? 'application/octet-stream',
|
|
})
|
|
res.end(file)
|
|
} catch {
|
|
sendJson(res, 404, { ok: false, error: 'Not found' })
|
|
}
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, {
|
|
'access-control-allow-origin': '*',
|
|
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
|
|
'access-control-allow-headers': 'content-type',
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url?.startsWith('/api/ingest')) {
|
|
await handleIngest(req, res)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url?.startsWith('/api/events')) {
|
|
sendJson(res, 200, buildSnapshot())
|
|
return
|
|
}
|
|
|
|
if (req.method === 'DELETE' && req.url?.startsWith('/api/events')) {
|
|
resetMonitor(res)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url?.startsWith('/events')) {
|
|
handleEvents(req, res)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
await serveStatic(req, res)
|
|
return
|
|
}
|
|
|
|
sendJson(res, 405, { ok: false, error: 'Method not allowed' })
|
|
})
|
|
|
|
server.listen(port, host, () => {
|
|
console.log('ingest-monitor.started', {
|
|
url: `http://localhost:${port}`,
|
|
ingestUrl: `http://localhost:${port}/api/ingest`,
|
|
forwardUrl,
|
|
})
|
|
})
|