159 lines
5.1 KiB
TypeScript
159 lines
5.1 KiB
TypeScript
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 });
|