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
|
- 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
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 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}
|
||||||
|
{PLAUSIBLE_SRC && PLAUSIBLE_WEBSITE_ID ? (
|
||||||
<Script
|
<Script
|
||||||
src="https://tracking.thewrightserver.net/script.js"
|
src={PLAUSIBLE_SRC}
|
||||||
data-website-id="a0d0582a-9bd0-4c0d-8e3c-3e6fcc99ec9a"
|
data-website-id={PLAUSIBLE_WEBSITE_ID}
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { 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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${getBackendUrl()}/api/parks/${id}/rides/${slug}`, {
|
||||||
next: { revalidate: 60 },
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { 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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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("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`,
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
|
||||||
console.error("[scheduler] tier-5 error:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[scheduler] cron jobs registered");
|
cron.schedule(
|
||||||
console.log(" tier-1: today — hourly (Mar-Dec)");
|
"0 */6 * * *",
|
||||||
console.log(" tier-2: current month — every 6h");
|
withLatch("scheduler.tier2", async () => {
|
||||||
console.log(" tier-3: upcoming — 3 AM + 3 PM");
|
log.info("scheduler.tier2", "scraping current month");
|
||||||
console.log(" tier-4: full year — 3 AM daily");
|
await scrapeCurrentMonth();
|
||||||
console.log(" tier-5: wait samples — every 5 min");
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user