From 5d9daee6271723592c051b9dd7ab2b9757f28b24 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 30 May 2026 10:17:52 -0400 Subject: [PATCH] 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 --- .env.example | 22 +++++ .gitea/workflows/deploy.yml | 36 ++++++++ .gitignore | 4 + Dockerfile | 27 ++++-- README.md | 12 ++- app/error.tsx | 38 +++++++++ app/layout.tsx | 15 ++-- app/not-found.tsx | 31 +++++++ app/page.tsx | 27 ++++-- app/park/[id]/error.tsx | 56 +++++++++++++ app/park/[id]/loading.tsx | 35 ++++++++ app/park/[id]/page.tsx | 84 ++++++++++++++----- app/park/[id]/ride/[slug]/error.tsx | 38 +++++++++ app/park/[id]/ride/[slug]/loading.tsx | 17 ++++ app/park/[id]/ride/[slug]/page.tsx | 36 ++++++-- backend/package.json | 2 +- backend/src/config.ts | 25 ++++++ backend/src/index.ts | 70 +++++++++++++--- backend/src/log.ts | 30 +++++++ backend/src/middleware/rate-limit.ts | 59 +++++++++++++ backend/src/routes/calendar.ts | 27 ++++-- backend/src/routes/rides.ts | 16 +++- backend/src/services/scheduler.ts | 116 ++++++++++++++++---------- backend/src/services/scraper.ts | 41 ++++++--- backend/src/services/wait-sampler.ts | 16 +++- backend/tsconfig.json | 2 +- docker-compose.yml | 27 ++++++ docs/wait-times-urls.txt | 30 +++++++ lib/api.ts | 46 ++++++++++ package.json | 1 + 30 files changed, 860 insertions(+), 126 deletions(-) create mode 100644 .env.example create mode 100644 app/error.tsx create mode 100644 app/not-found.tsx create mode 100644 app/park/[id]/error.tsx create mode 100644 app/park/[id]/loading.tsx create mode 100644 app/park/[id]/ride/[slug]/error.tsx create mode 100644 app/park/[id]/ride/[slug]/loading.tsx create mode 100644 backend/src/config.ts create mode 100644 backend/src/log.ts create mode 100644 backend/src/middleware/rate-limit.ts create mode 100644 docs/wait-times-urls.txt create mode 100644 lib/api.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..65c1943 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# ── Frontend (web) ────────────────────────────────────────────────────────── +# Backend API base URL. Required in production — the frontend throws at +# startup if this is unset and NODE_ENV=production. +BACKEND_URL=http://localhost:3001 + +# Optional: Plausible analytics. Both must be set for the script to render. +# NEXT_PUBLIC_PLAUSIBLE_SRC=https://plausible.example.com/script.js +# NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID=your-website-id + +# ── Backend ───────────────────────────────────────────────────────────────── +# Port the Hono server listens on (default 3001). +PORT=3001 + +# IANA timezone used by node-cron schedules and operating-hour windows. +TZ=America/New_York + +# How long a park's schedule data is considered fresh before the tiered +# scraper re-fetches it (default 72). +PARK_HOURS_STALENESS_HOURS=72 + +# Per-IP request limit for the public API, per minute (default 60). +RATE_LIMIT_PER_MIN=60 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5dc4f49..abea687 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,8 +6,44 @@ on: - main jobs: + verify: + name: Lint, typecheck, test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install frontend deps + run: npm ci + + - name: Frontend lint + run: npm run lint + + - name: Frontend typecheck + run: npm run typecheck + + - name: Frontend tests + run: npm test + + - name: Install backend deps + run: npm ci + working-directory: backend + + - name: Backend typecheck + run: npm run typecheck + working-directory: backend + + - name: Backend tests + run: npm test + working-directory: backend + build-push: name: Build & Push + needs: verify runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index d772b82..8601431 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ yarn-error.log* /backend/data/ parks.db +# debug script artifacts +/debug/ + # env files .env* !.env.example @@ -45,3 +48,4 @@ parks.db # typescript *.tsbuildinfo next-env.d.ts +.gstack/ diff --git a/Dockerfile b/Dockerfile index 5f2ed53..75e217e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,13 +6,25 @@ RUN npm ci COPY . . RUN npm run build -# ── backend-deps: backend node_modules (better-sqlite3 needs build tools) ──── -FROM node:22-bookworm-slim AS backend-deps +# ── backend-build: compile backend TypeScript to JS (better-sqlite3 build) ─── +FROM node:22-bookworm-slim AS backend-build RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \ rm -rf /var/lib/apt/lists/* WORKDIR /app +COPY backend/package.json backend/package-lock.json* ./backend/ +COPY backend/tsconfig.json ./backend/ +RUN cd backend && npm ci +COPY backend/src ./backend/src +COPY lib ./lib +RUN cd backend && npm run build + +# ── backend-prod-deps: production-only node_modules (omits tsc/tsx) ────────── +FROM node:22-bookworm-slim AS backend-prod-deps +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /app/backend COPY backend/package.json backend/package-lock.json* ./ -RUN npm ci +RUN npm ci --omit=dev # ── web ────────────────────────────────────────────────────────────────────── # Minimal Next.js standalone runner. No database, no native modules. @@ -37,6 +49,7 @@ CMD ["node", "server.js"] # ── backend ────────────────────────────────────────────────────────────────── # Hono API server + node-cron scheduler. Owns the SQLite database exclusively. +# Runs compiled JS (no tsx/tsc at runtime). FROM node:22-bookworm-slim AS backend WORKDIR /app @@ -45,11 +58,9 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs -COPY --from=backend-deps --chown=nextjs:nodejs /app/node_modules ./backend/node_modules -COPY --chown=nextjs:nodejs backend/src ./backend/src +COPY --from=backend-prod-deps --chown=nextjs:nodejs /app/backend/node_modules ./backend/node_modules +COPY --from=backend-build --chown=nextjs:nodejs /app/backend/dist ./backend/dist COPY --chown=nextjs:nodejs backend/package.json ./backend/package.json -COPY --chown=nextjs:nodejs backend/tsconfig.json ./backend/tsconfig.json -COPY --chown=nextjs:nodejs lib ./lib RUN mkdir -p /app/backend/data && chown nextjs:nodejs /app/backend/data VOLUME ["/app/backend/data"] @@ -59,4 +70,4 @@ EXPOSE 3001 ENV PORT=3001 WORKDIR /app/backend -CMD ["npx", "tsx", "src/index.ts"] +CMD ["node", "dist/backend/src/index.js"] diff --git a/README.md b/README.md index f03512f..e50075e 100644 --- a/README.md +++ b/README.md @@ -135,18 +135,24 @@ Images are built and pushed automatically by CI on every push to `main`. ### Environment variables +See [`.env.example`](.env.example) for the full list and defaults. + **web:** | Variable | Default | Description | |----------|---------|-------------| -| `BACKEND_URL` | `http://backend:3001` | Backend API base URL (Docker internal networking) | +| `BACKEND_URL` | _(required in prod)_ | Backend API base URL. Throws at startup if unset when `NODE_ENV=production`. | +| `NEXT_PUBLIC_PLAUSIBLE_SRC` | — | Plausible script URL. Analytics only render when both this and the website ID are set. | +| `NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID` | — | Plausible website ID. | **backend:** | Variable | Default | Description | |----------|---------|-------------| -| `TZ` | `UTC` | Timezone for cron schedules (e.g. `America/New_York`) | -| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched | +| `PORT` | `3001` | Port the Hono server listens on. | +| `TZ` | `UTC` | Timezone for cron schedules (e.g. `America/New_York`). | +| `PARK_HOURS_STALENESS_HOURS` | `72` | Hours before park schedule data is re-fetched. | +| `RATE_LIMIT_PER_MIN` | `60` | Per-IP request limit for the public API, per minute. | ### Updating diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..9c51f6b --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; + +export default function RootError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+
+

+ Something went wrong +

+

+ An unexpected error broke this page. Try again in a moment. +

+ +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 123edec..472371c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,9 @@ import type { Metadata } from "next"; import Script from "next/script"; import "./globals.css"; +const PLAUSIBLE_SRC = process.env.NEXT_PUBLIC_PLAUSIBLE_SRC; +const PLAUSIBLE_WEBSITE_ID = process.env.NEXT_PUBLIC_PLAUSIBLE_WEBSITE_ID; + export const metadata: Metadata = { title: "Thoosie Calendar", description: "Theme park operating hours and live ride status at a glance", @@ -12,11 +15,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} -