refactor: production-essentials hardening pass
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
+19
-8
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
-2
@@ -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 })
|
||||
<html lang="en">
|
||||
<body>
|
||||
{children}
|
||||
{PLAUSIBLE_SRC && PLAUSIBLE_WEBSITE_ID ? (
|
||||
<Script
|
||||
src="https://tracking.thewrightserver.net/script.js"
|
||||
data-website-id="a0d0582a-9bd0-4c0d-8e3c-3e6fcc99ec9a"
|
||||
src={PLAUSIBLE_SRC}
|
||||
data-website-id={PLAUSIBLE_WEBSITE_ID}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
) : null}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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'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
@@ -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<typeof HomePageClient>;
|
||||
|
||||
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<WeekData>(
|
||||
`/api/calendar/week?start=${weekStart}`,
|
||||
{ revalidate: 120 },
|
||||
);
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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<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 {
|
||||
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<CalendarMonthResponse>(
|
||||
`/api/calendar/${id}/month?month=${monthStr}`,
|
||||
{ revalidate: 300 },
|
||||
),
|
||||
apiFetch<RidesResponse>(
|
||||
`/api/parks/${id}/rides`,
|
||||
{ revalidate: 60 },
|
||||
),
|
||||
]);
|
||||
|
||||
if (!calendarData) {
|
||||
return <DataUnavailable parkName={park.name} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ minHeight: "100vh", background: "var(--color-bg)" }}>
|
||||
@@ -355,3 +386,18 @@ function Callout({ children }: { children: React.ReactNode }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<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) {
|
||||
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}`, {
|
||||
let res: Response;
|
||||
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) {
|
||||
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} />;
|
||||
}
|
||||
|
||||
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 last30dUptime = last30d.length
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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<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 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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<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 {
|
||||
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));
|
||||
});
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
// Tier 5: Wait-time samples — every 5 minutes for parks open today
|
||||
cron.schedule("*/5 * * * *", async () => {
|
||||
try {
|
||||
const r = await sampleAllOpenParks();
|
||||
console.log(
|
||||
`[scheduler] tier-5: sampled ${r.parksSampled} parks, ${r.samplesWritten} samples, ` +
|
||||
`${r.weatherDelayed} weather-delayed, ${r.errors} errors`,
|
||||
cron.schedule(
|
||||
"0 * * 3-12 *",
|
||||
withLatch("scheduler.tier1", async () => {
|
||||
log.info("scheduler.tier1", "scraping today");
|
||||
await scrapeToday();
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[scheduler] tier-5 error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
cron.schedule(
|
||||
"0 */6 * * *",
|
||||
withLatch("scheduler.tier2", async () => {
|
||||
log.info("scheduler.tier2", "scraping current month");
|
||||
await scrapeCurrentMonth();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"0 3,15 * * *",
|
||||
withLatch("scheduler.tier3", async () => {
|
||||
log.info("scheduler.tier3", "scraping upcoming months");
|
||||
await scrapeUpcomingMonths();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"0 3 * * *",
|
||||
withLatch("scheduler.tier4", async () => {
|
||||
log.info("scheduler.tier4", "scraping full year");
|
||||
await scrapeFullYear();
|
||||
}),
|
||||
);
|
||||
|
||||
cron.schedule(
|
||||
"*/5 * * * *",
|
||||
withLatch("scheduler.tier5", async () => {
|
||||
const r = await sampleAllOpenParks();
|
||||
log.info("scheduler.tier5", "sample run complete", {
|
||||
parksSampled: r.parksSampled,
|
||||
samplesWritten: r.samplesWritten,
|
||||
weatherDelayed: r.weatherDelayed,
|
||||
errors: r.errors,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
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<ScrapeResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "../lib/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "../lib/api.ts"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user