refactor: production-essentials hardening pass
Build and Deploy / Lint, typecheck, test (push) Successful in 30s
Build and Deploy / Build & Push (push) Successful in 1m39s

Backend: structured logger, env-validated config, graceful SIGTERM/SIGINT
shutdown, per-IP rate limiter, per-tier scheduler concurrency latch, error
context on previously-silent catches, compiled-JS Dockerfile stage.

Frontend: lib/api.ts consolidates BACKEND_URL with lazy production-required
check, root + per-segment error.tsx / not-found.tsx / loading.tsx,
generateMetadata on park and ride pages, graceful fallback when backend is
unreachable, Plausible script gated on env vars.

Infra: CI runs lint + typecheck + tests on both packages before docker build,
compose adds healthchecks, log rotation, and memory limits; .env.example
documents every variable.

Cleanup: removed empty app/api/parks/ dir and 0-byte root parks.db, moved
wait-times-urls.txt into docs/, dropped an `as any` cast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 10:17:52 -04:00
parent 6447db3008
commit 5d9daee627
30 changed files with 860 additions and 126 deletions
+22
View File
@@ -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
+36
View File
@@ -6,8 +6,44 @@ on:
- main - main
jobs: 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: build-push:
name: Build & Push name: Build & Push
needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+4
View File
@@ -38,6 +38,9 @@ yarn-error.log*
/backend/data/ /backend/data/
parks.db parks.db
# debug script artifacts
/debug/
# env files # env files
.env* .env*
!.env.example !.env.example
@@ -45,3 +48,4 @@ parks.db
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.gstack/
+19 -8
View File
@@ -6,13 +6,25 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# ── backend-deps: backend node_modules (better-sqlite3 needs build tools) ─── # ── backend-build: compile backend TypeScript to JS (better-sqlite3 build) ───
FROM node:22-bookworm-slim AS backend-deps FROM node:22-bookworm-slim AS backend-build
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \ RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /app 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* ./ COPY backend/package.json backend/package-lock.json* ./
RUN npm ci RUN npm ci --omit=dev
# ── web ────────────────────────────────────────────────────────────────────── # ── web ──────────────────────────────────────────────────────────────────────
# Minimal Next.js standalone runner. No database, no native modules. # Minimal Next.js standalone runner. No database, no native modules.
@@ -37,6 +49,7 @@ CMD ["node", "server.js"]
# ── backend ────────────────────────────────────────────────────────────────── # ── backend ──────────────────────────────────────────────────────────────────
# Hono API server + node-cron scheduler. Owns the SQLite database exclusively. # 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 FROM node:22-bookworm-slim AS backend
WORKDIR /app WORKDIR /app
@@ -45,11 +58,9 @@ ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
COPY --from=backend-deps --chown=nextjs:nodejs /app/node_modules ./backend/node_modules COPY --from=backend-prod-deps --chown=nextjs:nodejs /app/backend/node_modules ./backend/node_modules
COPY --chown=nextjs:nodejs backend/src ./backend/src 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/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 RUN mkdir -p /app/backend/data && chown nextjs:nodejs /app/backend/data
VOLUME ["/app/backend/data"] VOLUME ["/app/backend/data"]
@@ -59,4 +70,4 @@ EXPOSE 3001
ENV PORT=3001 ENV PORT=3001
WORKDIR /app/backend WORKDIR /app/backend
CMD ["npx", "tsx", "src/index.ts"] CMD ["node", "dist/backend/src/index.js"]
+9 -3
View File
@@ -135,18 +135,24 @@ Images are built and pushed automatically by CI on every push to `main`.
### Environment variables ### Environment variables
See [`.env.example`](.env.example) for the full list and defaults.
**web:** **web:**
| Variable | Default | Description | | 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:** **backend:**
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `TZ` | `UTC` | Timezone for cron schedules (e.g. `America/New_York`) | | `PORT` | `3001` | Port the Hono server listens on. |
| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched | | `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 ### Updating
+38
View File
@@ -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 (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
<div style={{ maxWidth: 480, textAlign: "center" }}>
<h1 style={{ fontSize: "1.4rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
Something went wrong
</h1>
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
An unexpected error broke this page. Try again in a moment.
</p>
<button
type="button"
onClick={reset}
style={{
padding: "8px 18px",
background: "var(--color-text)",
color: "var(--color-bg)",
border: "none",
borderRadius: 6,
fontSize: "0.85rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</main>
);
}
+10 -5
View File
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
import Script from "next/script"; import Script from "next/script";
import "./globals.css"; 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 = { export const metadata: Metadata = {
title: "Thoosie Calendar", title: "Thoosie Calendar",
description: "Theme park operating hours and live ride status at a glance", description: "Theme park operating hours and live ride status at a glance",
@@ -12,11 +15,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en"> <html lang="en">
<body> <body>
{children} {children}
<Script {PLAUSIBLE_SRC && PLAUSIBLE_WEBSITE_ID ? (
src="https://tracking.thewrightserver.net/script.js" <Script
data-website-id="a0d0582a-9bd0-4c0d-8e3c-3e6fcc99ec9a" src={PLAUSIBLE_SRC}
strategy="afterInteractive" data-website-id={PLAUSIBLE_WEBSITE_ID}
/> strategy="afterInteractive"
/>
) : null}
</body> </body>
</html> </html>
); );
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
export default function NotFound() {
return (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
<div style={{ maxWidth: 480, textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 8 }}>404</div>
<h1 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text)", marginBottom: 12 }}>
Page not found
</h1>
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
We couldn&apos;t find what you were looking for.
</p>
<Link
href="/"
style={{
padding: "8px 18px",
background: "var(--color-text)",
color: "var(--color-bg)",
borderRadius: 6,
fontSize: "0.85rem",
fontWeight: 600,
textDecoration: "none",
}}
>
Back to the calendar
</Link>
</div>
</main>
);
}
+22 -5
View File
@@ -1,8 +1,10 @@
import type { ComponentProps } from "react";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { HomePageClient } from "@/components/HomePageClient"; import { HomePageClient } from "@/components/HomePageClient";
import { getTodayLocal, formatDateLocal } from "@/lib/env"; import { getTodayLocal, formatDateLocal } from "@/lib/env";
import { apiFetch } from "@/lib/api";
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001"; type WeekData = ComponentProps<typeof HomePageClient>;
const WEEK_COOKIE = "tcWeek"; const WEEK_COOKIE = "tcWeek";
@@ -24,10 +26,25 @@ export default async function HomePage() {
const saved = (await cookies()).get(WEEK_COOKIE)?.value; const saved = (await cookies()).get(WEEK_COOKIE)?.value;
const weekStart = getWeekStart(saved); const weekStart = getWeekStart(saved);
const data = await fetch( const data = await apiFetch<WeekData>(
`${BACKEND_URL}/api/calendar/week?start=${weekStart}`, `/api/calendar/week?start=${weekStart}`,
{ next: { revalidate: 120 } }, { revalidate: 120 },
).then((r) => r.json()); );
if (!data) {
return (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
<div style={{ maxWidth: 480, textAlign: "center" }}>
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
Calendar data is unavailable
</h1>
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
We could not reach the backend. Refresh in a moment.
</p>
</div>
</main>
);
}
return <HomePageClient {...data} />; return <HomePageClient {...data} />;
} }
+56
View File
@@ -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 (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
<div style={{ maxWidth: 480, textAlign: "center" }}>
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
Could not load this park
</h1>
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
Try again in a moment, or head back to the calendar.
</p>
<div style={{ display: "flex", justifyContent: "center", gap: 10 }}>
<button
type="button"
onClick={reset}
style={{
padding: "8px 18px",
background: "var(--color-text)",
color: "var(--color-bg)",
border: "none",
borderRadius: 6,
fontSize: "0.85rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Try again
</button>
<Link
href="/"
style={{
padding: "8px 18px",
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
borderRadius: 6,
fontSize: "0.85rem",
fontWeight: 600,
textDecoration: "none",
}}
>
Back to calendar
</Link>
</div>
</div>
</main>
);
}
+35
View File
@@ -0,0 +1,35 @@
export default function ParkLoading() {
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
<header style={{
position: "sticky",
top: 0,
zIndex: 20,
background: "var(--color-bg)",
borderBottom: "1px solid var(--color-border)",
padding: "12px 24px",
display: "flex",
alignItems: "center",
gap: 16,
}}>
<div className="skeleton" style={{ width: 120, height: 18, borderRadius: 4 }} />
<div style={{ width: 1, height: 16, background: "var(--color-border)" }} />
<div className="skeleton" style={{ width: 180, height: 16, borderRadius: 4 }} />
</header>
<main style={{ padding: "24px 32px", maxWidth: 1280, margin: "0 auto", display: "flex", flexDirection: "column", gap: 40 }}>
<section>
<div className="skeleton" style={{ width: "100%", height: 320, borderRadius: 8 }} />
</section>
<section>
<div className="skeleton" style={{ width: 180, height: 16, borderRadius: 4, marginBottom: 16 }} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 6 }}>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="skeleton" style={{ height: 36, borderRadius: 8 }} />
))}
</div>
</section>
</main>
</div>
);
}
+65 -19
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { BackToCalendarLink } from "@/components/BackToCalendarLink"; import { BackToCalendarLink } from "@/components/BackToCalendarLink";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PARK_MAP } from "@/lib/parks"; 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 { RideStatus, RidesFetchResult } from "@/lib/scrapers/sixflags";
import type { LiveRidesResult } from "@/lib/scrapers/queuetimes"; import type { LiveRidesResult } from "@/lib/scrapers/queuetimes";
import { getTodayLocal } from "@/lib/env"; import { getTodayLocal } from "@/lib/env";
import { apiFetch } from "@/lib/api";
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001"; import type { DayData } from "@/lib/types";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ month?: string }>; searchParams: Promise<{ month?: string }>;
} }
interface CalendarMonthResponse {
parkId: string;
year: number;
month: number;
monthData: Record<string, DayData>;
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<Metadata> {
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 { function parseMonthParam(param: string | undefined): string {
if (param && /^\d{4}-\d{2}$/.test(param)) { if (param && /^\d{4}-\d{2}$/.test(param)) {
const [y, m] = param.split("-").map(Number); 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 [year, month] = monthStr.split("-").map(Number);
const [calendarData, ridesData] = await Promise.all([ const [calendarData, ridesData] = await Promise.all([
fetch(`${BACKEND_URL}/api/calendar/${id}/month?month=${monthStr}`, { apiFetch<CalendarMonthResponse>(
next: { revalidate: 300 }, `/api/calendar/${id}/month?month=${monthStr}`,
}).then((r) => r.json()), { revalidate: 300 },
fetch(`${BACKEND_URL}/api/parks/${id}/rides`, { ),
next: { revalidate: 60 }, apiFetch<RidesResponse>(
}).then((r) => r.json()), `/api/parks/${id}/rides`,
{ revalidate: 60 },
),
]); ]);
if (!calendarData) {
return <DataUnavailable parkName={park.name} />;
}
const { monthData, today } = calendarData; const { monthData, today } = calendarData;
const { const parkOpenToday = ridesData?.parkOpenToday ?? false;
parkOpenToday, const isWeatherDelay = ridesData?.isWeatherDelay ?? false;
isWeatherDelay, const liveRides = ridesData?.liveRides ?? null;
liveRides, const ridesResult = ridesData?.scheduleFallback ?? null;
scheduleFallback: ridesResult,
}: {
parkOpenToday: boolean;
isWeatherDelay: boolean;
liveRides: LiveRidesResult | null;
scheduleFallback: RidesFetchResult | null;
} = ridesData;
return ( return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}> <div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
@@ -355,3 +386,18 @@ function Callout({ children }: { children: React.ReactNode }) {
</div> </div>
); );
} }
function DataUnavailable({ parkName }: { parkName: string }) {
return (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
<div style={{ maxWidth: 480, textAlign: "center" }}>
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
{parkName} data is unavailable
</h1>
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem" }}>
We could not reach the backend. Refresh in a moment.
</p>
</div>
</main>
);
}
+38
View File
@@ -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 (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24, background: "var(--color-bg)" }}>
<div style={{ maxWidth: 480, textAlign: "center" }}>
<h1 style={{ fontSize: "1.2rem", fontWeight: 700, color: "var(--color-text)", marginBottom: 12 }}>
Could not load ride history
</h1>
<p style={{ color: "var(--color-text-muted)", lineHeight: 1.6, fontSize: "0.9rem", marginBottom: 20 }}>
The backend is unreachable. Try again in a moment.
</p>
<button
type="button"
onClick={reset}
style={{
padding: "8px 18px",
background: "var(--color-text)",
color: "var(--color-bg)",
border: "none",
borderRadius: 6,
fontSize: "0.85rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
export default function RideLoading() {
return (
<div style={{ minHeight: "100vh", background: "var(--color-bg)", padding: "32px 24px" }}>
<div style={{ maxWidth: 960, margin: "0 auto" }}>
<div className="skeleton" style={{ width: 160, height: 14, borderRadius: 4, marginBottom: 16 }} />
<div className="skeleton" style={{ width: 320, height: 28, borderRadius: 6, marginBottom: 24 }} />
<div className="skeleton" style={{ width: 100, height: 24, borderRadius: 999, marginBottom: 24 }} />
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="skeleton" style={{ width: 80, height: 32, borderRadius: 6 }} />
))}
</div>
<div className="skeleton" style={{ width: "100%", height: 280, borderRadius: 8 }} />
</div>
</div>
);
}
+30 -6
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PARK_MAP } from "@/lib/parks"; import { PARK_MAP } from "@/lib/parks";
import UptimePill from "@/components/charts/UptimePill"; import UptimePill from "@/components/charts/UptimePill";
import WaitTimeTodayChart from "@/components/charts/WaitTimeTodayChart"; import WaitTimeTodayChart from "@/components/charts/WaitTimeTodayChart";
import WeeklyStatsChart from "@/components/charts/WeeklyStatsChart"; import WeeklyStatsChart from "@/components/charts/WeeklyStatsChart";
import { getBackendUrl } from "@/lib/api";
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:3001";
type Tab = "today" | "7d" | "30d"; type Tab = "today" | "7d" | "30d";
@@ -62,6 +62,20 @@ function parseTab(raw: string | undefined): Tab {
return "today"; return "today";
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
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) { export default async function RideDetailPage({ params, searchParams }: PageProps) {
const { id, slug } = await params; const { id, slug } = await params;
const { tab: tabParam } = await searchParams; const { tab: tabParam } = await searchParams;
@@ -71,9 +85,14 @@ export default async function RideDetailPage({ params, searchParams }: PageProps
const tab = parseTab(tabParam); const tab = parseTab(tabParam);
const res = await fetch(`${BACKEND_URL}/api/parks/${id}/rides/${slug}`, { let res: Response;
next: { revalidate: 60 }, try {
}); res = await fetch(`${getBackendUrl()}/api/parks/${id}/rides/${slug}`, {
next: { revalidate: 60 },
});
} catch {
return <ErrorState parkId={id} parkName={park.name} />;
}
if (res.status === 404) { if (res.status === 404) {
return <NoHistoryYet parkId={id} parkName={park.name} slug={slug} />; return <NoHistoryYet parkId={id} parkName={park.name} slug={slug} />;
@@ -83,7 +102,12 @@ export default async function RideDetailPage({ params, searchParams }: PageProps
return <ErrorState parkId={id} parkName={park.name} />; return <ErrorState parkId={id} parkName={park.name} />;
} }
const data: ApiResponse = await res.json(); let data: ApiResponse;
try {
data = (await res.json()) as ApiResponse;
} catch {
return <ErrorState parkId={id} parkName={park.name} />;
}
const { ride, live, today, last7d, last30d, coverage } = data; const { ride, live, today, last7d, last30d, coverage } = data;
const last30dUptime = last30d.length const last30dUptime = last30d.length
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/backend/src/index.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "tsx --test tests/*.test.ts" "test": "tsx --test tests/*.test.ts"
}, },
+25
View File
@@ -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;
+58 -12
View File
@@ -1,10 +1,12 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { cors } from "hono/cors"; 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 { startScheduler } from "./services/scheduler";
import { rateLimit } from "./middleware/rate-limit";
import calendarRoutes from "./routes/calendar"; import calendarRoutes from "./routes/calendar";
import parksRoutes from "./routes/parks"; import parksRoutes from "./routes/parks";
@@ -13,12 +15,18 @@ import rideHistoryRoutes from "./routes/ride-history";
import statusRoutes from "./routes/status"; import statusRoutes from "./routes/status";
import scrapeRoutes from "./routes/scrape"; import scrapeRoutes from "./routes/scrape";
const PORT = parseInt(process.env.PORT ?? "3001", 10);
const app = new Hono(); 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("*", cors());
app.use("*", rateLimit(config.rateLimitPerMin));
app.route("/api/calendar", calendarRoutes); app.route("/api/calendar", calendarRoutes);
app.route("/api/parks", parksRoutes); app.route("/api/parks", parksRoutes);
@@ -27,14 +35,52 @@ app.route("/api/parks", rideHistoryRoutes);
app.route("/api/status", statusRoutes); app.route("/api/status", statusRoutes);
app.route("/api/scrape", scrapeRoutes); app.route("/api/scrape", scrapeRoutes);
// Initialize database on startup log.info("startup", "config loaded", {
getDb(); port: config.port,
console.log("[backend] database initialized"); nodeEnv: config.nodeEnv,
parkHoursStalenessHours: config.parkHoursStalenessHours,
rateLimitPerMin: config.rateLimitPerMin,
});
getDb();
log.info("startup", "database initialized");
// Start cron scheduler
startScheduler(); startScheduler();
// Start HTTP server const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
serve({ fetch: app.fetch, port: PORT }, (info) => { log.info("startup", "listening", { url: `http://localhost:${info.port}` });
console.log(`[backend] listening on 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 });
}); });
+30
View File
@@ -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<string, unknown>;
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),
};
+59
View File
@@ -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<MiddlewareHandler>[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<string, Bucket>();
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();
};
}
+21 -6
View File
@@ -7,8 +7,14 @@ import { fetchToday } from "../../../lib/scrapers/sixflags";
import { fetchLiveRides } from "../../../lib/scrapers/queuetimes"; import { fetchLiveRides } from "../../../lib/scrapers/queuetimes";
import { getDateRange, getParkMonthData, type DayData } from "../db/queries"; import { getDateRange, getParkMonthData, type DayData } from "../db/queries";
import { TtlCache } from "../services/cache"; 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<TodayCacheValue>(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<true>(5 * 60 * 1000);
const ridesCache = new TtlCache<{ openRides: number; openCoasters: number } | null>(5 * 60 * 1000); const ridesCache = new TtlCache<{ openRides: number; openCoasters: number } | null>(5 * 60 * 1000);
const app = new Hono(); const app = new Hono();
@@ -34,10 +40,13 @@ app.get("/week", async (c) => {
await Promise.all( await Promise.all(
PARKS.map(async (p) => { PARKS.map(async (p) => {
let live = todayCache.get(p.id); let live = todayCache.get(p.id);
if (live === null && !todayCache.get(p.id + "_checked")) { if (live === null && todayChecked.get(p.id) === null) {
live = await fetchToday(p.apiId).catch(() => 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, live);
todayCache.set(p.id + "_checked", true as any); todayChecked.set(p.id, true);
} }
if (!live) return; if (!live) return;
if (!data[p.id]) data[p.id] = {}; if (!data[p.id]) data[p.id] = {};
@@ -84,7 +93,10 @@ app.get("/week", async (c) => {
let cached = ridesCache.get(p.id); let cached = ridesCache.get(p.id);
if (cached === null) { if (cached === null) {
const coasterSet = getCoasterSet(p.id); 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 cached = result
? { ? {
openRides: result.rides.filter((r) => r.isOpen).length, 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 // Merge live today if viewing current month
const park = PARKS.find((p) => p.id === parkId); const park = PARKS.find((p) => p.id === parkId);
if (park) { 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) { if (liveToday) {
monthData[today] = { monthData[today] = {
isOpen: liveToday.isOpen, isOpen: liveToday.isOpen,
+13 -3
View File
@@ -10,6 +10,7 @@ import { getDayData } from "../db/queries";
import { liveRidesCache, fastLaneCache } from "../services/live-cache"; import { liveRidesCache, fastLaneCache } from "../services/live-cache";
import { slugifyRideName } from "../../../lib/ride-slug"; import { slugifyRideName } from "../../../lib/ride-slug";
import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes"; import type { LiveRidesResult } from "../../../lib/scrapers/queuetimes";
import { log } from "../log";
const app = new Hono(); const app = new Hono();
@@ -31,7 +32,10 @@ app.get("/:id/rides", async (c) => {
liveRides = liveRidesCache.get(id); liveRides = liveRidesCache.get(id);
if (liveRides === null) { if (liveRides === null) {
const coasterSet = getCoasterSet(id); 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); if (liveRides) liveRidesCache.set(id, liveRides);
} }
@@ -46,7 +50,10 @@ app.get("/:id/rides", async (c) => {
if (liveRides) { if (liveRides) {
let fastLane = fastLaneCache.get(id); let fastLane = fastLaneCache.get(id);
if (fastLane === null) { 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) fastLaneCache.set(id, fastLane);
} }
if (fastLane) { if (fastLane) {
@@ -81,7 +88,10 @@ app.get("/:id/rides", async (c) => {
let scheduleFallback = null; let scheduleFallback = null;
if (!liveRides) { 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"); c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=120");
+74 -42
View File
@@ -2,68 +2,100 @@ import cron from "node-cron";
import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper"; import { scrapeToday, scrapeCurrentMonth, scrapeUpcomingMonths, scrapeFullYear } from "./scraper";
import { sampleAllOpenParks } from "./wait-sampler"; import { sampleAllOpenParks } from "./wait-sampler";
import { getParkDayCount } from "../db/queries"; import { getParkDayCount } from "../db/queries";
import { log } from "../log";
let initialized = false; 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<void>): () => Promise<void> {
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 { export function startScheduler(): void {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
// Tier 1: Today — every hour during operating season (Mar-Dec) cron.schedule(
cron.schedule("0 * * 3-12 *", async () => { "0 * * 3-12 *",
console.log(`[scheduler] tier-1: scraping today @ ${new Date().toISOString()}`); withLatch("scheduler.tier1", async () => {
await scrapeToday().catch((err) => console.error("[scheduler] tier-1 error:", err)); log.info("scheduler.tier1", "scraping today");
}); await scrapeToday();
}),
);
// Tier 2: This week — every 6 hours, current month for all parks cron.schedule(
cron.schedule("0 */6 * * *", async () => { "0 */6 * * *",
console.log(`[scheduler] tier-2: scraping current month @ ${new Date().toISOString()}`); withLatch("scheduler.tier2", async () => {
await scrapeCurrentMonth().catch((err) => console.error("[scheduler] tier-2 error:", err)); log.info("scheduler.tier2", "scraping current month");
}); await scrapeCurrentMonth();
}),
);
// Tier 3: Upcoming — twice daily (3 AM, 3 PM), current + next month cron.schedule(
cron.schedule("0 3,15 * * *", async () => { "0 3,15 * * *",
console.log(`[scheduler] tier-3: scraping upcoming months @ ${new Date().toISOString()}`); withLatch("scheduler.tier3", async () => {
await scrapeUpcomingMonths().catch((err) => console.error("[scheduler] tier-3 error:", err)); log.info("scheduler.tier3", "scraping upcoming months");
}); await scrapeUpcomingMonths();
}),
);
// Tier 4: Full season — once daily at 3 AM cron.schedule(
cron.schedule("0 3 * * *", async () => { "0 3 * * *",
console.log(`[scheduler] tier-4: scraping full year @ ${new Date().toISOString()}`); withLatch("scheduler.tier4", async () => {
await scrapeFullYear().catch((err) => console.error("[scheduler] tier-4 error:", err)); log.info("scheduler.tier4", "scraping full year");
}); await scrapeFullYear();
}),
);
// Tier 5: Wait-time samples — every 5 minutes for parks open today cron.schedule(
cron.schedule("*/5 * * * *", async () => { "*/5 * * * *",
try { withLatch("scheduler.tier5", async () => {
const r = await sampleAllOpenParks(); const r = await sampleAllOpenParks();
console.log( log.info("scheduler.tier5", "sample run complete", {
`[scheduler] tier-5: sampled ${r.parksSampled} parks, ${r.samplesWritten} samples, ` + parksSampled: r.parksSampled,
`${r.weatherDelayed} weather-delayed, ${r.errors} errors`, samplesWritten: r.samplesWritten,
); weatherDelayed: r.weatherDelayed,
} catch (err) { errors: r.errors,
console.error("[scheduler] tier-5 error:", err); });
} }),
}); );
console.log("[scheduler] cron jobs registered"); log.info("scheduler", "cron jobs registered", {
console.log(" tier-1: today — hourly (Mar-Dec)"); tiers: "tier1=hourly(Mar-Dec) tier2=6h tier3=3am+3pm tier4=3am-daily tier5=5min",
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");
const existingRows = getParkDayCount(); const existingRows = getParkDayCount();
if (existingRows < 50) { if (existingRows < 50) {
console.log(`[scheduler] DB has ${existingRows} rows — running startup scrape`); log.info("scheduler", "running startup scrape", { existingRows });
scrapeToday() scrapeToday()
.then((r) => { .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(); return scrapeFullYear();
}) })
.then((r) => console.log(`[scheduler] startup full-year: ${r.fetched} fetched, ${r.skipped} skipped, ${r.errors} errors`)) .then((r) => {
.catch((err) => console.error("[scheduler] startup scrape error:", err)); 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 { } else {
console.log(`[scheduler] DB has ${existingRows} rows — skipping startup scrape, relying on cron`); log.info("scheduler", "skipping startup scrape relying on cron", { existingRows });
} }
} }
+29 -12
View File
@@ -1,10 +1,11 @@
import { PARKS } from "../../../lib/parks"; import { PARKS } from "../../../lib/parks";
import { scrapeMonth, fetchToday, RateLimitError } from "../../../lib/scrapers/sixflags"; import { scrapeMonth, fetchToday, RateLimitError } from "../../../lib/scrapers/sixflags";
import { upsertDay, isMonthScraped, getDayData, transact } from "../db/queries"; 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 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) { function sleep(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms)); return new Promise<void>((r) => setTimeout(r, ms));
@@ -54,9 +55,17 @@ export async function scrapeToday(): Promise<ScrapeResult> {
upsertDay(park.id, live.date, live.isOpen, live.hoursLabel, live.specialType); upsertDay(park.id, live.date, live.isOpen, live.hoursLabel, live.specialType);
updated++; updated++;
console.log(`[today] ${park.shortName}: updated (${live.isOpen ? "open" : "closed"}${live.hoursLabel ? " " + live.hoursLabel : ""})`); log.info("scrape.today", "updated", {
} catch { park: park.shortName,
isOpen: live.isOpen,
hours: live.hoursLabel ?? null,
});
} catch (err) {
errors++; errors++;
log.warn("scrape.today", "park failed", {
park: park.shortName,
err: (err as Error).message,
});
} }
await sleep(500); await sleep(500);
} }
@@ -71,7 +80,7 @@ export async function scrapeToday(): Promise<ScrapeResult> {
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
}; };
lastScrapeResult = result; 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; return result;
} }
@@ -96,14 +105,22 @@ export async function scrapeMonths(monthList: { year: number; month: number }[],
} }
}); });
fetched++; 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) { } 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++; 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); await sleep(DELAY_MS);
} }
@@ -119,7 +136,7 @@ export async function scrapeMonths(monthList: { year: number; month: number }[],
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
}; };
lastScrapeResult = result; lastScrapeResult = result;
console.log(`[month] done: ${fetched} fetched, ${skipped} skipped, ${errors} errors`); log.info("scrape.month", "done", { fetched, skipped, errors });
return result; return result;
} }
+13 -3
View File
@@ -26,6 +26,7 @@ import { formatLocalDate, formatLocalTime } from "../../../lib/timezone";
import { isWithinOperatingWindow } from "../../../lib/env"; import { isWithinOperatingWindow } from "../../../lib/env";
import { liveRidesCache, fastLaneCache } from "./live-cache"; import { liveRidesCache, fastLaneCache } from "./live-cache";
import { getDayData, upsertRide, insertSample, transact } from "../db/queries"; import { getDayData, upsertRide, insertSample, transact } from "../db/queries";
import { log } from "../log";
const PARALLEL_CHUNK = 6; const PARALLEL_CHUNK = 6;
@@ -54,7 +55,10 @@ async function samplePark(park: Park, now: Date): Promise<{
let liveRides = liveRidesCache.get(park.id); let liveRides = liveRidesCache.get(park.id);
if (liveRides === null) { if (liveRides === null) {
const coasterSet = getCoasterSet(park.id); 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) liveRidesCache.set(park.id, liveRides);
} }
if (!liveRides || liveRides.rides.length === 0) { 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. // Fast Lane — reuse cache; fetch on miss.
let fastLane = fastLaneCache.get(park.id); let fastLane = fastLaneCache.get(park.id);
if (fastLane === null) { 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); 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 }; return { ridesUpserted, samplesWritten, weatherDelayed: false, error: false };
} catch (err) { } 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 }; return { ridesUpserted: 0, samplesWritten: 0, weatherDelayed: false, error: true };
} }
} }
+1 -1
View File
@@ -17,5 +17,5 @@
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*.ts", "../lib/**/*.ts"], "include": ["src/**/*.ts", "../lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "../lib/api.ts"]
} }
+27
View File
@@ -8,6 +8,21 @@ services:
- BACKEND_URL=http://backend:3001 - BACKEND_URL=http://backend:3001
- TZ=America/New_York - TZ=America/New_York
restart: unless-stopped 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: backend:
image: gitea.thewrightserver.net/josh/thoosiecalendar:backend image: gitea.thewrightserver.net/josh/thoosiecalendar:backend
@@ -20,6 +35,18 @@ services:
- TZ=America/New_York - TZ=America/New_York
- PARK_HOURS_STALENESS_HOURS=72 - PARK_HOURS_STALENESS_HOURS=72
restart: unless-stopped 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: volumes:
park_data: park_data:
+30
View File
@@ -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
+46
View File
@@ -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<T>(
path: string,
options: { revalidate?: number } = {},
): Promise<T | null> {
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;
}
}
+1
View File
@@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit",
"debug": "tsx scripts/debug.ts", "debug": "tsx scripts/debug.ts",
"test": "tsx --test tests/*.test.ts" "test": "tsx --test tests/*.test.ts"
}, },