import cors from "@fastify/cors"; import Fastify from "fastify"; import fs from "node:fs/promises"; import path from "node:path"; const app = Fastify({ logger: true }); await app.register(cors, { origin: true }); app.setErrorHandler((err, req, reply) => { req.log.error({ err }, "Unhandled error"); if (reply.sent) return; const statusCode = typeof (err as { statusCode?: unknown }).statusCode === "number" ? (err as { statusCode: number }).statusCode : 500; reply.code(statusCode).send({ success: false, message: err instanceof Error ? err.message : String(err), data: [], errorCode: "INTERNAL", }); }); app.get("/health", async () => ({ ok: true })); const AIS_UPSTREAM_BASE = "http://211.208.115.83:8041"; const AIS_UPSTREAM_PATH = "/snp-api/api/ais-target/search"; app.get<{ Querystring: { minutes?: string; bbox?: string; }; }>("/api/ais-target/search", async (req, reply) => { const minutesRaw = req.query.minutes ?? "60"; const minutes = Number(minutesRaw); if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 60 * 24) { return reply.code(400).send({ success: false, message: "invalid minutes", data: [], errorCode: "BAD_REQUEST" }); } const bboxRaw = req.query.bbox; const bbox = parseBbox(bboxRaw); if (bboxRaw && !bbox) { return reply.code(400).send({ success: false, message: "invalid bbox", data: [], errorCode: "BAD_REQUEST" }); } const u = new URL(AIS_UPSTREAM_PATH, AIS_UPSTREAM_BASE); u.searchParams.set("minutes", String(minutes)); const controller = new AbortController(); const timeoutMs = 20_000; const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } }); const txt = await res.text(); if (!res.ok) { req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "AIS upstream error"); return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" }); } // Apply optional bbox filtering server-side to reduce payload to the browser. let json: { data?: unknown; message?: string }; try { json = JSON.parse(txt) as { data?: unknown; message?: string }; } catch (e) { req.log.warn({ err: e, body: txt.slice(0, 2000) }, "AIS upstream returned invalid JSON"); return reply .code(502) .send({ success: false, message: "upstream invalid json", data: [], errorCode: "UPSTREAM_INVALID_JSON" }); } if (!json || typeof json !== "object") { req.log.warn({ body: txt.slice(0, 2000) }, "AIS upstream returned non-object JSON"); return reply .code(502) .send({ success: false, message: "upstream invalid payload", data: [], errorCode: "UPSTREAM_INVALID_PAYLOAD" }); } const rows = Array.isArray(json.data) ? (json.data as unknown[]) : []; const filtered = bbox ? rows.filter((r) => { if (!r || typeof r !== "object") return false; const lat = (r as { lat?: unknown }).lat; const lon = (r as { lon?: unknown }).lon; if (typeof lat !== "number" || typeof lon !== "number") return false; return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax; }) : rows; if (bbox) { json.message = `${json.message ?? ""} (bbox: ${filtered.length}/${rows.length})`.trim(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any (json as any).data = filtered; reply.type("application/json").send(json); } catch (e) { const name = e instanceof Error ? e.name : ""; const isTimeout = name === "AbortError"; req.log.warn({ err: e, url: u.toString() }, "AIS proxy request failed"); return reply.code(isTimeout ? 504 : 502).send({ success: false, message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed", data: [], errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED", }); } finally { clearTimeout(timeout); } }); function parseBbox(raw: string | undefined) { if (!raw) return null; const parts = raw .split(",") .map((s) => s.trim()) .filter(Boolean); if (parts.length !== 4) return null; const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p)); const ok = Number.isFinite(lonMin) && Number.isFinite(latMin) && Number.isFinite(lonMax) && Number.isFinite(latMax) && lonMin >= -180 && lonMax <= 180 && latMin >= -90 && latMax <= 90 && lonMin < lonMax && latMin < latMax; if (!ok) return null; return { lonMin, latMin, lonMax, latMax }; } app.get("/zones", async (_req, reply) => { const zonesPath = path.resolve( process.cwd(), "..", "web", "public", "data", "zones", "zones.wgs84.geojson", ); const txt = await fs.readFile(zonesPath, "utf-8"); reply.type("application/json").send(JSON.parse(txt)); }); app.get("/vessels", async () => { return { source: "todo", vessels: [], }; }); const port = Number(process.env.PORT || 5174); const host = process.env.HOST || "127.0.0.1"; await app.listen({ port, host });