diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..65c1943
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,22 @@
+# ── Frontend (web) ──────────────────────────────────────────────────────────
+# Backend API base URL. Required in production — the frontend throws at
+# startup if this is unset and NODE_ENV=production.
+BACKEND_URL=http://localhost:3001
+
+# Optional: Plausible analytics. Both must be set for the script to render.
+# NEXT_PUBLIC_PLAUSIBLE_SRC=https://plausible.example.com/script.js
+# NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID=your-website-id
+
+# ── Backend ─────────────────────────────────────────────────────────────────
+# Port the Hono server listens on (default 3001).
+PORT=3001
+
+# IANA timezone used by node-cron schedules and operating-hour windows.
+TZ=America/New_York
+
+# How long a park's schedule data is considered fresh before the tiered
+# scraper re-fetches it (default 72).
+PARK_HOURS_STALENESS_HOURS=72
+
+# Per-IP request limit for the public API, per minute (default 60).
+RATE_LIMIT_PER_MIN=60
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 5dc4f49..abea687 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -6,8 +6,44 @@ on:
- main
jobs:
+ verify:
+ name: Lint, typecheck, test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install frontend deps
+ run: npm ci
+
+ - name: Frontend lint
+ run: npm run lint
+
+ - name: Frontend typecheck
+ run: npm run typecheck
+
+ - name: Frontend tests
+ run: npm test
+
+ - name: Install backend deps
+ run: npm ci
+ working-directory: backend
+
+ - name: Backend typecheck
+ run: npm run typecheck
+ working-directory: backend
+
+ - name: Backend tests
+ run: npm test
+ working-directory: backend
+
build-push:
name: Build & Push
+ needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index d772b82..8601431 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,9 @@ yarn-error.log*
/backend/data/
parks.db
+# debug script artifacts
+/debug/
+
# env files
.env*
!.env.example
@@ -45,3 +48,4 @@ parks.db
# typescript
*.tsbuildinfo
next-env.d.ts
+.gstack/
diff --git a/Dockerfile b/Dockerfile
index 5f2ed53..75e217e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,13 +6,25 @@ RUN npm ci
COPY . .
RUN npm run build
-# ── backend-deps: backend node_modules (better-sqlite3 needs build tools) ────
-FROM node:22-bookworm-slim AS backend-deps
+# ── backend-build: compile backend TypeScript to JS (better-sqlite3 build) ───
+FROM node:22-bookworm-slim AS backend-build
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
+COPY backend/package.json backend/package-lock.json* ./backend/
+COPY backend/tsconfig.json ./backend/
+RUN cd backend && npm ci
+COPY backend/src ./backend/src
+COPY lib ./lib
+RUN cd backend && npm run build
+
+# ── backend-prod-deps: production-only node_modules (omits tsc/tsx) ──────────
+FROM node:22-bookworm-slim AS backend-prod-deps
+RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
+ rm -rf /var/lib/apt/lists/*
+WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json* ./
-RUN npm ci
+RUN npm ci --omit=dev
# ── web ──────────────────────────────────────────────────────────────────────
# Minimal Next.js standalone runner. No database, no native modules.
@@ -37,6 +49,7 @@ CMD ["node", "server.js"]
# ── backend ──────────────────────────────────────────────────────────────────
# Hono API server + node-cron scheduler. Owns the SQLite database exclusively.
+# Runs compiled JS (no tsx/tsc at runtime).
FROM node:22-bookworm-slim AS backend
WORKDIR /app
@@ -45,11 +58,9 @@ ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
-COPY --from=backend-deps --chown=nextjs:nodejs /app/node_modules ./backend/node_modules
-COPY --chown=nextjs:nodejs backend/src ./backend/src
+COPY --from=backend-prod-deps --chown=nextjs:nodejs /app/backend/node_modules ./backend/node_modules
+COPY --from=backend-build --chown=nextjs:nodejs /app/backend/dist ./backend/dist
COPY --chown=nextjs:nodejs backend/package.json ./backend/package.json
-COPY --chown=nextjs:nodejs backend/tsconfig.json ./backend/tsconfig.json
-COPY --chown=nextjs:nodejs lib ./lib
RUN mkdir -p /app/backend/data && chown nextjs:nodejs /app/backend/data
VOLUME ["/app/backend/data"]
@@ -59,4 +70,4 @@ EXPOSE 3001
ENV PORT=3001
WORKDIR /app/backend
-CMD ["npx", "tsx", "src/index.ts"]
+CMD ["node", "dist/backend/src/index.js"]
diff --git a/README.md b/README.md
index f03512f..e50075e 100644
--- a/README.md
+++ b/README.md
@@ -135,18 +135,24 @@ Images are built and pushed automatically by CI on every push to `main`.
### Environment variables
+See [`.env.example`](.env.example) for the full list and defaults.
+
**web:**
| Variable | Default | Description |
|----------|---------|-------------|
-| `BACKEND_URL` | `http://backend:3001` | Backend API base URL (Docker internal networking) |
+| `BACKEND_URL` | _(required in prod)_ | Backend API base URL. Throws at startup if unset when `NODE_ENV=production`. |
+| `NEXT_PUBLIC_PLAUSIBLE_SRC` | — | Plausible script URL. Analytics only render when both this and the website ID are set. |
+| `NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID` | — | Plausible website ID. |
**backend:**
| Variable | Default | Description |
|----------|---------|-------------|
-| `TZ` | `UTC` | Timezone for cron schedules (e.g. `America/New_York`) |
-| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched |
+| `PORT` | `3001` | Port the Hono server listens on. |
+| `TZ` | `UTC` | Timezone for cron schedules (e.g. `America/New_York`). |
+| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched. |
+| `RATE_LIMIT_PER_MIN` | `60` | Per-IP request limit for the public API, per minute. |
### Updating
diff --git a/app/error.tsx b/app/error.tsx
new file mode 100644
index 0000000..9c51f6b
--- /dev/null
+++ b/app/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect } from "react";
+
+export default function RootError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+
+ Something went wrong
+
+
+ An unexpected error broke this page. Try again in a moment.
+
+
+
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 123edec..472371c 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
import Script from "next/script";
import "./globals.css";
+const PLAUSIBLE_SRC = process.env.NEXT_PUBLIC_PLAUSIBLE_SRC;
+const PLAUSIBLE_WEBSITE_ID = process.env.NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID;
+
export const metadata: Metadata = {
title: "Thoosie Calendar",
description: "Theme park operating hours and live ride status at a glance",
@@ -12,11 +15,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
-
+ {PLAUSIBLE_SRC && PLAUSIBLE_WEBSITE_ID ? (
+
+ ) : null}
);
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..6d4df7b
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+
+
+
404
+
+ Page not found
+
+
+ We couldn't find what you were looking for.
+
+
+ Back to the calendar
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 423b82c..4bf2561 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,8 +1,10 @@
+import type { ComponentProps } from "react";
import { cookies } from "next/headers";
import { HomePageClient } from "@/components/HomePageClient";
import { getTodayLocal, formatDateLocal } from "@/lib/env";
+import { apiFetch } from "@/lib/api";
-const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
+type WeekData = ComponentProps;
const WEEK_COOKIE = "tcWeek";
@@ -24,10 +26,25 @@ export default async function HomePage() {
const saved = (await cookies()).get(WEEK_COOKIE)?.value;
const weekStart = getWeekStart(saved);
- const data = await fetch(
- `${BACKEND_URL}/api/calendar/week?start=${weekStart}`,
- { next: { revalidate: 120 } },
- ).then((r) => r.json());
+ const data = await apiFetch(
+ `/api/calendar/week?start=${weekStart}`,
+ { revalidate: 120 },
+ );
+
+ if (!data) {
+ return (
+
+
+
+ Calendar data is unavailable
+
+
+ We could not reach the backend. Refresh in a moment.
+
+
+
+ );
+ }
return ;
}
diff --git a/app/park/[id]/error.tsx b/app/park/[id]/error.tsx
new file mode 100644
index 0000000..f9448ce
--- /dev/null
+++ b/app/park/[id]/error.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useEffect } from "react";
+import Link from "next/link";
+
+export default function ParkError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+
+ Could not load this park
+
+
+ Try again in a moment, or head back to the calendar.
+
+
+
+
+ Back to calendar
+
+
+
+
+ );
+}
diff --git a/app/park/[id]/loading.tsx b/app/park/[id]/loading.tsx
new file mode 100644
index 0000000..272997f
--- /dev/null
+++ b/app/park/[id]/loading.tsx
@@ -0,0 +1,35 @@
+export default function ParkLoading() {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/park/[id]/page.tsx b/app/park/[id]/page.tsx
index fd558b3..84d11a6 100644
--- a/app/park/[id]/page.tsx
+++ b/app/park/[id]/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import { BackToCalendarLink } from "@/components/BackToCalendarLink";
import { notFound } from "next/navigation";
import { PARK_MAP } from "@/lib/parks";
@@ -6,14 +7,45 @@ import { LiveRidePanel } from "@/components/LiveRidePanel";
import type { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes";
import { getTodayLocal } from "@/lib/env";
-
-const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
+import { apiFetch } from "@/lib/api";
+import type { DayData } from "@/lib/types";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ month?: string }>;
}
+interface CalendarMonthResponse {
+ parkId: string;
+ year: number;
+ month: number;
+ monthData: Record;
+ today: string;
+}
+
+interface RidesResponse {
+ parkId: string;
+ today: string;
+ parkOpenToday: boolean;
+ withinWindow: boolean;
+ isWeatherDelay: boolean;
+ liveRides: LiveRidesResult | null;
+ scheduleFallback: RidesFetchResult | null;
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { id } = await params;
+ const park = PARK_MAP.get(id);
+ if (!park) return { title: "Park not found | Thoosie Calendar" };
+ const title = `${park.name} | Thoosie Calendar`;
+ const description = `Operating hours and live ride status for ${park.name} (${park.location.city}, ${park.location.state}).`;
+ return {
+ title,
+ description,
+ openGraph: { title, description },
+ };
+}
+
function parseMonthParam(param: string | undefined): string {
if (param && /^\d{4}-\d{2}$/.test(param)) {
const [y, m] = param.split("-").map(Number);
@@ -35,26 +67,25 @@ export default async function ParkPage({ params, searchParams }: PageProps) {
const [year, month] = monthStr.split("-").map(Number);
const [calendarData, ridesData] = await Promise.all([
- fetch(`${BACKEND_URL}/api/calendar/${id}/month?month=${monthStr}`, {
- next: { revalidate: 300 },
- }).then((r) => r.json()),
- fetch(`${BACKEND_URL}/api/parks/${id}/rides`, {
- next: { revalidate: 60 },
- }).then((r) => r.json()),
+ apiFetch(
+ `/api/calendar/${id}/month?month=${monthStr}`,
+ { revalidate: 300 },
+ ),
+ apiFetch(
+ `/api/parks/${id}/rides`,
+ { revalidate: 60 },
+ ),
]);
+ if (!calendarData) {
+ return ;
+ }
+
const { monthData, today } = calendarData;
- const {
- parkOpenToday,
- isWeatherDelay,
- liveRides,
- scheduleFallback: ridesResult,
- }: {
- parkOpenToday: boolean;
- isWeatherDelay: boolean;
- liveRides: LiveRidesResult | null;
- scheduleFallback: RidesFetchResult | null;
- } = ridesData;
+ const parkOpenToday = ridesData?.parkOpenToday ?? false;
+ const isWeatherDelay = ridesData?.isWeatherDelay ?? false;
+ const liveRides = ridesData?.liveRides ?? null;
+ const ridesResult = ridesData?.scheduleFallback ?? null;
return (
@@ -355,3 +386,18 @@ function Callout({ children }: { children: React.ReactNode }) {
);
}
+
+function DataUnavailable({ parkName }: { parkName: string }) {
+ return (
+
+
+
+ {parkName} data is unavailable
+
+
+ We could not reach the backend. Refresh in a moment.
+
+
+
+ );
+}
diff --git a/app/park/[id]/ride/[slug]/error.tsx b/app/park/[id]/ride/[slug]/error.tsx
new file mode 100644
index 0000000..f3435a0
--- /dev/null
+++ b/app/park/[id]/ride/[slug]/error.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect } from "react";
+
+export default function RideError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+
+ Could not load ride history
+
+
+ The backend is unreachable. Try again in a moment.
+
+
+
+
+ );
+}
diff --git a/app/park/[id]/ride/[slug]/loading.tsx b/app/park/[id]/ride/[slug]/loading.tsx
new file mode 100644
index 0000000..0ebeb34
--- /dev/null
+++ b/app/park/[id]/ride/[slug]/loading.tsx
@@ -0,0 +1,17 @@
+export default function RideLoading() {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/park/[id]/ride/[slug]/page.tsx b/app/park/[id]/ride/[slug]/page.tsx
index 775aa7f..ccb9b32 100644
--- a/app/park/[id]/ride/[slug]/page.tsx
+++ b/app/park/[id]/ride/[slug]/page.tsx
@@ -1,11 +1,11 @@
+import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PARK_MAP } from "@/lib/parks";
import UptimePill from "@/components/charts/UptimePill";
import WaitTimeTodayChart from "@/components/charts/WaitTimeTodayChart";
import WeeklyStatsChart from "@/components/charts/WeeklyStatsChart";
-
-const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
+import { getBackendUrl } from "@/lib/api";
type Tab = "today" | "7d" | "30d";
@@ -62,6 +62,20 @@ function parseTab(raw: string | undefined): Tab {
return "today";
}
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { id, slug } = await params;
+ const park = PARK_MAP.get(id);
+ if (!park) return { title: "Ride not found | Thoosie Calendar" };
+ const rideName = decodeURIComponent(slug).replace(/-/g, " ");
+ const title = `${rideName} — ${park.shortName} | Thoosie Calendar`;
+ const description = `Live wait time and uptime history for ${rideName} at ${park.name}.`;
+ return {
+ title,
+ description,
+ openGraph: { title, description },
+ };
+}
+
export default async function RideDetailPage({ params, searchParams }: PageProps) {
const { id, slug } = await params;
const { tab: tabParam } = await searchParams;
@@ -71,9 +85,14 @@ export default async function RideDetailPage({ params, searchParams }: PageProps
const tab = parseTab(tabParam);
- const res = await fetch(`${BACKEND_URL}/api/parks/${id}/rides/${slug}`, {
- next: { revalidate: 60 },
- });
+ let res: Response;
+ try {
+ res = await fetch(`${getBackendUrl()}/api/parks/${id}/rides/${slug}`, {
+ next: { revalidate: 60 },
+ });
+ } catch {
+ return ;
+ }
if (res.status === 404) {
return ;
@@ -83,7 +102,12 @@ export default async function RideDetailPage({ params, searchParams }: PageProps
return ;
}
- const data: ApiResponse = await res.json();
+ let data: ApiResponse;
+ try {
+ data = (await res.json()) as ApiResponse;
+ } catch {
+ return ;
+ }
const { ride, live, today, last7d, last30d, coverage } = data;
const last30dUptime = last30d.length
diff --git a/backend/package.json b/backend/package.json
index a7f39a2..3dca581 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -5,7 +5,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
- "start": "node dist/index.js",
+ "start": "node dist/backend/src/index.js",
"typecheck": "tsc --noEmit",
"test": "tsx --test tests/*.test.ts"
},
diff --git a/backend/src/config.ts b/backend/src/config.ts
new file mode 100644
index 0000000..0d2a173
--- /dev/null
+++ b/backend/src/config.ts
@@ -0,0 +1,25 @@
+/**
+ * Centralized env parsing — validate at startup, fail fast on bad config.
+ * Other modules read from the frozen `config` object instead of process.env
+ * so misconfiguration shows up here, not deep in a request handler.
+ */
+
+import { parseStalenessHours } from "../../lib/env";
+
+function parsePort(raw: string | undefined, fallback: number): number {
+ if (!raw) return fallback;
+ const n = parseInt(raw, 10);
+ if (!Number.isFinite(n) || n < 1 || n > 65535) {
+ throw new Error(`Invalid PORT=${raw}: must be an integer in 1..65535`);
+ }
+ return n;
+}
+
+export const config = Object.freeze({
+ port: parsePort(process.env.PORT, 3001),
+ parkHoursStalenessHours: parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72),
+ nodeEnv: process.env.NODE_ENV ?? "development",
+ rateLimitPerMin: parsePort(process.env.RATE_LIMIT_PER_MIN, 60),
+});
+
+export type Config = typeof config;
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 2bdfa44..9887a98 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -1,10 +1,12 @@
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { cors } from "hono/cors";
-import { logger } from "hono/logger";
-import { getDb } from "./db/index";
+import { config } from "./config";
+import { log } from "./log";
+import { getDb, closeDb } from "./db/index";
import { startScheduler } from "./services/scheduler";
+import { rateLimit } from "./middleware/rate-limit";
import calendarRoutes from "./routes/calendar";
import parksRoutes from "./routes/parks";
@@ -13,12 +15,18 @@ import rideHistoryRoutes from "./routes/ride-history";
import statusRoutes from "./routes/status";
import scrapeRoutes from "./routes/scrape";
-const PORT = parseInt(process.env.PORT ?? "3001", 10);
-
const app = new Hono();
-app.use("*", logger());
+app.use("*", async (c, next) => {
+ const start = Date.now();
+ await next();
+ log.info("http", `${c.req.method} ${c.req.path}`, {
+ status: c.res.status,
+ ms: Date.now() - start,
+ });
+});
app.use("*", cors());
+app.use("*", rateLimit(config.rateLimitPerMin));
app.route("/api/calendar", calendarRoutes);
app.route("/api/parks", parksRoutes);
@@ -27,14 +35,52 @@ app.route("/api/parks", rideHistoryRoutes);
app.route("/api/status", statusRoutes);
app.route("/api/scrape", scrapeRoutes);
-// Initialize database on startup
-getDb();
-console.log("[backend] database initialized");
+log.info("startup", "config loaded", {
+ port: config.port,
+ nodeEnv: config.nodeEnv,
+ parkHoursStalenessHours: config.parkHoursStalenessHours,
+ rateLimitPerMin: config.rateLimitPerMin,
+});
+
+getDb();
+log.info("startup", "database initialized");
-// Start cron scheduler
startScheduler();
-// Start HTTP server
-serve({ fetch: app.fetch, port: PORT }, (info) => {
- console.log(`[backend] listening on http://localhost:${info.port}`);
+const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
+ log.info("startup", "listening", { url: `http://localhost:${info.port}` });
+});
+
+let shuttingDown = false;
+function shutdown(signal: string): void {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ log.info("shutdown", "signal received", { signal });
+
+ const forceExit = setTimeout(() => {
+ log.error("shutdown", "force-exiting after 5s grace period");
+ process.exit(1);
+ }, 5000);
+ forceExit.unref();
+
+ server.close((err) => {
+ if (err) log.error("shutdown", "http server close error", { err: err.message });
+ else log.info("shutdown", "http server closed");
+ try {
+ closeDb();
+ log.info("shutdown", "database closed");
+ } catch (err) {
+ log.error("shutdown", "database close error", { err: (err as Error).message });
+ }
+ process.exit(0);
+ });
+}
+
+process.on("SIGTERM", () => shutdown("SIGTERM"));
+process.on("SIGINT", () => shutdown("SIGINT"));
+process.on("unhandledRejection", (reason) => {
+ log.error("process", "unhandledRejection", { reason: String(reason) });
+});
+process.on("uncaughtException", (err) => {
+ log.error("process", "uncaughtException", { err: err.message, stack: err.stack });
});
diff --git a/backend/src/log.ts b/backend/src/log.ts
new file mode 100644
index 0000000..fbcf75d
--- /dev/null
+++ b/backend/src/log.ts
@@ -0,0 +1,30 @@
+/**
+ * Tiny structured logger. Emits `[ISO] [LEVEL] [tag] msg key=value...` so logs
+ * are searchable and grep-friendly without dragging in pino/winston.
+ */
+
+type Meta = Record;
+type Level = "INFO" | "WARN" | "ERROR";
+
+function formatMeta(meta?: Meta): string {
+ if (!meta) return "";
+ const parts: string[] = [];
+ for (const [k, v] of Object.entries(meta)) {
+ if (v === undefined) continue;
+ const s = typeof v === "string" ? v : JSON.stringify(v);
+ parts.push(`${k}=${s}`);
+ }
+ return parts.length ? " " + parts.join(" ") : "";
+}
+
+function emit(level: Level, tag: string, msg: string, meta?: Meta): void {
+ const line = `${new Date().toISOString()} [${level}] [${tag}] ${msg}${formatMeta(meta)}`;
+ if (level === "ERROR") console.error(line);
+ else console.log(line);
+}
+
+export const log = {
+ info: (tag: string, msg: string, meta?: Meta) => emit("INFO", tag, msg, meta),
+ warn: (tag: string, msg: string, meta?: Meta) => emit("WARN", tag, msg, meta),
+ error: (tag: string, msg: string, meta?: Meta) => emit("ERROR", tag, msg, meta),
+};
diff --git a/backend/src/middleware/rate-limit.ts b/backend/src/middleware/rate-limit.ts
new file mode 100644
index 0000000..b7b130d
--- /dev/null
+++ b/backend/src/middleware/rate-limit.ts
@@ -0,0 +1,59 @@
+/**
+ * Simple IP-based rate limiter. Fixed-window counter in a Map, swept on
+ * each request — no external dependency, sufficient for a single-instance
+ * public site. Keys honour x-forwarded-for so a reverse proxy doesn't
+ * collapse every client to one bucket.
+ */
+
+import type { MiddlewareHandler } from "hono";
+import { log } from "../log";
+
+interface Bucket {
+ count: number;
+ resetAt: number;
+}
+
+const WINDOW_MS = 60_000;
+
+function clientIp(c: Parameters[0]): string {
+ const fwd = c.req.header("x-forwarded-for");
+ if (fwd) return fwd.split(",")[0].trim();
+ const real = c.req.header("x-real-ip");
+ if (real) return real.trim();
+ // @hono/node-server attaches the raw connection on c.env.incoming
+ const incoming = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined)?.incoming;
+ return incoming?.socket?.remoteAddress ?? "unknown";
+}
+
+export function rateLimit(limitPerMin: number): MiddlewareHandler {
+ const buckets = new Map();
+
+ return async (c, next) => {
+ const now = Date.now();
+ const ip = clientIp(c);
+ let bucket = buckets.get(ip);
+
+ if (!bucket || bucket.resetAt <= now) {
+ bucket = { count: 0, resetAt: now + WINDOW_MS };
+ buckets.set(ip, bucket);
+ }
+
+ bucket.count++;
+
+ if (bucket.count > limitPerMin) {
+ const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
+ log.warn("rate-limit", "blocked", { ip, count: bucket.count, retryAfter });
+ c.header("Retry-After", String(retryAfter));
+ return c.json({ error: "Too many requests" }, 429);
+ }
+
+ // Opportunistic cleanup so the Map doesn't grow unbounded.
+ if (buckets.size > 10_000) {
+ for (const [k, v] of buckets) {
+ if (v.resetAt <= now) buckets.delete(k);
+ }
+ }
+
+ await next();
+ };
+}
diff --git a/backend/src/routes/calendar.ts b/backend/src/routes/calendar.ts
index 591c64f..1484e36 100644
--- a/backend/src/routes/calendar.ts
+++ b/backend/src/routes/calendar.ts
@@ -7,8 +7,14 @@ import { fetchToday } from "../../../lib/scrapers/sixflags";
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
import { getDateRange, getParkMonthData, type DayData } from "../db/queries";
import { TtlCache } from "../services/cache";
+import { log } from "../log";
-const todayCache = new TtlCache<{ date: string; isOpen: boolean; hoursLabel?: string; specialType?: string } | null>(5 * 60 * 1000);
+type TodayCacheValue = { date: string; isOpen: boolean; hoursLabel?: string; specialType?: string } | null;
+const todayCache = new TtlCache(5 * 60 * 1000);
+// Tracks parks we've already attempted this TTL window so a null cache hit
+// doesn't re-fetch on every request. Same TTL as todayCache so they expire
+// together.
+const todayChecked = new TtlCache(5 * 60 * 1000);
const ridesCache = new TtlCache<{ openRides: number; openCoasters: number } | null>(5 * 60 * 1000);
const app = new Hono();
@@ -34,10 +40,13 @@ app.get("/week", async (c) => {
await Promise.all(
PARKS.map(async (p) => {
let live = todayCache.get(p.id);
- if (live === null && !todayCache.get(p.id + "_checked")) {
- live = await fetchToday(p.apiId).catch(() => null);
+ if (live === null && todayChecked.get(p.id) === null) {
+ live = await fetchToday(p.apiId).catch((err: Error) => {
+ log.warn("calendar.week", "fetchToday failed", { park: p.id, err: err.message });
+ return null;
+ });
todayCache.set(p.id, live);
- todayCache.set(p.id + "_checked", true as any);
+ todayChecked.set(p.id, true);
}
if (!live) return;
if (!data[p.id]) data[p.id] = {};
@@ -84,7 +93,10 @@ app.get("/week", async (c) => {
let cached = ridesCache.get(p.id);
if (cached === null) {
const coasterSet = getCoasterSet(p.id);
- const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet).catch(() => null);
+ const result = await fetchLiveRides(QUEUE_TIMES_IDS[p.id], coasterSet).catch((err: Error) => {
+ log.warn("calendar.week", "fetchLiveRides failed", { park: p.id, err: err.message });
+ return null;
+ });
cached = result
? {
openRides: result.rides.filter((r) => r.isOpen).length,
@@ -143,7 +155,10 @@ app.get("/:parkId/month", async (c) => {
// Merge live today if viewing current month
const park = PARKS.find((p) => p.id === parkId);
if (park) {
- const liveToday = await fetchToday(park.apiId).catch(() => null);
+ const liveToday = await fetchToday(park.apiId).catch((err: Error) => {
+ log.warn("calendar.month", "fetchToday failed", { park: park.id, err: err.message });
+ return null;
+ });
if (liveToday) {
monthData[today] = {
isOpen: liveToday.isOpen,
diff --git a/backend/src/routes/rides.ts b/backend/src/routes/rides.ts
index 47856b8..43fbdbb 100644
--- a/backend/src/routes/rides.ts
+++ b/backend/src/routes/rides.ts
@@ -10,6 +10,7 @@ import { getDayData } from "../db/queries";
import { liveRidesCache, fastLaneCache } from "../services/live-cache";
import { slugifyRideName } from "../../../lib/ride-slug";
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
+import { log } from "../log";
const app = new Hono();
@@ -31,7 +32,10 @@ app.get("/:id/rides", async (c) => {
liveRides = liveRidesCache.get(id);
if (liveRides === null) {
const coasterSet = getCoasterSet(id);
- liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch(() => null);
+ liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch((err: Error) => {
+ log.warn("rides", "fetchLiveRides failed", { park: id, err: err.message });
+ return null;
+ });
if (liveRides) liveRidesCache.set(id, liveRides);
}
@@ -46,7 +50,10 @@ app.get("/:id/rides", async (c) => {
if (liveRides) {
let fastLane = fastLaneCache.get(id);
if (fastLane === null) {
- fastLane = await fetchFastLaneWaits(park.apiId).catch(() => null);
+ fastLane = await fetchFastLaneWaits(park.apiId).catch((err: Error) => {
+ log.warn("rides", "fetchFastLaneWaits failed", { park: id, err: err.message });
+ return null;
+ });
if (fastLane) fastLaneCache.set(id, fastLane);
}
if (fastLane) {
@@ -81,7 +88,10 @@ app.get("/:id/rides", async (c) => {
let scheduleFallback = null;
if (!liveRides) {
- scheduleFallback = await scrapeRidesForDay(park.apiId, today).catch(() => null);
+ scheduleFallback = await scrapeRidesForDay(park.apiId, today).catch((err: Error) => {
+ log.warn("rides", "scrapeRidesForDay failed", { park: id, err: err.message });
+ return null;
+ });
}
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts
index afcfc8d..4cbd889 100644
--- a/backend/src/services/scheduler.ts
+++ b/backend/src/services/scheduler.ts
@@ -2,68 +2,100 @@ import cron from "node-cron";
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
import { sampleAllOpenParks } from "./wait-sampler";
import { getParkDayCount } from "../db/queries";
+import { log } from "../log";
let initialized = false;
+/**
+ * Wrap a cron handler so a still-running prior tick is skipped instead of
+ * racing it. Each tier gets its own latch — better-sqlite3's per-statement
+ * locking handles row-level safety, but skipping overlap avoids redundant
+ * upstream API calls and the resulting rate-limit risk.
+ */
+function withLatch(tag: string, fn: () => Promise): () => Promise {
+ let running = false;
+ return async () => {
+ if (running) {
+ log.warn(tag, "previous run still in progress, skipping tick");
+ return;
+ }
+ running = true;
+ try {
+ await fn();
+ } catch (err) {
+ log.error(tag, "tick failed", { err: (err as Error).message });
+ } finally {
+ running = false;
+ }
+ };
+}
+
export function startScheduler(): void {
if (initialized) return;
initialized = true;
- // Tier 1: Today — every hour during operating season (Mar-Dec)
- cron.schedule("0 * * 3-12 *", async () => {
- console.log(`[scheduler] tier-1: scraping today @ ${new Date().toISOString()}`);
- await scrapeToday().catch((err) => console.error("[scheduler] tier-1 error:", err));
- });
+ cron.schedule(
+ "0 * * 3-12 *",
+ withLatch("scheduler.tier1", async () => {
+ log.info("scheduler.tier1", "scraping today");
+ await scrapeToday();
+ }),
+ );
- // Tier 2: This week — every 6 hours, current month for all parks
- cron.schedule("0 */6 * * *", async () => {
- console.log(`[scheduler] tier-2: scraping current month @ ${new Date().toISOString()}`);
- await scrapeCurrentMonth().catch((err) => console.error("[scheduler] tier-2 error:", err));
- });
+ cron.schedule(
+ "0 */6 * * *",
+ withLatch("scheduler.tier2", async () => {
+ log.info("scheduler.tier2", "scraping current month");
+ await scrapeCurrentMonth();
+ }),
+ );
- // Tier 3: Upcoming — twice daily (3 AM, 3 PM), current + next month
- cron.schedule("0 3,15 * * *", async () => {
- console.log(`[scheduler] tier-3: scraping upcoming months @ ${new Date().toISOString()}`);
- await scrapeUpcomingMonths().catch((err) => console.error("[scheduler] tier-3 error:", err));
- });
+ cron.schedule(
+ "0 3,15 * * *",
+ withLatch("scheduler.tier3", async () => {
+ log.info("scheduler.tier3", "scraping upcoming months");
+ await scrapeUpcomingMonths();
+ }),
+ );
- // Tier 4: Full season — once daily at 3 AM
- cron.schedule("0 3 * * *", async () => {
- console.log(`[scheduler] tier-4: scraping full year @ ${new Date().toISOString()}`);
- await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err));
- });
+ cron.schedule(
+ "0 3 * * *",
+ withLatch("scheduler.tier4", async () => {
+ log.info("scheduler.tier4", "scraping full year");
+ await scrapeFullYear();
+ }),
+ );
- // Tier 5: Wait-time samples — every 5 minutes for parks open today
- cron.schedule("*/5 * * * *", async () => {
- try {
+ cron.schedule(
+ "*/5 * * * *",
+ withLatch("scheduler.tier5", async () => {
const r = await sampleAllOpenParks();
- console.log(
- `[scheduler] tier-5: sampled ${r.parksSampled} parks, ${r.samplesWritten} samples, ` +
- `${r.weatherDelayed} weather-delayed, ${r.errors} errors`,
- );
- } catch (err) {
- console.error("[scheduler] tier-5 error:", err);
- }
- });
+ log.info("scheduler.tier5", "sample run complete", {
+ parksSampled: r.parksSampled,
+ samplesWritten: r.samplesWritten,
+ weatherDelayed: r.weatherDelayed,
+ errors: r.errors,
+ });
+ }),
+ );
- console.log("[scheduler] cron jobs registered");
- console.log(" tier-1: today — hourly (Mar-Dec)");
- console.log(" tier-2: current month — every 6h");
- console.log(" tier-3: upcoming — 3 AM + 3 PM");
- console.log(" tier-4: full year — 3 AM daily");
- console.log(" tier-5: wait samples — every 5 min");
+ log.info("scheduler", "cron jobs registered", {
+ tiers: "tier1=hourly(Mar-Dec) tier2=6h tier3=3am+3pm tier4=3am-daily tier5=5min",
+ });
const existingRows = getParkDayCount();
if (existingRows < 50) {
- console.log(`[scheduler] DB has ${existingRows} rows — running startup scrape`);
+ log.info("scheduler", "running startup scrape", { existingRows });
scrapeToday()
.then((r) => {
- console.log(`[scheduler] startup today: ${r.fetched} fetched, ${r.updated} updated, ${r.errors} errors`);
+ log.info("scheduler.startup", "today done", { fetched: r.fetched, updated: r.updated, errors: r.errors });
return scrapeFullYear();
})
- .then((r) => console.log(`[scheduler] startup full-year: ${r.fetched} fetched, ${r.skipped} skipped, ${r.errors} errors`))
- .catch((err) => console.error("[scheduler] startup scrape error:", err));
+ .then((r) => {
+ log.info("scheduler.startup", "full-year done", { fetched: r.fetched, skipped: r.skipped, errors: r.errors });
+ })
+ .catch((err) => log.error("scheduler.startup", "scrape failed", { err: (err as Error).message }));
} else {
- console.log(`[scheduler] DB has ${existingRows} rows — skipping startup scrape, relying on cron`);
+ log.info("scheduler", "skipping startup scrape — relying on cron", { existingRows });
}
}
diff --git a/backend/src/services/scraper.ts b/backend/src/services/scraper.ts
index 62b2d67..96cdc4e 100644
--- a/backend/src/services/scraper.ts
+++ b/backend/src/services/scraper.ts
@@ -1,10 +1,11 @@
import { PARKS } from "../../../lib/parks";
import { scrapeMonth, fetchToday, RateLimitError } from "../../../lib/scrapers/sixflags";
import { upsertDay, isMonthScraped, getDayData, transact } from "../db/queries";
-import { parseStalenessHours } from "../../../lib/env";
+import { config } from "../config";
+import { log } from "../log";
const DELAY_MS = 1000;
-const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000;
+const STALE_AFTER_MS = config.parkHoursStalenessHours * 60 * 60 * 1000;
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
@@ -54,9 +55,17 @@ export async function scrapeToday(): Promise {
upsertDay(park.id, live.date, live.isOpen, live.hoursLabel, live.specialType);
updated++;
- console.log(`[today] ${park.shortName}: updated (${live.isOpen ? "open" : "closed"}${live.hoursLabel ? " " + live.hoursLabel : ""})`);
- } catch {
+ log.info("scrape.today", "updated", {
+ park: park.shortName,
+ isOpen: live.isOpen,
+ hours: live.hoursLabel ?? null,
+ });
+ } catch (err) {
errors++;
+ log.warn("scrape.today", "park failed", {
+ park: park.shortName,
+ err: (err as Error).message,
+ });
}
await sleep(500);
}
@@ -71,7 +80,7 @@ export async function scrapeToday(): Promise {
finishedAt: new Date().toISOString(),
};
lastScrapeResult = result;
- console.log(`[today] done: ${fetched} fetched, ${updated} updated, ${skipped} skipped, ${errors} errors`);
+ log.info("scrape.today", "done", { fetched, updated, skipped, errors });
return result;
}
@@ -96,14 +105,22 @@ export async function scrapeMonths(monthList: { year: number; month: number }[],
}
});
fetched++;
- console.log(`[month] ${park.shortName} ${year}-${String(month).padStart(2, "0")}: ${days.filter((d) => d.isOpen).length} open days`);
+ log.info("scrape.month", "scraped", {
+ park: park.shortName,
+ month: `${year}-${String(month).padStart(2, "0")}`,
+ openDays: days.filter((d) => d.isOpen).length,
+ });
} catch (err) {
- if (err instanceof RateLimitError) {
- console.log(`[month] ${park.shortName}: rate limited`);
- } else {
- console.error(`[month] ${park.shortName}: error — ${err instanceof Error ? err.message : err}`);
- }
errors++;
+ if (err instanceof RateLimitError) {
+ log.warn("scrape.month", "rate limited", { park: park.shortName });
+ } else {
+ log.error("scrape.month", "failed", {
+ park: park.shortName,
+ month: `${year}-${String(month).padStart(2, "0")}`,
+ err: err instanceof Error ? err.message : String(err),
+ });
+ }
}
await sleep(DELAY_MS);
}
@@ -119,7 +136,7 @@ export async function scrapeMonths(monthList: { year: number; month: number }[],
finishedAt: new Date().toISOString(),
};
lastScrapeResult = result;
- console.log(`[month] done: ${fetched} fetched, ${skipped} skipped, ${errors} errors`);
+ log.info("scrape.month", "done", { fetched, skipped, errors });
return result;
}
diff --git a/backend/src/services/wait-sampler.ts b/backend/src/services/wait-sampler.ts
index 858cf0b..0f6890e 100644
--- a/backend/src/services/wait-sampler.ts
+++ b/backend/src/services/wait-sampler.ts
@@ -26,6 +26,7 @@ import { formatLocalDate, formatLocalTime } from "../../../lib/timezone";
import { isWithinOperatingWindow } from "../../../lib/env";
import { liveRidesCache, fastLaneCache } from "./live-cache";
import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
+import { log } from "../log";
const PARALLEL_CHUNK = 6;
@@ -54,7 +55,10 @@ async function samplePark(park: Park, now: Date): Promise<{
let liveRides = liveRidesCache.get(park.id);
if (liveRides === null) {
const coasterSet = getCoasterSet(park.id);
- liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch(() => null);
+ liveRides = await fetchLiveRides(queueTimesId, coasterSet).catch((err: Error) => {
+ log.warn("wait-sampler", "fetchLiveRides failed", { park: park.id, err: err.message });
+ return null;
+ });
if (liveRides) liveRidesCache.set(park.id, liveRides);
}
if (!liveRides || liveRides.rides.length === 0) {
@@ -70,7 +74,10 @@ async function samplePark(park: Park, now: Date): Promise<{
// Fast Lane — reuse cache; fetch on miss.
let fastLane = fastLaneCache.get(park.id);
if (fastLane === null) {
- fastLane = await fetchFastLaneWaits(park.apiId).catch(() => null);
+ fastLane = await fetchFastLaneWaits(park.apiId).catch((err: Error) => {
+ log.warn("wait-sampler", "fetchFastLaneWaits failed", { park: park.id, err: err.message });
+ return null;
+ });
if (fastLane) fastLaneCache.set(park.id, fastLane);
}
@@ -117,7 +124,10 @@ async function samplePark(park: Park, now: Date): Promise<{
return { ridesUpserted, samplesWritten, weatherDelayed: false, error: false };
} catch (err) {
- console.error(`[wait-sampler] error sampling ${park.id}:`, err);
+ log.error("wait-sampler", "sampling failed", {
+ park: park.id,
+ err: (err as Error).message,
+ });
return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: true };
}
}
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
index 55e821d..76d681e 100644
--- a/backend/tsconfig.json
+++ b/backend/tsconfig.json
@@ -17,5 +17,5 @@
"sourceMap": true
},
"include": ["src/**/*.ts", "../lib/**/*.ts"],
- "exclude": ["node_modules", "dist"]
+ "exclude": ["node_modules", "dist", "../lib/api.ts"]
}
diff --git a/docker-compose.yml b/docker-compose.yml
index aa5cb3b..2b91434 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,21 @@ services:
- BACKEND_URL=http://backend:3001
- TZ=America/New_York
restart: unless-stopped
+ mem_limit: 512m
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 20s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+ depends_on:
+ backend:
+ condition: service_healthy
backend:
image: gitea.thewrightserver.net/josh/thoosiecalendar:backend
@@ -20,6 +35,18 @@ services:
- TZ=America/New_York
- PARK_HOURS_STALENESS_HOURS=72
restart: unless-stopped
+ mem_limit: 512m
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/api/status"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 30s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
volumes:
park_data:
diff --git a/docs/wait-times-urls.txt b/docs/wait-times-urls.txt
new file mode 100644
index 0000000..de91b3c
--- /dev/null
+++ b/docs/wait-times-urls.txt
@@ -0,0 +1,30 @@
+Six Flags / Cedar Fair live wait-times endpoints
+Pattern: https://d18car1k0ff81h.cloudfront.net/wait-times/park/{apiId}
+
+# Six Flags branded parks
+Six Flags Great Adventure https://d18car1k0ff81h.cloudfront.net/wait-times/park/905
+Six Flags Magic Mountain https://d18car1k0ff81h.cloudfront.net/wait-times/park/906
+Six Flags Great America https://d18car1k0ff81h.cloudfront.net/wait-times/park/910
+Six Flags Over Georgia https://d18car1k0ff81h.cloudfront.net/wait-times/park/902
+Six Flags Over Texas https://d18car1k0ff81h.cloudfront.net/wait-times/park/901
+Six Flags St. Louis https://d18car1k0ff81h.cloudfront.net/wait-times/park/903
+Six Flags Fiesta Texas https://d18car1k0ff81h.cloudfront.net/wait-times/park/914
+Six Flags New England https://d18car1k0ff81h.cloudfront.net/wait-times/park/935
+Six Flags Discovery Kingdom https://d18car1k0ff81h.cloudfront.net/wait-times/park/936
+Six Flags Mexico https://d18car1k0ff81h.cloudfront.net/wait-times/park/960
+Six Flags Great Escape https://d18car1k0ff81h.cloudfront.net/wait-times/park/924
+Six Flags Darien Lake https://d18car1k0ff81h.cloudfront.net/wait-times/park/945
+
+# Former Cedar Fair theme parks
+Cedar Point https://d18car1k0ff81h.cloudfront.net/wait-times/park/1
+Knott's Berry Farm https://d18car1k0ff81h.cloudfront.net/wait-times/park/4
+Canada's Wonderland https://d18car1k0ff81h.cloudfront.net/wait-times/park/40
+Carowinds https://d18car1k0ff81h.cloudfront.net/wait-times/park/30
+Kings Dominion https://d18car1k0ff81h.cloudfront.net/wait-times/park/25
+Kings Island https://d18car1k0ff81h.cloudfront.net/wait-times/park/20
+Valleyfair https://d18car1k0ff81h.cloudfront.net/wait-times/park/14
+Worlds of Fun https://d18car1k0ff81h.cloudfront.net/wait-times/park/6
+Michigan's Adventure https://d18car1k0ff81h.cloudfront.net/wait-times/park/12
+Dorney Park https://d18car1k0ff81h.cloudfront.net/wait-times/park/8
+California's Great America https://d18car1k0ff81h.cloudfront.net/wait-times/park/35
+Frontier City https://d18car1k0ff81h.cloudfront.net/wait-times/park/943
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000..488c20b
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,46 @@
+/**
+ * Single source of truth for the backend URL used by Next.js server
+ * components. Defaults to localhost in development; throws at *request time*
+ * in production if BACKEND_URL isn't set so a misdeployed container fails
+ * with a clear message instead of silently pointing at localhost. The check
+ * is lazy so Next.js build-time page-data collection doesn't trip it.
+ */
+
+let warned = false;
+
+export function getBackendUrl(): string {
+ const explicit = process.env.BACKEND_URL;
+ if (explicit) return explicit;
+ if (process.env.NODE_ENV === "production") {
+ throw new Error(
+ "BACKEND_URL env var is required in production. " +
+ "Set it to the backend service URL (e.g. http://backend:3001).",
+ );
+ }
+ if (!warned) {
+ warned = true;
+ console.warn("[lib/api] BACKEND_URL unset — defaulting to http://localhost:3001 (dev only)");
+ }
+ return "http://localhost:3001";
+}
+
+/**
+ * Fetch JSON from the backend with a default revalidate window. Returns
+ * `null` on network failure or non-2xx status — callers handle the null
+ * to render a graceful fallback instead of crashing the server render.
+ */
+export async function apiFetch(
+ path: string,
+ options: { revalidate?: number } = {},
+): Promise {
+ const { revalidate = 60 } = options;
+ try {
+ const res = await fetch(`${getBackendUrl()}${path}`, {
+ next: { revalidate },
+ });
+ if (!res.ok) return null;
+ return (await res.json()) as T;
+ } catch {
+ return null;
+ }
+}
diff --git a/package.json b/package.json
index cbc916f..2717612 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "typecheck": "tsc --noEmit",
"debug": "tsx scripts/debug.ts",
"test": "tsx --test tests/*.test.ts"
},