import type { ServerWebSocket } from "bun" type Anchor = { lat: number; lon: number } type StreamConfig = { anchor: Anchor spreadKm: number rateMs: number sogKn: number maxVessels: number } type VesselState = { mmsi: number name: string lat: number lon: number sog: number cog: number heading: number vesselType: number vesselTypeName: string } type SocketState = { config: StreamConfig vessels: VesselState[] sequence: number timer: ReturnType | null } const REGION_PRESETS: Record = { mediterranean: { lat: 36.2, lon: 14.2 }, shanghai: { lat: 31.1, lon: 121.25 }, east_asia: { lat: 31.1, lon: 121.25 }, north_sea: { lat: 56.2, lon: 3.2 }, norway: { lat: 59.9, lon: 10.7 }, } const port = Number(Bun.env.MOCK_AIS_WS_PORT || 8787) const baseMmsi = Number(Bun.env.MOCK_AIS_BASE_MMSI || 999000000) const region = String(Bun.env.MOCK_AIS_REGION || "mediterranean").toLowerCase() const defaultAnchor: Anchor = REGION_PRESETS[region] ?? REGION_PRESETS.mediterranean const defaultIntervalMs = clamp(Number(Bun.env.MOCK_AIS_WS_INTERVAL_MS || 1500), 200, 30_000) const defaultSpreadKm = clamp(Number(Bun.env.MOCK_AIS_SPREAD_KM || 60), 1, 5_000) const defaultSogKn = clamp(Number(Bun.env.MOCK_AIS_SOG_KN || 12), 0, 60) const defaultMaxVessels = clamp(Number(Bun.env.MOCK_AIS_MAX_VESSELS || 12), 1, 200) const KN_TO_DEG_LAT_PER_SEC = 1 / 60 / 60 // 1 nautical mile = 1/60 degree of latitude; per second const VESSEL_TYPE_BANK: Array<{ type: number; name: string }> = [ { type: 70, name: "Cargo" }, { type: 80, name: "Tanker" }, { type: 60, name: "Passenger" }, { type: 30, name: "Fishing" }, { type: 35, name: "Military" }, ] function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min return Math.min(Math.max(value, min), max) } function defaultConfig(): StreamConfig { return { anchor: { ...defaultAnchor }, spreadKm: defaultSpreadKm, rateMs: defaultIntervalMs, sogKn: defaultSogKn, maxVessels: defaultMaxVessels, } } function applyOverrides(base: StreamConfig, overrides: Record): StreamConfig { const next: StreamConfig = { anchor: { ...base.anchor }, spreadKm: base.spreadKm, rateMs: base.rateMs, sogKn: base.sogKn, maxVessels: base.maxVessels, } const anchor = overrides?.anchor if (anchor && typeof anchor === "object") { const lat = Number((anchor as Record).lat) const lon = Number((anchor as Record).lon) if (Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { next.anchor = { lat, lon } } } if (Number.isFinite(Number(overrides.lat)) && Number.isFinite(Number(overrides.lon))) { const lat = Number(overrides.lat) const lon = Number(overrides.lon) if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { next.anchor = { lat, lon } } } if (Number.isFinite(Number(overrides.spread_km))) { next.spreadKm = clamp(Number(overrides.spread_km), 1, 5_000) } if (Number.isFinite(Number(overrides.rate_hz)) && Number(overrides.rate_hz) > 0) { next.rateMs = clamp(1000 / Number(overrides.rate_hz), 100, 30_000) } if (Number.isFinite(Number(overrides.rate_ms))) { next.rateMs = clamp(Number(overrides.rate_ms), 100, 30_000) } if (Number.isFinite(Number(overrides.sog_kn))) { next.sogKn = clamp(Number(overrides.sog_kn), 0, 60) } if (Number.isFinite(Number(overrides.max_vessels))) { next.maxVessels = clamp(Number(overrides.max_vessels), 1, 200) } return next } function spawnVessel(state: SocketState, index: number): VesselState { const { anchor, spreadKm, sogKn } = state.config const angle = Math.random() * Math.PI * 2 const radius = Math.random() * spreadKm const dLat = (radius * Math.cos(angle)) / 111 const dLon = (radius * Math.sin(angle)) / (111 * Math.cos((anchor.lat * Math.PI) / 180) || 1) const cog = Math.random() * 360 const profile = VESSEL_TYPE_BANK[index % VESSEL_TYPE_BANK.length] return { mmsi: baseMmsi + index + 1, name: `MOCK VESSEL ${String(index + 1).padStart(3, "0")}`, lat: clamp(anchor.lat + dLat, -89.999, 89.999), lon: ((anchor.lon + dLon + 540) % 360) - 180, sog: clamp(sogKn * (0.6 + Math.random() * 0.6), 0, 60), cog, heading: Math.round(cog), vesselType: profile.type, vesselTypeName: profile.name, } } function advanceVessel(vessel: VesselState, dtSeconds: number) { const radians = (vessel.cog * Math.PI) / 180 const speedDegPerSec = vessel.sog * KN_TO_DEG_LAT_PER_SEC const dLat = speedDegPerSec * Math.cos(radians) * dtSeconds const dLon = (speedDegPerSec * Math.sin(radians) * dtSeconds) / (Math.cos((vessel.lat * Math.PI) / 180) || 1) vessel.lat = clamp(vessel.lat + dLat, -89.999, 89.999) vessel.lon = ((vessel.lon + dLon + 540) % 360) - 180 // small course wander so the path isn't a straight line vessel.cog = (vessel.cog + (Math.random() - 0.5) * 4 + 360) % 360 vessel.heading = Math.round(vessel.cog) } function pickNextVessel(state: SocketState): VesselState { state.sequence += 1 if (state.vessels.length === 0 || (state.sequence % 4 === 0 && state.vessels.length < state.config.maxVessels)) { const vessel = spawnVessel(state, state.vessels.length) state.vessels.push(vessel) return vessel } const vessel = state.vessels[state.sequence % state.vessels.length] advanceVessel(vessel, state.config.rateMs / 1000) return vessel } function buildPayload(state: SocketState, vessel: VesselState) { return { type: "vessel", sequence: state.sequence, config: { anchor: state.config.anchor, spread_km: state.config.spreadKm, rate_ms: state.config.rateMs, sog_kn: state.config.sogKn, max_vessels: state.config.maxVessels, }, data: { mmsi: String(vessel.mmsi), name: vessel.name, lat: Number(vessel.lat.toFixed(6)), lon: Number(vessel.lon.toFixed(6)), sog: Number(vessel.sog.toFixed(2)), cog: Number(vessel.cog.toFixed(1)), heading: vessel.heading, vessel_type: vessel.vesselType, vessel_type_name: vessel.vesselTypeName, source_note: `mock:${region}`, received_at: new Date().toISOString(), }, } } const sockets = new Map, SocketState>() function startTimer(socket: ServerWebSocket, state: SocketState) { if (state.timer) clearInterval(state.timer) state.timer = setInterval(() => { const vessel = pickNextVessel(state) const payload = buildPayload(state, vessel) try { socket.send(JSON.stringify(payload)) } catch { // socket already closed; cleanup happens in close() } }, state.config.rateMs) } const server = Bun.serve({ port, fetch(request, server) { const url = new URL(request.url) if (url.pathname !== "/ais") { return new Response("Mock AIS WS server. Connect to /ais.", { status: 200 }) } if (server.upgrade(request)) { return undefined } return new Response("WebSocket upgrade failed", { status: 400 }) }, websocket: { open(socket) { const state: SocketState = { config: defaultConfig(), vessels: [], sequence: 0, timer: null, } sockets.set(socket, state) socket.send( JSON.stringify({ type: "hello", source: "mock_ais_ws", config: { anchor: state.config.anchor, spread_km: state.config.spreadKm, rate_ms: state.config.rateMs, sog_kn: state.config.sogKn, max_vessels: state.config.maxVessels, }, subscribe_hint: "Send { type: 'subscribe', anchor: { lat, lon }, spread_km, rate_hz, sog_kn, max_vessels } to retarget.", }), ) startTimer(socket, state) }, message(socket, message) { const state = sockets.get(socket) if (!state) return let parsed: Record = {} try { parsed = JSON.parse(typeof message === "string" ? message : message.toString()) } catch { return } if (!parsed || typeof parsed !== "object") return const overrides = (parsed as { type?: unknown }).type === "subscribe" ? parsed : parsed const before = state.config state.config = applyOverrides(state.config, overrides) const anchorChanged = before.anchor.lat !== state.config.anchor.lat || before.anchor.lon !== state.config.anchor.lon if (anchorChanged) { // throw away old vessels so new ones spawn at the new anchor state.vessels = [] state.sequence = 0 } console.info("[mock-ais-ws] config update", state.config) startTimer(socket, state) socket.send( JSON.stringify({ type: "subscribe_ack", config: { anchor: state.config.anchor, spread_km: state.config.spreadKm, rate_ms: state.config.rateMs, sog_kn: state.config.sogKn, max_vessels: state.config.maxVessels, }, }), ) }, close(socket) { const state = sockets.get(socket) if (state?.timer) clearInterval(state.timer) sockets.delete(socket) }, }, }) console.info(`[mock-ais-ws] listening on ws://localhost:${server.port}/ais`) console.info( `[mock-ais-ws] defaults region=${region} anchor=${JSON.stringify(defaultAnchor)} spread_km=${defaultSpreadKm} rate_ms=${defaultIntervalMs} sog_kn=${defaultSogKn} max_vessels=${defaultMaxVessels}`, )