gc-wing/apps/api/src/index.ts
2026-02-15 11:22:38 +09:00

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 });