Skip to content

Commit 25c809e

Browse files
committed
feat: self-hosted Docker stack, live AIS relay, vessel data-source indicators
Self-contained Docker/Podman Compose stack with bundled Redis, AIS relay sidecar, and all services needed to run World Monitor locally. Docker stack: - Dockerfile.relay: lightweight Node.js sidecar running scripts/ais-relay.cjs for live AIS vessel data via AISStream.io WebSocket - docker-compose.yml: self-contained stack (app + Redis + redis-rest + AIS relay) with env-var references for all API keys — no hardcoded values - .gitignore: docker-compose.override.yml for local-specific configuration AIS data-source transparency: - GlobeMap: native tooltip shows "AIS LIVE" vs "EST. POSITION" per vessel - GlobeMap: hover tooltip shows green "AIS LIVE" or amber "EST. POSITION" with USNI attribution and article date when applicable - GlobeMap: USNI-sourced vessels rendered with dashed amber ring and reduced opacity to visually distinguish estimated positions - MapPopup: data-source badge in vessel detail popup header Quick start: cp .env.example .env # add API keys docker compose up -d --build
1 parent 88aea87 commit 25c809e

14 files changed

Lines changed: 725 additions & 19 deletions

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
node_modules
2+
dist
3+
.git
4+
.github
5+
.windsurf
6+
.agent
7+
.agents
8+
.claude
9+
.factory
10+
.planning
11+
e2e
12+
src-tauri/target
13+
src-tauri/sidecar/node
14+
*.log
15+
*.md
16+
!README.md
17+
docs/internal
18+
docs/Docs_To_Review
19+
tests

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ public/blog/
66
*.log
77
.env
88
.env.local
9+
docker-compose.override.yml
910
.playwright-mcp/
1011
.vercel
1112
api/\[domain\]/v1/\[rpc\].js

Dockerfile

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# =============================================================================
2+
# World Monitor — Docker Image
3+
# =============================================================================
4+
# Multi-stage build:
5+
# builder — installs deps, compiles TS handlers, builds Vite frontend
6+
# final — nginx (static) + node (API) under supervisord
7+
# =============================================================================
8+
9+
# ── Stage 1: Builder ─────────────────────────────────────────────────────────
10+
FROM node:22-alpine AS builder
11+
12+
WORKDIR /app
13+
14+
# Install root dependencies (layer-cached until package.json changes)
15+
COPY package.json package-lock.json ./
16+
RUN npm ci --ignore-scripts
17+
18+
# Install blog-site dependencies (separate workspace)
19+
COPY blog-site/package.json blog-site/package-lock.json ./blog-site/
20+
RUN cd blog-site && npm ci --ignore-scripts
21+
22+
# Copy full source
23+
COPY . .
24+
25+
# Compile TypeScript API handlers → self-contained ESM bundles
26+
# Output is api/**/*.js alongside the source .ts files
27+
RUN node docker/build-handlers.mjs
28+
29+
# Build Vite frontend (outputs to dist/)
30+
RUN npm run build
31+
32+
# ── Stage 2: Runtime ─────────────────────────────────────────────────────────
33+
FROM node:20-alpine AS final
34+
35+
# nginx + supervisord
36+
RUN apk add --no-cache nginx supervisor && \
37+
mkdir -p /tmp/nginx-client-body /tmp/nginx-proxy /tmp/nginx-fastcgi \
38+
/tmp/nginx-uwsgi /tmp/nginx-scgi /var/log/supervisor
39+
40+
WORKDIR /app
41+
42+
# API server
43+
COPY --from=builder /app/src-tauri/sidecar/local-api-server.mjs ./local-api-server.mjs
44+
COPY --from=builder /app/src-tauri/sidecar/package.json ./package.json
45+
46+
# API handler modules (JS originals + compiled TS bundles)
47+
COPY --from=builder /app/api ./api
48+
49+
# Static data files used by handlers at runtime
50+
COPY --from=builder /app/data ./data
51+
52+
# Built frontend static files
53+
COPY --from=builder /app/dist /usr/share/nginx/html
54+
55+
# Nginx + supervisord configs
56+
COPY docker/nginx.conf /etc/nginx/nginx.conf
57+
COPY docker/supervisord.conf /etc/supervisor/conf.d/worldmonitor.conf
58+
59+
EXPOSE 8080
60+
61+
# Healthcheck via nginx
62+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
63+
CMD wget -qO- http://localhost:8080/api/health || exit 1
64+
65+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/worldmonitor.conf"]

Dockerfile.relay

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# =============================================================================
2+
# AIS Relay Sidecar
3+
# =============================================================================
4+
# Runs scripts/ais-relay.cjs as a standalone container.
5+
# Only dependency beyond Node stdlib is the 'ws' WebSocket library.
6+
# Set AISSTREAM_API_KEY in docker-compose.yml.
7+
# =============================================================================
8+
9+
FROM node:22-alpine
10+
11+
WORKDIR /app
12+
13+
# Install only the ws package (everything else is Node stdlib)
14+
RUN npm install --omit=dev ws@8
15+
16+
# Relay script
17+
COPY scripts/ais-relay.cjs ./scripts/ais-relay.cjs
18+
19+
# Shared helper required by the relay (rss-allowed-domains.cjs)
20+
COPY shared/ ./shared/
21+
22+
EXPOSE 3004
23+
24+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
25+
CMD wget -qO- http://localhost:3004/health || exit 1
26+
27+
CMD ["node", "scripts/ais-relay.cjs"]

docker-compose.yml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# =============================================================================
2+
# World Monitor — Docker / Podman Compose
3+
# =============================================================================
4+
# Self-contained stack: app + Redis + AIS relay.
5+
#
6+
# Quick start:
7+
# cp .env.example .env # add your API keys
8+
# docker compose up -d --build
9+
#
10+
# The app will be available at http://localhost:3000
11+
# =============================================================================
12+
13+
version: '3.9'
14+
15+
services:
16+
17+
worldmonitor:
18+
build:
19+
context: .
20+
dockerfile: Dockerfile
21+
image: worldmonitor:latest
22+
container_name: worldmonitor
23+
ports:
24+
- "${WM_PORT:-3000}:8080"
25+
environment:
26+
UPSTASH_REDIS_REST_URL: "http://redis-rest:80"
27+
UPSTASH_REDIS_REST_TOKEN: "wm-local-token"
28+
LOCAL_API_PORT: "46123"
29+
LOCAL_API_MODE: "docker"
30+
LOCAL_API_CLOUD_FALLBACK: "false"
31+
WS_RELAY_URL: "http://ais-relay:3004"
32+
# LLM provider (any OpenAI-compatible endpoint)
33+
LLM_API_URL: "${LLM_API_URL:-}"
34+
LLM_API_KEY: "${LLM_API_KEY:-}"
35+
LLM_MODEL: "${LLM_MODEL:-}"
36+
GROQ_API_KEY: "${GROQ_API_KEY:-}"
37+
# Data source API keys (optional — features degrade gracefully)
38+
AISSTREAM_API_KEY: "${AISSTREAM_API_KEY:-}"
39+
FINNHUB_API_KEY: "${FINNHUB_API_KEY:-}"
40+
EIA_API_KEY: "${EIA_API_KEY:-}"
41+
FRED_API_KEY: "${FRED_API_KEY:-}"
42+
ACLED_ACCESS_TOKEN: "${ACLED_ACCESS_TOKEN:-}"
43+
NASA_FIRMS_API_KEY: "${NASA_FIRMS_API_KEY:-}"
44+
CLOUDFLARE_API_TOKEN: "${CLOUDFLARE_API_TOKEN:-}"
45+
AVIATIONSTACK_API: "${AVIATIONSTACK_API:-}"
46+
depends_on:
47+
redis-rest:
48+
condition: service_started
49+
ais-relay:
50+
condition: service_started
51+
restart: unless-stopped
52+
53+
ais-relay:
54+
build:
55+
context: .
56+
dockerfile: Dockerfile.relay
57+
image: worldmonitor-ais-relay:latest
58+
container_name: worldmonitor-ais-relay
59+
environment:
60+
AISSTREAM_API_KEY: "${AISSTREAM_API_KEY:-}"
61+
PORT: "3004"
62+
restart: unless-stopped
63+
64+
redis:
65+
image: docker.io/redis:7-alpine
66+
container_name: worldmonitor-redis
67+
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
68+
volumes:
69+
- redis-data:/data
70+
restart: unless-stopped
71+
72+
redis-rest:
73+
image: docker.io/hiett/serverless-redis-http:latest
74+
container_name: worldmonitor-redis-rest
75+
environment:
76+
SRH_MODE: "env"
77+
SRH_TOKEN: "wm-local-token"
78+
SRH_CONNECTION_STRING: "redis://redis:6379"
79+
depends_on:
80+
- redis
81+
restart: unless-stopped
82+
83+
volumes:
84+
redis-data:

docker/build-handlers.mjs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Compiles all API handlers into self-contained ESM bundles so the
3+
* local-api-server.mjs sidecar can discover and load them without node_modules.
4+
*
5+
* Two passes:
6+
* 1. TypeScript handlers (api/**\/*.ts) → bundled .js at same path
7+
* 2. Plain JS handlers (api/*.js root level) → bundled in-place to inline npm deps
8+
*
9+
* Run: node docker/build-handlers.mjs
10+
*/
11+
12+
import { build } from 'esbuild';
13+
import { readdir, stat } from 'node:fs/promises';
14+
import { fileURLToPath } from 'node:url';
15+
import path from 'node:path';
16+
17+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
18+
const projectRoot = path.resolve(__dirname, '..');
19+
const apiRoot = path.join(projectRoot, 'api');
20+
21+
// ── Pass 1: TypeScript handlers in subdirectories ─────────────────────────
22+
async function findTsHandlers(dir) {
23+
const entries = await readdir(dir, { withFileTypes: true });
24+
const handlers = [];
25+
for (const entry of entries) {
26+
const fullPath = path.join(dir, entry.name);
27+
if (entry.isDirectory()) {
28+
handlers.push(...await findTsHandlers(fullPath));
29+
} else if (
30+
entry.name.endsWith('.ts') &&
31+
!entry.name.startsWith('_') &&
32+
!entry.name.endsWith('.test.ts') &&
33+
!entry.name.endsWith('.d.ts')
34+
) {
35+
handlers.push(fullPath);
36+
}
37+
}
38+
return handlers;
39+
}
40+
41+
// ── Pass 2: Plain JS handlers at api/ root level ──────────────────────────
42+
async function findJsHandlers(dir) {
43+
const entries = await readdir(dir, { withFileTypes: true });
44+
return entries
45+
.filter(e =>
46+
e.isFile() &&
47+
e.name.endsWith('.js') &&
48+
!e.name.startsWith('_') &&
49+
!e.name.endsWith('.test.js') &&
50+
!e.name.endsWith('.test.mjs')
51+
)
52+
.map(e => path.join(dir, e.name));
53+
}
54+
55+
async function compileHandlers(handlers, label) {
56+
if (handlers.length === 0) {
57+
console.log(`${label}: nothing to compile`);
58+
return 0;
59+
}
60+
console.log(`${label}: compiling ${handlers.length} handlers...`);
61+
62+
const results = await Promise.allSettled(
63+
handlers.map(async (entryPoint) => {
64+
const outfile = entryPoint.replace(/\.ts$/, '.js');
65+
await build({
66+
entryPoints: [entryPoint],
67+
outfile,
68+
bundle: true,
69+
format: 'esm',
70+
platform: 'node',
71+
target: 'node20',
72+
treeShaking: true,
73+
allowOverwrite: true,
74+
loader: { '.ts': 'ts' },
75+
});
76+
const { size } = await stat(outfile);
77+
return { file: path.relative(projectRoot, outfile), size };
78+
})
79+
);
80+
81+
let ok = 0, failed = 0;
82+
for (const result of results) {
83+
if (result.status === 'fulfilled') {
84+
const { file, size } = result.value;
85+
console.log(` ✓ ${file} (${(size / 1024).toFixed(1)} KB)`);
86+
ok++;
87+
} else {
88+
console.error(` ✗ ${result.reason?.message || result.reason}`);
89+
failed++;
90+
}
91+
}
92+
return failed;
93+
}
94+
95+
const tsHandlers = await findTsHandlers(apiRoot);
96+
const jsHandlers = await findJsHandlers(apiRoot);
97+
98+
const tsFailed = await compileHandlers(tsHandlers, 'build-handlers [TS]');
99+
// JS handlers bundled AFTER TS so compiled .js outputs don't get re-processed
100+
const jsFailed = await compileHandlers(jsHandlers, 'build-handlers [JS]');
101+
102+
const totalFailed = tsFailed + jsFailed;
103+
console.log(`\nbuild-handlers: complete (${totalFailed} failures)`);
104+
if (totalFailed > 0) process.exit(1);

docker/nginx.conf

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
worker_processes auto;
2+
error_log /dev/stderr warn;
3+
pid /tmp/nginx.pid;
4+
5+
events {
6+
worker_connections 1024;
7+
}
8+
9+
http {
10+
include /etc/nginx/mime.types;
11+
default_type application/octet-stream;
12+
13+
log_format main '$remote_addr - [$time_local] "$request" $status $body_bytes_sent';
14+
access_log /dev/stdout main;
15+
16+
sendfile on;
17+
tcp_nopush on;
18+
keepalive_timeout 65;
19+
20+
# Serve pre-compressed assets (gzip .gz — built by vite brotliPrecompressPlugin)
21+
# brotli_static requires ngx_brotli module — not in Alpine nginx, use gzip fallback
22+
gzip_static on;
23+
gzip on;
24+
gzip_comp_level 5;
25+
gzip_min_length 1024;
26+
gzip_vary on;
27+
gzip_types application/json application/javascript text/css text/plain application/xml text/xml image/svg+xml;
28+
29+
# Temp dirs writable by non-root
30+
client_body_temp_path /tmp/nginx-client-body;
31+
proxy_temp_path /tmp/nginx-proxy;
32+
fastcgi_temp_path /tmp/nginx-fastcgi;
33+
uwsgi_temp_path /tmp/nginx-uwsgi;
34+
scgi_temp_path /tmp/nginx-scgi;
35+
36+
server {
37+
listen 8080;
38+
root /usr/share/nginx/html;
39+
index index.html;
40+
41+
# Static assets — immutable cache
42+
location /assets/ {
43+
add_header Cache-Control "public, max-age=31536000, immutable";
44+
try_files $uri =404;
45+
}
46+
47+
location /map-styles/ {
48+
add_header Cache-Control "public, max-age=31536000, immutable";
49+
try_files $uri =404;
50+
}
51+
52+
location /data/ {
53+
add_header Cache-Control "public, max-age=31536000, immutable";
54+
try_files $uri =404;
55+
}
56+
57+
location /textures/ {
58+
add_header Cache-Control "public, max-age=31536000, immutable";
59+
try_files $uri =404;
60+
}
61+
62+
# API proxy → Node.js local-api-server
63+
location /api/ {
64+
proxy_pass http://127.0.0.1:46123;
65+
proxy_http_version 1.1;
66+
proxy_set_header Host $host;
67+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
68+
proxy_set_header X-Forwarded-Proto $scheme;
69+
# Pass Origin as localhost so api key checks pass for browser-origin requests
70+
proxy_set_header Origin http://localhost;
71+
proxy_read_timeout 120s;
72+
proxy_send_timeout 120s;
73+
}
74+
75+
# SPA fallback — all other routes serve index.html
76+
location / {
77+
add_header Cache-Control "no-cache, no-store, must-revalidate";
78+
# Allow nested YouTube iframes to call requestStorageAccess() so
79+
# signed-in users' YouTube session cookies are used (avoids bot-check).
80+
add_header Permissions-Policy "storage-access=*";
81+
try_files $uri $uri/ /index.html;
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)