feat: move EONET/GDACS to server-side with Redis caching (#983)

* feat: move EONET/GDACS to server-side with Redis caching and bootstrap hydration

Browser-direct fetches to eonet.gsfc.nasa.gov and gdacs.org caused CORS
errors and had no server-side caching. This moves both to the standard
Vercel edge → cachedFetchJson → Redis → bootstrap hydration pattern.

- Add proto definitions for NaturalService with ListNaturalEvents RPC
- Create server handler merging EONET + GDACS with 30min Redis TTL
- Add Vercel edge function at /api/natural/v1/list-natural-events
- Register naturalEvents in bootstrap SLOW_KEYS for CDN hydration
- Replace browser-direct fetches with RPC client + circuit breaker
- Delete src/services/gdacs.ts (logic moved server-side)

* fix: restore @ts-nocheck on generated files stripped by buf generate
This commit is contained in:
Elie Habib
2026-03-04 15:02:03 +04:00
committed by GitHub
parent 5709ed45a2
commit 4de2f74210
38 changed files with 674 additions and 273 deletions

3
api/bootstrap.js vendored
View File

@@ -26,12 +26,13 @@ const BOOTSTRAP_CACHE_KEYS = {
positiveGeoEvents: 'positive-events:geo-bootstrap:v1',
theaterPosture: 'theater-posture:sebuf:stale:v1',
riskScores: 'risk:scores:sebuf:stale:v1',
naturalEvents: 'natural:events:v1',
};
const SLOW_KEYS = new Set([
'bisPolicy', 'bisExchange', 'bisCredit', 'minerals', 'giving',
'sectors', 'etfFlows', 'shippingRates', 'wildfires', 'climateAnomalies',
'cyberThreats', 'techReadiness', 'theaterPosture', 'riskScores',
'cyberThreats', 'techReadiness', 'theaterPosture', 'riskScores', 'naturalEvents',
]);
const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints',

9
api/natural/v1/[rpc].ts Normal file
View File

@@ -0,0 +1,9 @@
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createNaturalServiceRoutes } from '../../../src/generated/server/worldmonitor/natural/v1/service_server';
import { naturalHandler } from '../../../server/worldmonitor/natural/v1/handler';
export default createDomainGateway(
createNaturalServiceRoutes(naturalHandler, serverOptions),
);

View File

@@ -0,0 +1 @@
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ListNaturalEventsRequest":{"properties":{"days":{"format":"int32","type":"integer"}},"type":"object"},"ListNaturalEventsResponse":{"properties":{"events":{"items":{"$ref":"#/components/schemas/NaturalEvent"},"type":"array"}},"type":"object"},"NaturalEvent":{"properties":{"category":{"type":"string"},"categoryTitle":{"type":"string"},"closed":{"type":"boolean"},"date":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"description":{"type":"string"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"magnitude":{"format":"double","type":"number"},"magnitudeUnit":{"type":"string"},"sourceName":{"type":"string"},"sourceUrl":{"type":"string"},"title":{"type":"string"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"NaturalService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/natural/v1/list-natural-events":{"get":{"operationId":"ListNaturalEvents","parameters":[{"in":"query","name":"days","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListNaturalEventsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListNaturalEvents","tags":["NaturalService"]}}}}

View File

@@ -0,0 +1,117 @@
openapi: 3.1.0
info:
title: NaturalService API
version: 1.0.0
paths:
/api/natural/v1/list-natural-events:
get:
tags:
- NaturalService
summary: ListNaturalEvents
operationId: ListNaturalEvents
parameters:
- name: days
in: query
required: false
schema:
type: integer
format: int32
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListNaturalEventsResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
ListNaturalEventsRequest:
type: object
properties:
days:
type: integer
format: int32
ListNaturalEventsResponse:
type: object
properties:
events:
type: array
items:
$ref: '#/components/schemas/NaturalEvent'
NaturalEvent:
type: object
properties:
id:
type: string
title:
type: string
description:
type: string
category:
type: string
categoryTitle:
type: string
lat:
type: number
format: double
lon:
type: number
format: double
date:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
magnitude:
type: number
format: double
magnitudeUnit:
type: string
sourceUrl:
type: string
sourceName:
type: string
closed:
type: boolean

File diff suppressed because one or more lines are too long

View File

@@ -169,15 +169,6 @@ components:
required:
- provider
description: SummarizeArticleRequest specifies parameters for LLM article summarization.
SummarizeStatus:
type: string
enum:
- SUMMARIZE_STATUS_UNSPECIFIED
- SUMMARIZE_STATUS_SUCCESS
- SUMMARIZE_STATUS_CACHED
- SUMMARIZE_STATUS_SKIPPED
- SUMMARIZE_STATUS_ERROR
description: SummarizeStatus indicates the outcome of a summarization request.
SummarizeArticleResponse:
type: object
properties:
@@ -204,10 +195,17 @@ components:
type: string
description: Error type/name (e.g. "TypeError").
status:
$ref: '#/components/schemas/SummarizeStatus'
type: string
enum:
- SUMMARIZE_STATUS_UNSPECIFIED
- SUMMARIZE_STATUS_SUCCESS
- SUMMARIZE_STATUS_CACHED
- SUMMARIZE_STATUS_SKIPPED
- SUMMARIZE_STATUS_ERROR
description: SummarizeStatus indicates the outcome of a summarization request.
statusDetail:
type: string
description: Human-readable detail for non-success statuses (error message, skip reason, etc.).
description: Human-readable detail for non-success statuses (skip reason, etc.).
description: SummarizeArticleResponse contains the LLM summarization result.
GetSummarizeArticleCacheRequest:
type: object

View File

@@ -0,0 +1,29 @@
syntax = "proto3";
package worldmonitor.natural.v1;
import "sebuf/http/annotations.proto";
message NaturalEvent {
string id = 1;
string title = 2;
string description = 3;
string category = 4;
string category_title = 5;
double lat = 6;
double lon = 7;
int64 date = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
double magnitude = 9;
string magnitude_unit = 10;
string source_url = 11;
string source_name = 12;
bool closed = 13;
}
message ListNaturalEventsRequest {
int32 days = 1 [(sebuf.http.query) = { name: "days" }];
}
message ListNaturalEventsResponse {
repeated NaturalEvent events = 1;
}

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
package worldmonitor.natural.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/natural/v1/list_natural_events.proto";
service NaturalService {
option (sebuf.http.service_config) = {base_path: "/api/natural/v1"};
rpc ListNaturalEvents(ListNaturalEventsRequest) returns (ListNaturalEventsResponse) {
option (sebuf.http.config) = {path: "/list-natural-events", method: HTTP_METHOD_GET};
}
}

View File

@@ -56,6 +56,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/aviation/v1/list-airport-delays': 'static',
'/api/market/v1/get-country-stock-index': 'slow',
'/api/natural/v1/list-natural-events': 'slow',
'/api/wildfire/v1/list-fire-detections': 'static',
'/api/maritime/v1/list-navigational-warnings': 'static',
'/api/supply-chain/v1/get-shipping-rates': 'static',

View File

@@ -0,0 +1,7 @@
import type { NaturalServiceHandler } from '../../../../src/generated/server/worldmonitor/natural/v1/service_server';
import { listNaturalEvents } from './list-natural-events';
export const naturalHandler: NaturalServiceHandler = {
listNaturalEvents,
};

View File

@@ -0,0 +1,177 @@
import type {
NaturalServiceHandler,
ServerContext,
ListNaturalEventsRequest,
ListNaturalEventsResponse,
NaturalEvent,
} from '../../../../src/generated/server/worldmonitor/natural/v1/service_server';
import { CHROME_UA } from '../../../_shared/constants';
import { cachedFetchJson } from '../../../_shared/redis';
const REDIS_CACHE_KEY = 'natural:events:v1';
const REDIS_CACHE_TTL = 1800; // 30 min
const EONET_API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events';
const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP';
const DAYS = 30;
const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000;
const GDACS_TO_CATEGORY: Record<string, string> = {
EQ: 'earthquakes',
FL: 'floods',
TC: 'severeStorms',
VO: 'volcanoes',
WF: 'wildfires',
DR: 'drought',
};
const EVENT_TYPE_NAMES: Record<string, string> = {
EQ: 'Earthquake',
FL: 'Flood',
TC: 'Tropical Cyclone',
VO: 'Volcano',
WF: 'Wildfire',
DR: 'Drought',
};
async function fetchEonet(days: number): Promise<NaturalEvent[]> {
const url = `${EONET_API_URL}?status=open&days=${days}`;
const res = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`EONET ${res.status}`);
const data: any = await res.json();
const events: NaturalEvent[] = [];
const now = Date.now();
for (const event of data.events || []) {
const category = event.categories?.[0];
if (!category) continue;
if (category.id === 'earthquakes') continue;
const latestGeo = event.geometry?.[event.geometry.length - 1];
if (!latestGeo || latestGeo.type !== 'Point') continue;
const eventDate = new Date(latestGeo.date);
const [lon, lat] = latestGeo.coordinates;
if (category.id === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) continue;
const source = event.sources?.[0];
events.push({
id: event.id || '',
title: event.title || '',
description: event.description || '',
category: category.id || '',
categoryTitle: category.title || '',
lat,
lon,
date: eventDate.getTime(),
magnitude: latestGeo.magnitudeValue ?? 0,
magnitudeUnit: latestGeo.magnitudeUnit || '',
sourceUrl: source?.url || '',
sourceName: source?.id || '',
closed: event.closed !== null,
});
}
return events;
}
async function fetchGdacs(): Promise<NaturalEvent[]> {
const res = await fetch(GDACS_API, {
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`GDACS ${res.status}`);
const data: any = await res.json();
const features: any[] = data.features || [];
const seen = new Set<string>();
const events: NaturalEvent[] = [];
for (const f of features) {
if (!f.geometry || f.geometry.type !== 'Point') continue;
const props = f.properties;
const key = `${props.eventtype}-${props.eventid}`;
if (seen.has(key)) continue;
seen.add(key);
if (props.alertlevel === 'Green') continue;
const category = GDACS_TO_CATEGORY[props.eventtype] || 'manmade';
const alertPrefix = props.alertlevel === 'Red' ? '🔴 ' : props.alertlevel === 'Orange' ? '🟠 ' : '';
const description = props.description || EVENT_TYPE_NAMES[props.eventtype] || props.eventtype;
const severity = props.severitydata?.severitytext || '';
events.push({
id: `gdacs-${props.eventtype}-${props.eventid}`,
title: `${alertPrefix}${props.name || ''}`,
description: `${description}${severity ? ` - ${severity}` : ''}`,
category,
categoryTitle: description,
lat: f.geometry.coordinates[1] ?? 0,
lon: f.geometry.coordinates[0] ?? 0,
date: new Date(props.fromdate || 0).getTime(),
magnitude: 0,
magnitudeUnit: '',
sourceUrl: props.url?.report || '',
sourceName: 'GDACS',
closed: false,
});
}
return events.slice(0, 100);
}
export const listNaturalEvents: NaturalServiceHandler['listNaturalEvents'] = async (
_ctx: ServerContext,
_req: ListNaturalEventsRequest,
): Promise<ListNaturalEventsResponse> => {
try {
const result = await cachedFetchJson<ListNaturalEventsResponse>(
REDIS_CACHE_KEY,
REDIS_CACHE_TTL,
async () => {
const [eonetResult, gdacsResult] = await Promise.allSettled([
fetchEonet(DAYS),
fetchGdacs(),
]);
const eonetEvents = eonetResult.status === 'fulfilled' ? eonetResult.value : [];
const gdacsEvents = gdacsResult.status === 'fulfilled' ? gdacsResult.value : [];
if (eonetResult.status === 'rejected') console.error('[EONET]', eonetResult.reason?.message);
if (gdacsResult.status === 'rejected') console.error('[GDACS]', gdacsResult.reason?.message);
const seenLocations = new Set<string>();
const merged: NaturalEvent[] = [];
for (const event of gdacsEvents) {
const k = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
if (!seenLocations.has(k)) {
seenLocations.add(k);
merged.push(event);
}
}
for (const event of eonetEvents) {
const k = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
if (!seenLocations.has(k)) {
seenLocations.add(k);
merged.push(event);
}
}
return merged.length > 0 ? { events: merged } : null;
},
);
return result || { events: [] };
} catch {
return { events: [] };
}
};

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/displacement/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/giving/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/intelligence/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/maritime/v1/service.proto

View File

@@ -0,0 +1,117 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/natural/v1/service.proto
export interface ListNaturalEventsRequest {
days: number;
}
export interface ListNaturalEventsResponse {
events: NaturalEvent[];
}
export interface NaturalEvent {
id: string;
title: string;
description: string;
category: string;
categoryTitle: string;
lat: number;
lon: number;
date: number;
magnitude: number;
magnitudeUnit: string;
sourceUrl: string;
sourceName: string;
closed: boolean;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface NaturalServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface NaturalServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class NaturalServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: NaturalServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async listNaturalEvents(req: ListNaturalEventsRequest, options?: NaturalServiceCallOptions): Promise<ListNaturalEventsResponse> {
let path = "/api/natural/v1/list-natural-events";
const params = new URLSearchParams();
if (req.days != null && req.days !== 0) params.set("days", String(req.days));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as ListNaturalEventsResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/news/v1/service.proto
@@ -10,8 +11,6 @@ export interface SummarizeArticleRequest {
lang: string;
}
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
export interface SummarizeArticleResponse {
summary: string;
model: string;
@@ -66,6 +65,8 @@ export interface GeoCoordinates {
longitude: number;
}
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
export type ThreatLevel = "THREAT_LEVEL_UNSPECIFIED" | "THREAT_LEVEL_LOW" | "THREAT_LEVEL_MEDIUM" | "THREAT_LEVEL_HIGH" | "THREAT_LEVEL_CRITICAL";
export interface FieldViolation {

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/prediction/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/research/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/seismology/v1/service.proto

View File

@@ -207,3 +207,4 @@ export class SupplyChainServiceClient {
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/wildfire/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/conflict/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/displacement/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/giving/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/intelligence/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/maritime/v1/service.proto

View File

@@ -0,0 +1,131 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/natural/v1/service.proto
export interface ListNaturalEventsRequest {
days: number;
}
export interface ListNaturalEventsResponse {
events: NaturalEvent[];
}
export interface NaturalEvent {
id: string;
title: string;
description: string;
category: string;
categoryTitle: string;
lat: number;
lon: number;
date: number;
magnitude: number;
magnitudeUnit: string;
sourceUrl: string;
sourceName: string;
closed: boolean;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface NaturalServiceHandler {
listNaturalEvents(ctx: ServerContext, req: ListNaturalEventsRequest): Promise<ListNaturalEventsResponse>;
}
export function createNaturalServiceRoutes(
handler: NaturalServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
{
method: "GET",
path: "/api/natural/v1/list-natural-events",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: ListNaturalEventsRequest = {
days: Number(params.get("days") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listNaturalEvents", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listNaturalEvents(ctx, body);
return new Response(JSON.stringify(result as ListNaturalEventsResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/news/v1/service.proto
@@ -10,8 +11,6 @@ export interface SummarizeArticleRequest {
lang: string;
}
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
export interface SummarizeArticleResponse {
summary: string;
model: string;
@@ -66,6 +65,8 @@ export interface GeoCoordinates {
longitude: number;
}
export type SummarizeStatus = "SUMMARIZE_STATUS_UNSPECIFIED" | "SUMMARIZE_STATUS_SUCCESS" | "SUMMARIZE_STATUS_CACHED" | "SUMMARIZE_STATUS_SKIPPED" | "SUMMARIZE_STATUS_ERROR";
export type ThreatLevel = "THREAT_LEVEL_UNSPECIFIED" | "THREAT_LEVEL_LOW" | "THREAT_LEVEL_MEDIUM" | "THREAT_LEVEL_HIGH" | "THREAT_LEVEL_CRITICAL";
export interface FieldViolation {

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/positive_events/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/prediction/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/research/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/seismology/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/supply_chain/v1/service.proto

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/wildfire/v1/service.proto

View File

@@ -1,40 +1,10 @@
import type { NaturalEvent, NaturalEventCategory } from '@/types';
import { fetchGDACSEvents, type GDACSEvent } from './gdacs';
interface EonetGeometry {
magnitudeValue?: number;
magnitudeUnit?: string;
date: string;
type: string;
coordinates: [number, number];
}
interface EonetSource {
id: string;
url: string;
}
interface EonetCategory {
id: string;
title: string;
}
interface EonetEvent {
id: string;
title: string;
description: string | null;
closed: string | null;
categories: EonetCategory[];
sources: EonetSource[];
geometry: EonetGeometry[];
}
interface EonetResponse {
title: string;
events: EonetEvent[];
}
const EONET_API_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events';
import {
NaturalServiceClient,
type ListNaturalEventsResponse,
} from '@/generated/client/worldmonitor/natural/v1/service_client';
import { createCircuitBreaker } from '@/utils';
import { getHydratedData } from '@/services/bootstrap';
const CATEGORY_ICONS: Record<NaturalEventCategory, string> = {
severeStorms: '🌀',
@@ -56,117 +26,34 @@ export function getNaturalEventIcon(category: NaturalEventCategory): string {
return CATEGORY_ICONS[category] || '⚠️';
}
// Wildfires older than 48 hours are filtered out (stale data)
const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000;
const client = new NaturalServiceClient('', { fetch: (...args) => globalThis.fetch(...args) });
const breaker = createCircuitBreaker<ListNaturalEventsResponse>({ name: 'NaturalEvents', cacheTtlMs: 30 * 60 * 1000, persistCache: true });
const GDACS_TO_CATEGORY: Record<string, NaturalEventCategory> = {
EQ: 'earthquakes',
FL: 'floods',
TC: 'severeStorms',
VO: 'volcanoes',
WF: 'wildfires',
DR: 'drought',
};
const emptyFallback: ListNaturalEventsResponse = { events: [] };
function convertGDACSToNaturalEvent(gdacs: GDACSEvent): NaturalEvent {
const category = GDACS_TO_CATEGORY[gdacs.eventType] || 'manmade';
function toNaturalEvent(e: ListNaturalEventsResponse['events'][number]): NaturalEvent {
return {
id: gdacs.id,
title: `${gdacs.alertLevel === 'Red' ? '🔴 ' : gdacs.alertLevel === 'Orange' ? '🟠 ' : ''}${gdacs.name}`,
description: `${gdacs.description}${gdacs.severity ? ` - ${gdacs.severity}` : ''}`,
category,
categoryTitle: gdacs.description,
lat: gdacs.coordinates[1],
lon: gdacs.coordinates[0],
date: gdacs.fromDate,
sourceUrl: gdacs.url,
sourceName: 'GDACS',
closed: false,
id: e.id,
title: e.title,
description: e.description || undefined,
category: (e.category || 'manmade') as NaturalEventCategory,
categoryTitle: e.categoryTitle,
lat: e.lat,
lon: e.lon,
date: new Date(e.date),
magnitude: e.magnitude ?? undefined,
magnitudeUnit: e.magnitudeUnit ?? undefined,
sourceUrl: e.sourceUrl || undefined,
sourceName: e.sourceName || undefined,
closed: e.closed,
};
}
export async function fetchNaturalEvents(days = 30): Promise<NaturalEvent[]> {
const [eonetEvents, gdacsEvents] = await Promise.all([
fetchEonetEvents(days),
fetchGDACSEvents(),
]);
export async function fetchNaturalEvents(_days = 30): Promise<NaturalEvent[]> {
const hydrated = getHydratedData('naturalEvents') as ListNaturalEventsResponse | undefined;
const response = hydrated ?? await breaker.execute(async () => {
return client.listNaturalEvents({ days: 30 });
}, emptyFallback);
const gdacsConverted = gdacsEvents.map(convertGDACSToNaturalEvent);
const seenLocations = new Set<string>();
const merged: NaturalEvent[] = [];
for (const event of gdacsConverted) {
const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
if (!seenLocations.has(key)) {
seenLocations.add(key);
merged.push(event);
}
}
for (const event of eonetEvents) {
const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
if (!seenLocations.has(key)) {
seenLocations.add(key);
merged.push(event);
}
}
return merged;
}
async function fetchEonetEvents(days: number): Promise<NaturalEvent[]> {
try {
const url = `${EONET_API_URL}?status=open&days=${days}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`EONET API error: ${response.status}`);
}
const data: EonetResponse = await response.json();
const events: NaturalEvent[] = [];
const now = Date.now();
for (const event of data.events) {
const category = event.categories[0];
if (!category) continue;
// Skip earthquakes - USGS provides better data for seismic events
if (category.id === 'earthquakes') continue;
// Get most recent geometry point
const latestGeo = event.geometry[event.geometry.length - 1];
if (!latestGeo || latestGeo.type !== 'Point') continue;
const eventDate = new Date(latestGeo.date);
const [lon, lat] = latestGeo.coordinates;
const source = event.sources[0];
// Filter out wildfires older than 48 hours
if (category.id === 'wildfires' && now - eventDate.getTime() > WILDFIRE_MAX_AGE_MS) {
continue;
}
events.push({
id: event.id,
title: event.title,
description: event.description || undefined,
category: category.id as NaturalEventCategory,
categoryTitle: category.title,
lat,
lon,
date: eventDate,
magnitude: latestGeo.magnitudeValue,
magnitudeUnit: latestGeo.magnitudeUnit,
sourceUrl: source?.url,
sourceName: source?.id,
closed: event.closed !== null,
});
}
return events;
} catch (error) {
console.error('[EONET] Failed to fetch natural events:', error);
return [];
}
return (response.events || []).map(toNaturalEvent);
}

View File

@@ -1,114 +0,0 @@
import { createCircuitBreaker } from '@/utils';
export interface GDACSEvent {
id: string;
eventType: 'EQ' | 'FL' | 'TC' | 'VO' | 'WF' | 'DR';
name: string;
description: string;
alertLevel: 'Green' | 'Orange' | 'Red';
country: string;
coordinates: [number, number];
fromDate: Date;
severity: string;
url: string;
}
interface GDACSFeature {
geometry: {
type: string;
coordinates: [number, number];
};
properties: {
eventtype: string;
eventid: number;
name: string;
description: string;
alertlevel: string;
country: string;
fromdate: string;
severitydata?: {
severity: number;
severitytext: string;
severityunit: string;
};
url: {
report: string;
};
};
}
interface GDACSResponse {
features: GDACSFeature[];
}
const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP';
const breaker = createCircuitBreaker<GDACSEvent[]>({ name: 'GDACS', cacheTtlMs: 10 * 60 * 1000, persistCache: true });
const EVENT_TYPE_NAMES: Record<string, string> = {
EQ: 'Earthquake',
FL: 'Flood',
TC: 'Tropical Cyclone',
VO: 'Volcano',
WF: 'Wildfire',
DR: 'Drought',
};
export async function fetchGDACSEvents(): Promise<GDACSEvent[]> {
return breaker.execute(async () => {
const response = await fetch(GDACS_API, {
headers: { 'Accept': 'application/json' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: GDACSResponse = await response.json();
const seen = new Set<string>();
return data.features
.filter(f => {
if (!f.geometry || f.geometry.type !== 'Point') return false;
const key = `${f.properties.eventtype}-${f.properties.eventid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.filter(f => f.properties.alertlevel !== 'Green')
.slice(0, 100)
.map(f => ({
id: `gdacs-${f.properties.eventtype}-${f.properties.eventid}`,
eventType: f.properties.eventtype as GDACSEvent['eventType'],
name: f.properties.name,
description: f.properties.description || EVENT_TYPE_NAMES[f.properties.eventtype] || f.properties.eventtype,
alertLevel: f.properties.alertlevel as GDACSEvent['alertLevel'],
country: f.properties.country,
coordinates: f.geometry.coordinates,
fromDate: new Date(f.properties.fromdate),
severity: f.properties.severitydata?.severitytext || '',
url: f.properties.url?.report || '',
}));
}, []);
}
export function getGDACSStatus(): string {
return breaker.getStatus();
}
export function getEventTypeIcon(type: GDACSEvent['eventType']): string {
switch (type) {
case 'EQ': return '🌍';
case 'FL': return '🌊';
case 'TC': return '🌀';
case 'VO': return '🌋';
case 'WF': return '🔥';
case 'DR': return '☀️';
default: return '⚠️';
}
}
export function getAlertColor(level: GDACSEvent['alertLevel']): [number, number, number, number] {
switch (level) {
case 'Red': return [255, 0, 0, 200];
case 'Orange': return [255, 140, 0, 180];
default: return [255, 200, 0, 160];
}
}

View File

@@ -196,6 +196,7 @@ function sebufApiPlugin(): Plugin {
givingServerMod, givingHandlerMod,
tradeServerMod, tradeHandlerMod,
supplyChainServerMod, supplyChainHandlerMod,
naturalServerMod, naturalHandlerMod,
] = await Promise.all([
import('./server/router'),
import('./server/cors'),
@@ -242,6 +243,8 @@ function sebufApiPlugin(): Plugin {
import('./server/worldmonitor/trade/v1/handler'),
import('./src/generated/server/worldmonitor/supply_chain/v1/service_server'),
import('./server/worldmonitor/supply-chain/v1/handler'),
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
import('./server/worldmonitor/natural/v1/handler'),
]);
const serverOptions = { onError: errorMod.mapErrorToResponse };
@@ -267,6 +270,7 @@ function sebufApiPlugin(): Plugin {
...givingServerMod.createGivingServiceRoutes(givingHandlerMod.givingHandler, serverOptions),
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
];
cachedCorsMod = corsMod;
return routerMod.createRouter(allRoutes);