From 9674fc9cbe7b01fd8b526e2de14d39cd183fdc1c Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Sat, 6 Dec 2025 03:03:30 +0900 Subject: [PATCH] Squashed commit of the following: commit da3f3bff39f09e753c1c52de0c47a12383cd20dc Author: Nik Afiq Date: Sat Dec 6 02:49:31 2025 +0900 docs: add README files for frontend and backend with setup instructions and commands commit df71faa0751d1cef3ad6d50d0293fb8f8239d000 Author: Nik Afiq Date: Sat Dec 6 02:44:13 2025 +0900 chore: update Vite version and add Vitest configuration - Updated Vite dependency from rolldown-vite@7.1.12 to ^5.4.11 in package.json. - Added setup file for Vitest to handle Vite SSR helpers, preventing ReferenceError during unit tests. - Created a new tsconfig.vitest.json file extending the main tsconfig for Vitest compatibility. - Added vitest.config.ts to configure Vitest with Node environment and setup files. commit f3a1710dd0fe12998965992f6b5f8803422087cf Author: Nik Afiq Date: Sat Dec 6 02:37:32 2025 +0900 feat: add testing framework and implement unit tests for API and config - Added Vitest as a testing framework with scripts for running tests. - Created unit tests for the `fetchSchedule` and `fetchServerNow` functions in `watchparty.test.ts`. - Implemented unit tests for configuration functions in `config.test.ts`. - Added utility functions for parsing time in `time.ts` with corresponding tests in `time.test.ts`. - Updated API error handling to use `unknown` type for better type safety. - Refactored `TimeSyncNotice` and `Timer` components to improve performance and error handling. - Enhanced toast notifications by moving related functions to `toastBus.ts`. - Improved type definitions across various files for better type safety and clarity. commit 0d436849fc4a0b87d0c73f4fe14fe1e272d47ad9 Author: Nik Afiq Date: Sat Dec 6 02:30:01 2025 +0900 Refactor components to utilize centralized configuration: update TimeSyncNotice and Timer to use config for intervals; enhance error handling and retry logic in ShowsPage. commit 8dbd4d207a471d05fab8c6f9cd95e3f4f7ec9099 Author: Nik Afiq Date: Sat Dec 6 02:22:28 2025 +0900 Refactor API endpoint handling: replace join function with buildApiUrl for cleaner URL construction; update BrowserRouter basename to use config commit 3e327aa73877034019dffe262580536f4be7c62e Author: Nik Afiq Date: Sat Dec 6 02:18:31 2025 +0900 Refactor configuration management: introduce config.ts for centralized app configuration; update API endpoint handling and logger to use new config commit 131984d1baf368f94a14a62986eaf028ebbd7c86 Author: Nik Afiq Date: Sat Dec 6 02:11:33 2025 +0900 Refactor API types: move ScheduleResponse and ShowItem types to a new types.ts file; update imports in watchparty and Timer components commit 8faa4661a9ccc0691490a5766f0eb1d97f24b6e5 Author: Nik Afiq Date: Sat Dec 6 02:05:42 2025 +0900 Refactor API handling: introduce centralized error handling and logging; replace direct fetch calls with apiFetch in Timer, ShowsPage, and hooks commit ffde7e89fcab6f48c6023afab73e4b2e1122efa5 Author: Nik Afiq Date: Sat Dec 6 01:52:36 2025 +0900 Add dist directory to .gitignore commit 128a5be6eaa16bf4db5f7dd832b1d461fa2b835d Author: Nik Afiq Date: Sat Dec 6 01:52:28 2025 +0900 Add toast notifications and debug overlay components; refactor Timer and ShowsPage for error handling --- .gitignore | 1 + README.md | 86 +- backend/README.md | 39 + frontend/Dockerfile | 2 + frontend/README.md | 43 + frontend/package-lock.json | 1372 +++++++++++++++----- frontend/package.json | 10 +- frontend/src/App.tsx | 6 +- frontend/src/api/client.ts | 111 ++ frontend/src/api/endpoint.ts | 10 +- frontend/src/api/types.ts | 23 + frontend/src/api/watchparty.test.ts | 35 + frontend/src/api/watchparty.ts | 97 ++ frontend/src/components/DebugOverlay.tsx | 89 ++ frontend/src/components/TimeSyncNotice.tsx | 49 +- frontend/src/components/Timer.tsx | 143 +- frontend/src/components/Toasts.tsx | 42 + frontend/src/config.test.ts | 30 + frontend/src/config.ts | 61 + frontend/src/hooks/useServerClock.ts | 23 +- frontend/src/hooks/useTimeSkew.ts | 42 +- frontend/src/index.css | 106 +- frontend/src/main.tsx | 5 +- frontend/src/pages/ShowsPage.tsx | 88 +- frontend/src/test/setup.ts | 27 + frontend/src/utils/logger.ts | 47 +- frontend/src/utils/time.test.ts | 28 + frontend/src/utils/time.ts | 28 + frontend/src/utils/toastBus.ts | 52 + frontend/tsconfig.vitest.json | 7 + frontend/vitest.config.ts | 14 + 31 files changed, 2120 insertions(+), 596 deletions(-) create mode 100644 backend/README.md create mode 100644 frontend/README.md create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/api/watchparty.test.ts create mode 100644 frontend/src/api/watchparty.ts create mode 100644 frontend/src/components/DebugOverlay.tsx create mode 100644 frontend/src/components/Toasts.tsx create mode 100644 frontend/src/config.test.ts create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/utils/time.test.ts create mode 100644 frontend/src/utils/time.ts create mode 100644 frontend/src/utils/toastBus.ts create mode 100644 frontend/tsconfig.vitest.json create mode 100644 frontend/vitest.config.ts diff --git a/.gitignore b/.gitignore index 93ecf03..a743447 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env .cache .DS_Store +*/dist */node_modules \ No newline at end of file diff --git a/README.md b/README.md index 8167d30..6323aae 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,40 @@ # Watch Party (Frontend + Backend) -Watch Party is a small full-stack app: +Watch Party is a small full-stack app for synchronized viewing with a live timer and episode control. -- **Frontend:** Vite + React + TypeScript, served by **Nginx** -- **Backend:** Go (Gin) API + PostgreSQL -- **Orchestration:** One **docker-compose** that keeps FE/BE split on the same internal network -- **Routing:** Your Debian Nginx is the public entrypoint and reverse-proxies to the Mac mini (where this stack runs) +## Features +- Live timer with server-synced clock and schedule-driven start/end. +- Episode selector (set current episode + optional custom start time). +- Time-skew banner (warns when client clock is off). +- Debug-only logging overlay and error toasts for API failures (enable with `FRONTEND_MODE=debug` / `VITE_APP_MODE=debug`). +## Prerequisites +- Docker + Docker Compose plugin. +- Node.js 20+ (for local frontend dev/test). +- Go 1.22+ (for local backend dev/test). +- A `.env` file at repo root (see template below). + +## Deploy (compose) +```bash +docker compose build +docker compose up -d +``` +Open: `http://:3000/watch-party/` + +Health checks: +- Web (nginx): `curl http://:3000/` +- API via web proxy: `curl http://:3000/api/healthz` +- API direct (inside network): `curl http://api:8082/healthz` + +Logs: +```bash +docker compose logs -f migrate +docker compose logs -f api +docker compose logs -f web +docker compose logs -f db +``` + +Architecture: ``` router → Debian nginx (TLS) → Mac mini :3000 → [web nginx ↔ api ↔ db] ``` @@ -80,30 +108,6 @@ PGSSLMODE=disable --- -## Run (Production-like) - -```bash -docker compose build -docker compose up -d -``` - -Open: `http://:3000/watch-party/` - -Health checks: -- Web (nginx): `curl http://:3000/` -- API through web proxy: `curl http://:3000/api/healthz` -- API direct (inside network only): `curl http://api:8082/healthz` (from another container) - -Logs: -```bash -docker compose logs -f migrate # should start, apply SQL, and exit 0 -docker compose logs -f api -docker compose logs -f web -docker compose logs -f db -``` - ---- - ## Debian Nginx (public reverse proxy) Terminate TLS on Debian and forward to the Mac mini: @@ -129,25 +133,9 @@ Security headers & HSTS on Debian are recommended. --- ## Local Development (hot reload) - -You can dev each side outside Docker: - -- **Frontend** - ```bash - cd frontend - npm ci - npm run dev # http://localhost:5173 - ``` - Configure your FE to call the API (e.g., via a local `.env` or vite config). - -- **Backend** - ```bash - cd backend - go run ./cmd/server - ``` - Make sure Postgres is reachable (either from the compose `db` or your local). - -For a dev-only compose with Vite HMR, create an override (not included here). +See per-service READMEs: +- Frontend: `frontend/README.md` +- Backend: `backend/README.md` --- @@ -194,4 +182,4 @@ curl http://:3000/api/current ## License -MIT \ No newline at end of file +MIT diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7b2ae75 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,39 @@ +# Backend (Go + Gin) + +REST API that serves the schedule/time endpoints and current episode control. Built in Go, containerized with a multi-stage Dockerfile. + +## Prerequisites +- Go 1.22+ +- PostgreSQL reachable (uses env-driven DSN pieces) +- `golang-migrate` not required locally (migrations shipped in repo) + +## Env +The server builds its DSN from: +- `PGHOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `PGSSLMODE` +- `ADDR` (listen address, default `:8082`) +- `GIN_MODE` (e.g., `release`) + +## Commands +```bash +# Run locally (expects Postgres per env) +go run ./cmd/server + +# Run migrations +go run ./cmd/migrate + +# Tests +go test ./... +``` + +## Docker build (used by compose) +```bash +docker build -t watchparty-backend . +``` +Compose uses the same image for `api` and the one-off `migrate` job. + +## Endpoints (key) +- `GET /api/v1/time` — server time for client clock sync +- `GET /api/v1/current` — current schedule item +- `POST /api/v1/current` — set current episode (expects `{ id, start_time? }`) +- `GET /api/v1/shows` — list of episodes +- `GET /healthz` — health check diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5c2b718..a839946 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,6 +11,8 @@ ENV PUBLIC_BASE_PATH=${PUBLIC_BASE_PATH} ARG FRONTEND_MODE=production ENV FRONTEND_MODE=${FRONTEND_MODE} ENV VITE_APP_MODE=${FRONTEND_MODE} +ARG VITE_BACKEND_ORIGIN=/api +ENV VITE_BACKEND_ORIGIN=${VITE_BACKEND_ORIGIN} RUN npm run build diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..f7968d7 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,43 @@ +# Frontend (Vite + React + TS) + +Single-page app served by Nginx in production. Uses Vite for build/dev, React Router, and debug-gated logging/overlay. + +## Prerequisites +- Node.js 20+ +- npm (uses `package-lock.json`) + +## Commands +```bash +npm ci # install deps +npm run dev # start Vite dev server (http://localhost:5173) +npm run build # type-check + production build +npm run lint # eslint +npm test # vitest unit tests (config, parsers, API helpers) +``` + +## Env/config +- `PUBLIC_BASE_PATH` (build arg) → Vite `base`/Router `basename` (default `/watch-party/`, use `/` for local dev). +- `FRONTEND_MODE` → `VITE_APP_MODE` (`debug` enables logging/overlay; `production` disables). +- `VITE_BACKEND_ORIGIN` (default `/api`) — relative path preferred so Nginx proxy works; avoid baking hosts. +- Optional polling overrides: + - `VITE_INTERVAL_TIME_SYNC_MS` (default 60000) + - `VITE_INTERVAL_TIME_SKEW_MS` (default 300000) + - `VITE_INTERVAL_SCHEDULE_POLL_MS` (default 60000) + +## Running locally +```bash +cd frontend +npm ci +VITE_APP_MODE=debug npm run dev # enables debug overlay/logging +``` +The dev server proxies `/api` to `http://localhost:8082` (see `vite.config.ts`). Adjust if your backend runs elsewhere. + +## Docker build +```bash +docker build -t watchparty-frontend \ + --build-arg PUBLIC_BASE_PATH=/watch-party/ \ + --build-arg FRONTEND_MODE=production \ + --build-arg VITE_BACKEND_ORIGIN=/api \ + . +``` +The runtime Nginx proxy target is controlled separately via `BACKEND_ORIGIN` in the root `.env`/compose. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0154190..1c69d25 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,8 @@ "globals": "^16.4.0", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", - "vite": "npm:rolldown-vite@7.1.12" + "vite": "^5.4.11", + "vitest": "^2.1.8" } }, "node_modules/@babel/code-frame": { @@ -309,38 +310,395 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" + "os": [ + "aix" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, "node_modules/@eslint-community/eslint-utils": { @@ -599,19 +957,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", - "integrity": "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -650,30 +995,31 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.90.0.tgz", - "integrity": "sha512-TfWn2tT97Weq1/1kTc+6ZeQ3TTj8350HoovtWaUYkX1nie7ONBqeMvudpluj4rmt2jc+l1QsBV/U70Oqsv1S4A==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@oxc-project/types": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.90.0.tgz", - "integrity": "sha512-fWvaufWUcLtm/OBKcNmxUkR0kQW5ZKAF0t03BXPqdzpxmnVCmSKzvUDRCOKnSagSfNzG/3ZdKpComH3GMy881g==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.39.tgz", - "integrity": "sha512-mjraAJQ3VRLPb3BUgVigHvmAYhiBpEeSM0dhvaO6XHtJ0k1o9Ng1Z6Qvlp4/1wDiUf7a10L5c3yleoGZ2r0Maw==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "cpu": [ "arm64" ], @@ -682,15 +1028,12 @@ "optional": true, "os": [ "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.39.tgz", - "integrity": "sha512-tnuiLq9vd08KsZeFkFgzCXVKsTgSZGn+YBQjHSEiUvXJy5pfUf82X/YyLCG8P6I+WDd2cgrcLilMBQPZgaNwkg==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -699,15 +1042,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.39.tgz", - "integrity": "sha512-wLFoB3ZM4AoeBlsP0eVbPzWfkEgvmnibMQEKUgWRfJnKhUWiSxl0kGdSw1fNYdX3KAqIeA5gPJNvSJmf6g5S3Q==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "cpu": [ "x64" ], @@ -716,15 +1056,26 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.39.tgz", - "integrity": "sha512-wzFZlixF9VMbyi++rHCU4Cy72SH11aBNnkadmvwTAbokwjYHi8NqxQ3/Lx00c700N6kwwuiTsbcGt5DEA9aROw==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "cpu": [ "x64" ], @@ -733,15 +1084,12 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.39.tgz", - "integrity": "sha512-eVnZcwGbje1uwdFjeQZQ6918RHgGIK7iTC+AoDsgetgAXQmQpnuWYQ9OWa5oTHNQyCkZbMfiHKgpkUPpceMecw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "cpu": [ "arm" ], @@ -750,15 +1098,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.39.tgz", - "integrity": "sha512-Td96iRQA0nmRZM6kJ3+LDDKWLh4bl0zqeR+IYxXwPZBw4iXSREzXrcZ3QqgFHqnXPgryIJEW1U1Ebh2xf+b2UA==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "cpu": [ "arm64" ], @@ -767,15 +1126,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.39.tgz", - "integrity": "sha512-bcSIh1TFUoPcexJH+gO1sE6wpSR0j3UpWBnjAwyM1PRKfjtqN4R9Du90ofH5KsR/A35FT3eP4mdnhMDTd5Yt+A==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "cpu": [ "arm64" ], @@ -784,15 +1140,82 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.39.tgz", - "integrity": "sha512-tYEcZdVGovEemh7ELr+VUoezGkuBgRZYvDHHW/HVIw9LQW5HKLtBIGLzFlOfu/Lq5b9FlDKl+lrY6weviaNnKw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "cpu": [ "x64" ], @@ -801,15 +1224,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.39.tgz", - "integrity": "sha512-xf9QdMC+qwQxtFAty/9RxgCLFdp9pFl09g86hxGPzlzCtHUjd+BmeUnUTXvVC8CHJLWECLQbFP6/233XHG0blA==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "cpu": [ "x64" ], @@ -818,15 +1238,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.39.tgz", - "integrity": "sha512-QCvN02VpE6zFYry0zAU+29D5+O9tJELNt+OjuCubilZdD/S8xFdho7qBJaa3YhFYyA9cReOMVH8Z8b3yWb4hcA==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", "cpu": [ "arm64" ], @@ -835,32 +1252,12 @@ "optional": true, "os": [ "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.39.tgz", - "integrity": "sha512-LFgshxApyBNiBHFVpun7tPrIQ4TvxW0f/endC5C4RzEHu7mxexBCQEkO5XrZ42Cr5DUY+ERNbkfNTUv+vVCaxQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.39.tgz", - "integrity": "sha512-Mykirawg+s1e0uzVSEFhUBTShvXrOghPnyuLYkCfw8gzy8bMYiJuxsAfcopzZIIAVOHeSblJoiA/e7gYFjg8HA==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "cpu": [ "arm64" ], @@ -869,15 +1266,12 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.39.tgz", - "integrity": "sha512-4PQJfWx7mdzXbAa4y+3OSSo911BZyJ/Is4pJKiwcGUqtvY66MX7BqlNWMr9QAozArAGE2knDubLqCQwZpK631w==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "cpu": [ "ia32" ], @@ -886,15 +1280,12 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.39.tgz", - "integrity": "sha512-0zmmPOWbFfp1g9ofieimHwhuclZMcib0HL52Q+JTRpOHChI2f83TtH3duKWtAaxqhLUndTr/Z5sxzb+G2FNL9g==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "cpu": [ "x64" ], @@ -903,28 +1294,21 @@ "optional": true, "os": [ "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.38", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", - "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "os": [ + "win32" + ] }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1307,6 +1691,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1363,16 +1860,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", - "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1380,6 +1867,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1455,6 +1952,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1486,6 +1993,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1503,6 +2027,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1586,6 +2120,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1599,6 +2143,8 @@ "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -1610,6 +2156,52 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1801,6 +2393,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1811,6 +2413,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2178,6 +2790,8 @@ "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "dev": true, "license": "MPL-2.0", + "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -2214,6 +2828,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2235,6 +2850,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2256,6 +2872,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2277,6 +2894,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2298,6 +2916,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2319,6 +2938,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2340,6 +2960,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2361,6 +2982,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2382,6 +3004,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2403,6 +3026,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2434,6 +3058,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2444,6 +3075,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2604,6 +3245,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2784,47 +3442,48 @@ "node": ">=0.10.0" } }, - "node_modules/rolldown": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.39.tgz", - "integrity": "sha512-05bTT0CJU9dvCRC0Uc4zwB79W5N9MV9OG/Inyx8KNE2pSrrApJoWxEEArW6rmjx113HIx5IreCoTjzLfgvXTdg==", + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.90.0", - "@rolldown/pluginutils": "1.0.0-beta.39", - "ansis": "^4.0.0" + "@types/estree": "1.0.8" }, "bin": { - "rolldown": "bin/cli.mjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.39", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.39", - "@rolldown/binding-darwin-x64": "1.0.0-beta.39", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.39", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.39", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.39", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.39", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.39", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.39", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.39", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.39", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.39", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.39", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.39" + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.39", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.39.tgz", - "integrity": "sha512-GkTtNCV8ObWbq3LrJStPBv9jkRPct8WlwotVjx3aU0RwfH3LyheixWK9Zhaj22C4EQj/TJxYyetoX+uOn/MWKw==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2894,6 +3553,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2904,6 +3570,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2930,52 +3610,48 @@ "node": ">=8" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=14.0.0" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=14.0.0" } }, "node_modules/to-regex-range": { @@ -3004,14 +3680,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3112,26 +3780,21 @@ } }, "node_modules/vite": { - "name": "rolldown-vite", - "version": "7.1.12", - "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.1.12.tgz", - "integrity": "sha512-JREtUS+Lpa3s5Ha3ajf2F4LMS4BFxlVjpGz0k0ZR8rV3ZO3tzk5hukqyi9yRBcrvnTUg/BEForyCDahALFYAZA==", + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.90.0", - "fdir": "^6.5.0", - "lightningcss": "^1.30.1", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rolldown": "1.0.0-beta.39", - "tinyglobby": "^0.2.15" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3140,31 +3803,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "esbuild": "^0.25.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -3179,44 +3836,96 @@ }, "terser": { "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "bin": { + "vite-node": "vite-node.mjs" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=12" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "node_modules/which": { @@ -3235,6 +3944,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 597fdd2..be85eff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^19.1.1", @@ -26,9 +28,7 @@ "globals": "^16.4.0", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", - "vite": "npm:rolldown-vite@7.1.12" - }, - "overrides": { - "vite": "npm:rolldown-vite@7.1.12" + "vite": "^5.4.11", + "vitest": "^2.1.8" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1502241..fc345d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,8 @@ import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom"; import Timer from "./components/Timer"; import ShowsPage from "./pages/ShowsPage"; import TimeSyncNotice from "./components/TimeSyncNotice"; +import { ToastViewport } from "./components/Toasts"; +import DebugOverlay from "./components/DebugOverlay"; import "./index.css"; const TIME_SYNC_OFF_THRESHOLD = 100; @@ -71,6 +73,8 @@ export default function App() { + + ); -} \ No newline at end of file +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..da03d25 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,111 @@ +import { logApiError, logApiRequest, logApiResponse } from "../utils/logger"; + +export type ApiErrorShape = { + message: string; + status?: number; + url?: string; + data?: unknown; + cause?: unknown; +}; + +export class ApiError extends Error { + status?: number; + url?: string; + data?: unknown; + constructor(message: string, opts?: Partial) { + super(message); + this.name = "ApiError"; + this.status = opts?.status; + this.url = opts?.url; + this.data = opts?.data; + if (opts?.cause !== undefined) { + (this as unknown as { cause: unknown }).cause = opts.cause; // best-effort for debugging visibility + } + } +} + +type FetchExpect = "json" | "text" | "void"; + +type MaybeAbortSignal = AbortSignal | null | undefined; + +type ApiFetchOptions = RequestInit & { + timeoutMs?: number; + expect?: FetchExpect; + logLabel?: string; +}; + +function mergeSignal(signal?: MaybeAbortSignal, timeoutMs?: number) { + if (!signal && !timeoutMs) return { signal, cleanup: () => {} }; + + const ac = new AbortController(); + let timeoutId: ReturnType | null = null; + + if (signal) { + if (signal.aborted) { + ac.abort(signal.reason); + } else { + const onAbort = () => ac.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + const err = new DOMException("Request timed out", "AbortError"); + ac.abort(err); + }, timeoutMs); + } + + const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); }; + return { signal: ac.signal, cleanup }; +} + +async function readBody(res: Response, expect: FetchExpect) { + if (expect === "void") return undefined; + if (expect === "text") return res.text(); + // json by default + return res.json(); +} + +export async function apiFetch(url: string, opts?: ApiFetchOptions): Promise { + const { timeoutMs, expect = "json", logLabel, ...init } = opts || {}; + const { signal: mergedSignal, cleanup } = mergeSignal(init.signal, timeoutMs); + const headers = { Accept: "application/json", ...(init.headers || {}) }; + const label = logLabel || url; + + try { + logApiRequest(label, { url, method: init.method || "GET" }); + const res = await fetch(url, { ...init, headers, signal: mergedSignal || init.signal }); + logApiResponse(label, res); + + if (!res.ok) { + let data: unknown; + try { data = await readBody(res, expect); } + catch { /* ignore parse errors for error responses */ } + throw new ApiError(`HTTP ${res.status}`, { status: res.status, url, data }); + } + + if (expect === "void") return undefined as T; + + try { + const data = (await readBody(res, expect)) as T; + return data; + } catch (e: unknown) { + throw new ApiError("Failed to parse response", { url, cause: e }); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + logApiError(label, err); + throw err; + } + const message = err instanceof Error ? err.message : "Request failed"; + const status = err && typeof err === "object" && "status" in err + ? Number((err as { status?: unknown }).status) + : undefined; + const apiErr = new ApiError(message, { url, cause: err, status }); + logApiError(label, apiErr); + throw apiErr; + } finally { + cleanup(); + } +} diff --git a/frontend/src/api/endpoint.ts b/frontend/src/api/endpoint.ts index 456e6d7..08c1aad 100644 --- a/frontend/src/api/endpoint.ts +++ b/frontend/src/api/endpoint.ts @@ -1,7 +1,9 @@ +import { buildApiUrl } from "../config"; + export const API_ENDPOINT = { v1: { - CURRENT: `/api/v1/current`, - SHOWS: `/api/v1/shows`, - TIME: `/api/v1/time` + CURRENT: buildApiUrl("/v1/current"), + SHOWS: buildApiUrl("/v1/shows"), + TIME: buildApiUrl("/v1/time"), }, -} as const; \ No newline at end of file +} as const; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..fff625b --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,23 @@ +// Shared API response shapes for the frontend. + +export type TimeResponse = { + now: number | string; +}; + +export type ScheduleResponse = { + ep_num: number; + ep_title: string; + season_name: string; + start_time?: string; + playback_length?: string; +}; + +export type ShowItem = { + id: number; + ep_num: number; + ep_title: string; + season_name: string; + start_time: string; + playback_length: string; + date_created: string; +}; diff --git a/frontend/src/api/watchparty.test.ts b/frontend/src/api/watchparty.test.ts new file mode 100644 index 0000000..0b5e0a6 --- /dev/null +++ b/frontend/src/api/watchparty.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { fetchSchedule, fetchServerNow } from "./watchparty"; + +const mockFetch = (payload: unknown, ok = true, status = 200) => { + const response = new Response(JSON.stringify(payload), { status, statusText: ok ? "OK" : "Error" }); + return vi.spyOn(globalThis, "fetch").mockResolvedValue(response as Response); +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("fetchSchedule", () => { + it("normalizes schedule payload", async () => { + const spy = mockFetch({ + ep_num: "2", + ep_title: "Title", + season_name: "S1", + start_time: "10:00", + playback_length: "01:00:00", + }); + const res = await fetchSchedule(); + expect(res.ep_num).toBe(2); + expect(res.ep_title).toBe("Title"); + expect(spy).toHaveBeenCalled(); + }); +}); + +describe("fetchServerNow", () => { + it("converts seconds to ms", async () => { + mockFetch({ now: 1700000000 }); // seconds + const ms = await fetchServerNow(); + expect(ms).toBe(1700000000 * 1000); + }); +}); diff --git a/frontend/src/api/watchparty.ts b/frontend/src/api/watchparty.ts new file mode 100644 index 0000000..beffa9e --- /dev/null +++ b/frontend/src/api/watchparty.ts @@ -0,0 +1,97 @@ +import { API_ENDPOINT } from "./endpoint"; +import { ApiError, apiFetch } from "./client"; +import type { ScheduleResponse, ShowItem, TimeResponse } from "./types"; + +export type { ScheduleResponse, ShowItem, TimeResponse } from "./types"; + +function asNumber(v: unknown, fallback = 0) { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : fallback; +} + +function asString(v: unknown, fallback = "") { + return typeof v === "string" ? v : fallback; +} + +function normalizeSchedule(data: unknown): ScheduleResponse { + if (data == null || typeof data !== "object") { + throw new ApiError("Bad schedule payload", { url: API_ENDPOINT.v1.CURRENT, data }); + } + const obj = data as Record; + return { + ep_num: asNumber(obj.ep_num, 0), + ep_title: asString(obj.ep_title, "未設定"), + season_name: asString(obj.season_name, "未設定"), + start_time: obj.start_time ? asString(obj.start_time) : undefined, + playback_length: obj.playback_length ? asString(obj.playback_length) : undefined, + }; +} + +function normalizeShows(data: unknown): ShowItem[] { + if (!Array.isArray(data)) { + throw new ApiError("Shows payload is not an array", { url: API_ENDPOINT.v1.SHOWS, data }); + } + return data.map((item) => { + if (!item || typeof item !== "object") { + throw new ApiError("Bad show item", { url: API_ENDPOINT.v1.SHOWS, data: item }); + } + const obj = item as Record; + const id = asNumber(obj.id, NaN); + if (!Number.isFinite(id)) { + throw new ApiError("Show item missing id", { url: API_ENDPOINT.v1.SHOWS, data: item }); + } + return { + id, + ep_num: asNumber(obj.ep_num, 0), + ep_title: asString(obj.ep_title, "不明"), + season_name: asString(obj.season_name, "不明"), + start_time: asString(obj.start_time, ""), + playback_length: asString(obj.playback_length, ""), + date_created: asString(obj.date_created, ""), + }; + }); +} + +export async function fetchSchedule(signal?: AbortSignal) { + const data = await apiFetch(API_ENDPOINT.v1.CURRENT, { + signal, + timeoutMs: 12_000, + logLabel: "load schedule", + }); + return normalizeSchedule(data); +} + +export async function fetchShows(signal?: AbortSignal) { + const data = await apiFetch(API_ENDPOINT.v1.SHOWS, { + signal, + timeoutMs: 12_000, + logLabel: "fetch shows", + }); + return normalizeShows(data); +} + +export async function postCurrentEpisode(payload: { id: number; start_time?: string }, signal?: AbortSignal) { + await apiFetch(API_ENDPOINT.v1.CURRENT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal, + timeoutMs: 12_000, + expect: "void", + logLabel: "set current episode", + }); +} + +export async function fetchServerNow(signal?: AbortSignal): Promise { + const data = await apiFetch(API_ENDPOINT.v1.TIME, { + signal, + timeoutMs: 8_000, + logLabel: "server time", + }); + let n = typeof data?.now === "number" ? data.now : Number(data?.now); + if (!Number.isFinite(n)) { + throw new ApiError("Bad time payload", { url: API_ENDPOINT.v1.TIME, data }); + } + if (n < 1e12) n = n * 1000; // seconds → ms + return n; +} diff --git a/frontend/src/components/DebugOverlay.tsx b/frontend/src/components/DebugOverlay.tsx new file mode 100644 index 0000000..f25a1b6 --- /dev/null +++ b/frontend/src/components/DebugOverlay.tsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from "react"; +import type { DebugLog, Level } from "../utils/logger"; +import { subscribeLogs, logger } from "../utils/logger"; + +function fmt(ts: number) { + const d = new Date(ts); + return d.toLocaleTimeString(undefined, { hour12: false }) + "." + String(d.getMilliseconds()).padStart(3, "0"); +} + +function levelLabel(l: Level) { + if (l === "debug") return "DBG"; + if (l === "info") return "INF"; + if (l === "warn") return "WRN"; + return "ERR"; +} + +export default function DebugOverlay() { + const [open, setOpen] = useState(() => { + return logger.enabled && (localStorage.getItem("debugOverlay") === "1"); + }); + const [logs, setLogs] = useState([]); + + useEffect(() => subscribeLogs(setLogs), []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key.toLowerCase() === "d" && e.shiftKey && e.ctrlKey) { + setOpen((v) => { + const next = !v; + if (next) localStorage.setItem("debugOverlay", "1"); + else localStorage.removeItem("debugOverlay"); + return next; + }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const rendered = useMemo(() => logs.slice(-30).reverse(), [logs]); + + if (!logger.enabled) return null; + + return ( +
+ + {open && ( +
+
Recent API logs (Ctrl+Shift+D to toggle)
+
+ {rendered.length === 0 &&
No logs yet.
} + {rendered.map((l) => { + let detailText: string | null = null; + if (l.details !== undefined) { + if (typeof l.details === "string") detailText = l.details; + else { + try { detailText = JSON.stringify(l.details); } + catch { detailText = String(l.details); } + } + } + return ( +
+ {fmt(l.ts)} + {levelLabel(l.level)} + {l.label} + {detailText && ( + + {detailText} + + )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/TimeSyncNotice.tsx b/frontend/src/components/TimeSyncNotice.tsx index 57f97dc..119284e 100644 --- a/frontend/src/components/TimeSyncNotice.tsx +++ b/frontend/src/components/TimeSyncNotice.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTimeSkew } from "../hooks/useTimeSkew"; +import { config } from "../config"; function formatDelay(ms: number, withSign = true) { const sign = withSign ? (ms > 0 ? "+" : ms < 0 ? "−" : "") : ""; @@ -25,10 +26,13 @@ export default function TimeSyncNotice({ dismissTtlMs?: number; storageScope?: "session" | "local"; }) { - const { skewMs, rttMs, recheck } = useTimeSkew({ intervalMs }); + const { skewMs, rttMs, recheck, error } = useTimeSkew({ intervalMs: intervalMs ?? config.intervals.timeSkewMs }); const KEY = "timesync.dismissedUntil"; - const store = storageScope === "local" ? window.localStorage : window.sessionStorage; + const store = useMemo( + () => (storageScope === "local" ? window.localStorage : window.sessionStorage), + [storageScope], + ); // read dismissed state (true if now < dismissedUntil) const [dismissed, setDismissed] = useState(() => { @@ -56,36 +60,45 @@ export default function TimeSyncNotice({ store.removeItem(KEY); } } catch { - /* ignore */ + // ignore storage errors } return () => { if (id) clearTimeout(id); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dismissed]); + }, [dismissed, store]); // if skew returns within threshold, clear dismissal so it can show again later if it drifts useEffect(() => { if (skewMs != null && Math.abs(skewMs) <= thresholdMs && dismissed) { setDismissed(false); - try { store.removeItem(KEY); } catch { } + try { store.removeItem(KEY); } catch { void 0; } } - }, [skewMs, thresholdMs, dismissed]); + }, [skewMs, thresholdMs, dismissed, store]); - if (dismissed || skewMs == null || Math.abs(skewMs) <= thresholdMs) return null; + if (dismissed) return null; - const ahead = skewMs > 0; - const msgJa = ahead - ? `端末の時計が正確な時刻より ${formatDelay(skewMs)} 進んでいます(通信往復遅延 ${rttMs ?? "-"}ms)` - : `端末の時計が正確な時刻より ${formatDelay(-skewMs)} 遅れています(通信往復遅延 ${rttMs ?? "-"}ms)`; - const msgEn = ahead - ? `Your device clock is ${formatDelay(skewMs)} ahead of the correct time (RTT ${rttMs ?? "-"}ms).` - : `Your device clock is ${formatDelay(-skewMs)} behind the correct time (RTT ${rttMs ?? "-"}ms).`; + const hasSkew = skewMs != null; + const skewVal = skewMs ?? 0; + if (!error && (!hasSkew || Math.abs(skewVal) <= thresholdMs)) return null; + + const ahead = skewVal > 0; + const msgJa = error + ? `時刻同期に失敗しました: ${error}` + : ahead + ? `端末の時計が正確な時刻より ${formatDelay(skewVal)} 進んでいます(通信往復遅延 ${rttMs ?? "-"}ms)` + : `端末の時計が正確な時刻より ${formatDelay(-skewVal)} 遅れています(通信往復遅延 ${rttMs ?? "-"}ms)`; + const msgEn = error + ? `Time sync failed: ${error}` + : ahead + ? `Your device clock is ${formatDelay(skewVal)} ahead of the correct time (RTT ${rttMs ?? "-"}ms).` + : `Your device clock is ${formatDelay(-skewVal)} behind the correct time (RTT ${rttMs ?? "-"}ms).`; const onClose = () => { setDismissed(true); try { const until = Date.now() + dismissTtlMs; store.setItem(KEY, String(until)); - } catch { } + } catch { + // ignore storage failures + } }; return ( @@ -101,4 +114,4 @@ export default function TimeSyncNotice({ ); -} \ No newline at end of file +} diff --git a/frontend/src/components/Timer.tsx b/frontend/src/components/Timer.tsx index 329db19..880cec0 100644 --- a/frontend/src/components/Timer.tsx +++ b/frontend/src/components/Timer.tsx @@ -1,25 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useServerClock } from "../hooks/useServerClock"; -import { API_ENDPOINT } from "../api/endpoint"; -import { logApiError, logApiRequest, logApiResponse } from "../utils/logger"; +import { logApiError } from "../utils/logger"; +import { toastError } from "../utils/toastBus"; +import { fetchSchedule } from "../api/watchparty"; +import type { ScheduleResponse } from "../api/types"; +import { config } from "../config"; +import { parseDurationToSeconds, parseStartTime } from "../utils/time"; // ===== Config & fallbacks ===== const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09) -const API_URL_CURRENT = API_ENDPOINT.v1.CURRENT const FALLBACK_START_HOUR = 19; const FALLBACK_START_MINUTE = 25; const FALLBACK_END_SECONDS = 300; -const TIME_SYNC_INTERVAL = 60_000; +const TIME_SYNC_INTERVAL = config.intervals.timeSyncMs; +const SCHEDULE_POLL_INTERVAL = config.intervals.schedulePollMs; // ============================== -type ApiSchedule = { - ep_num: number; - ep_title: string; - season_name: string; - start_time: string; // "HH:MM" or "HH:MM:SS" - playback_length: string; // "MM:SS" or "HH:MM:SS" -}; - function formatHMS(total: number) { const t = Math.max(0, Math.floor(total)); const h = Math.floor(t / 3600); @@ -31,31 +27,6 @@ function formatHMS(total: number) { return h > 0 ? `${HH}:${MM}:${SS}` : `${MM}:${SS}`; } -function parseStartTime(s?: string): { hour: number; minute: number; second: number } | null { - if (!s || typeof s !== "string") return null; - const m = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(s.trim()); - if (!m) return null; - const hour = Number(m[1]); - const minute = Number(m[2]); - const second = m[3] ? Number(m[3]) : 0; - if (hour < 0 || hour > 23) return null; - return { hour, minute, second }; -} - -function parseDurationToSeconds(s?: string): number { - if (!s || typeof s !== "string") return FALLBACK_END_SECONDS; - const parts = s.trim().split(":").map(Number); - if (parts.some((n) => Number.isNaN(n) || n < 0)) return FALLBACK_END_SECONDS; - if (parts.length === 2) { - const [mm, ss] = parts; - return mm * 60 + ss; - } else if (parts.length === 3) { - const [hh, mm, ss] = parts; - return hh * 3600 + mm * 60 + ss; - } - return FALLBACK_END_SECONDS; -} - function getJstYMD(now = new Date()) { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: TIMEZONE, @@ -78,18 +49,8 @@ function jstToUtcMs(y: number, m: number, d: number, hh: number, mm: number, ss } async function loadSchedule(signal?: AbortSignal) { - logApiRequest("loadSchedule", { url: API_URL_CURRENT }); - const res = await fetch(API_URL_CURRENT, { cache: "no-store", signal }); - logApiResponse("loadSchedule", res); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const payload = (await res.json()) as ApiSchedule; - logApiResponse("loadSchedule payload", res, { - ep_num: payload?.ep_num, - start_time: payload?.start_time, - playback_length: payload?.playback_length, - season_name: payload?.season_name, - }); - return payload; + // Shared fetcher handles logging and error shaping. + return fetchSchedule(signal) as Promise; } export default function Timer() { @@ -113,29 +74,38 @@ export default function Timer() { refreshMs: TIME_SYNC_INTERVAL, }); + const applySchedule = React.useCallback((data: ScheduleResponse | null) => { + if (!data) return; + const parsedStart = parseStartTime(data.start_time); + const end = parseDurationToSeconds(data.playback_length, FALLBACK_END_SECONDS); + const { y, m, d } = getJstYMD(); + const startMs = parsedStart + ? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second) + : startUtcMs; // keep fallback + + setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name }); + setStartUtcMs(startMs); + setEndSeconds(end); + setLoaded(true); + setErrorMsg(null); + }, [startUtcMs]); + + const fetchAndApply = React.useCallback(async (signal?: AbortSignal) => { + try { + const data = await loadSchedule(signal); + applySchedule(data); + } catch (e) { + setLoaded(true); + setErrorMsg("Failed to load schedule; using defaults."); + logApiError("loadSchedule", e); + toastError("スケジュール取得に失敗しました", e instanceof Error ? e.message : String(e || "")); + } + }, [applySchedule]); + // fetch schedule useEffect(() => { const ac = new AbortController(); - loadSchedule(ac.signal) - .then((data) => { - const parsedStart = parseStartTime(data.start_time); - const end = parseDurationToSeconds(data.playback_length); - const { y, m, d } = getJstYMD(); - const startMs = parsedStart - ? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second) - : startUtcMs; // keep fallback - - setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name }); - setStartUtcMs(startMs); - setEndSeconds(end); - setLoaded(true); - setErrorMsg(null); - }) - .catch((e) => { - setLoaded(true); - setErrorMsg("Failed to load schedule; using defaults."); - logApiError("loadSchedule", e); - }); + fetchAndApply(ac.signal); return () => ac.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // mount only @@ -160,7 +130,7 @@ export default function Timer() { }; tick(); return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; - }, [startUtcMs, endSeconds, nowMs]); + }, [startUtcMs, endSeconds, nowMs, ready]); // status labels const startLabel = useMemo(() => { @@ -193,23 +163,9 @@ export default function Timer() { ac?.abort(); // cancel any in-flight ac = new AbortController(); try { - const data = await loadSchedule(ac.signal); - // Recompute start/end from fresh data - const parsedStart = parseStartTime(data.start_time); - const end = parseDurationToSeconds(data.playback_length); - const { y, m, d } = getJstYMD(); - const nextStartMs = parsedStart - ? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second) - : startUtcMs; - setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name }); - setStartUtcMs(nextStartMs); - setEndSeconds(end); - setErrorMsg(null); - setLoaded(true); - // If the new schedule has moved us out of "ended", the ticking effect will handle it. - } catch (e) { - logApiError("loadSchedule (ended poll)", e); - // keep current UI; try again next tick + await fetchAndApply(ac.signal); + } catch { + // fetchAndApply already handles logging/toast } }; // immediate fetch once the timer ends @@ -217,7 +173,7 @@ export default function Timer() { // then poll every 60s const id = window.setInterval(() => { if (!stopped) refetch(); - }, 60_000); + }, SCHEDULE_POLL_INTERVAL); return () => { stopped = true; if (id) window.clearInterval(id); @@ -278,7 +234,14 @@ export default function Timer() { {loaded && phase === "ended" && (
再生終了。 開始 {startLabel} JST.
)} - {errorMsg &&
{errorMsg}
} + {errorMsg && ( +
+ {errorMsg}{" "} + +
+ )} {showPrestartCountdown && (
diff --git a/frontend/src/components/Toasts.tsx b/frontend/src/components/Toasts.tsx new file mode 100644 index 0000000..f7b9a60 --- /dev/null +++ b/frontend/src/components/Toasts.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import type { Toast } from "../utils/toastBus"; +import { dismissToast, subscribeToasts } from "../utils/toastBus"; + +type ToastProps = { + toast: Toast; +}; + +function ToastItem({ toast }: ToastProps) { + return ( +
+
+
{toast.level === "error" ? "Error" : toast.level === "warn" ? "Warning" : "Info"}
+
{toast.message}
+ {toast.detail &&
{toast.detail}
} +
+ +
+ ); +} + +export function ToastViewport() { + const [toasts, setToasts] = useState([]); + + useEffect(() => subscribeToasts(setToasts), []); + + useEffect(() => { + const timers = toasts.map((t) => { + if (!t.expiresAt) return null; + const delay = Math.max(500, t.expiresAt - Date.now()); + return window.setTimeout(() => dismissToast(t.id), delay); + }).filter(Boolean) as number[]; + return () => timers.forEach((id) => window.clearTimeout(id)); + }, [toasts]); + + if (toasts.length === 0) return null; + return ( +
+ {toasts.map((t) => )} +
+ ); +} diff --git a/frontend/src/config.test.ts b/frontend/src/config.test.ts new file mode 100644 index 0000000..513c70d --- /dev/null +++ b/frontend/src/config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { buildApiUrl, normalizeApiBase, normalizeBasePath, envNumber } from "./config"; + +describe("normalizeBasePath", () => { + it("enforces leading and trailing slash", () => { + expect(normalizeBasePath("watch")).toBe("/watch/"); + expect(normalizeBasePath("/watch")).toBe("/watch/"); + expect(normalizeBasePath("/watch/")).toBe("/watch/"); + }); +}); + +describe("normalizeApiBase", () => { + it("enforces leading slash and trims trailing", () => { + expect(normalizeApiBase("api")).toBe("/api"); + expect(normalizeApiBase("/api/")).toBe("/api"); + }); +}); + +describe("buildApiUrl", () => { + it("joins base and path without duplicate slashes", () => { + expect(buildApiUrl("/v1/test")).toBe("/api/v1/test"); + expect(buildApiUrl("v1/test")).toBe("/api/v1/test"); + }); +}); + +describe("envNumber", () => { + it("returns fallback on bad input", () => { + expect(envNumber("MISSING_KEY", 123)).toBe(123); + }); +}); diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..6d5f2f9 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,61 @@ +type AppConfig = { + mode: string; + isDebug: boolean; + basePath: string; + apiBase: string; + backendOrigin: string; + intervals: { + timeSyncMs: number; + timeSkewMs: number; + schedulePollMs: number; + }; +}; + +export function normalizeBasePath(raw?: string) { + let v = (raw || "/").trim(); + if (!v.startsWith("/")) v = `/${v}`; + if (!v.endsWith("/")) v = `${v}/`; + return v.replace(/\/{2,}/g, "/"); +} + +export function normalizeApiBase(raw?: string) { + let v = (raw || "/api").trim(); + if (!v.startsWith("/")) v = `/${v}`; + return v.replace(/\/+$/, ""); +} + +type ImportMetaEnvish = { env?: Record }; + +export function envNumber(key: string, fallback: number) { + const rawEnv = (import.meta as ImportMetaEnvish).env || {}; + const raw = rawEnv[key]; + if (raw == null) return fallback; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +const rawMode = (import.meta.env.VITE_APP_MODE || import.meta.env.MODE || "").toString().toLowerCase(); +const basePath = normalizeBasePath(import.meta.env.BASE_URL); +const apiBase = normalizeApiBase(import.meta.env.VITE_BACKEND_ORIGIN); +const timeSyncMs = envNumber("VITE_INTERVAL_TIME_SYNC_MS", 60_000); +const timeSkewMs = envNumber("VITE_INTERVAL_TIME_SKEW_MS", 5 * 60_000); +const schedulePollMs = envNumber("VITE_INTERVAL_SCHEDULE_POLL_MS", 60_000); + +export const config: AppConfig = { + mode: rawMode || "production", + isDebug: rawMode === "debug", + basePath, + apiBase, + backendOrigin: apiBase, // alias for clarity + intervals: { + timeSyncMs, + timeSkewMs, + schedulePollMs, + }, +}; + +export function buildApiUrl(path: string) { + const p = path.startsWith("/") ? path : `/${path}`; + const base = config.apiBase.replace(/\/$/, ""); + return `${base}${p}`; +} diff --git a/frontend/src/hooks/useServerClock.ts b/frontend/src/hooks/useServerClock.ts index c5f15ad..de16361 100644 --- a/frontend/src/hooks/useServerClock.ts +++ b/frontend/src/hooks/useServerClock.ts @@ -1,9 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { API_ENDPOINT } from "../api/endpoint"; -import { logApiError, logApiRequest, logApiResponse } from "../utils/logger"; - -/** Uses /api/time => { now: } and returns a server-correct "nowMs()" */ -const TIME_URL_ENDPOINT = API_ENDPOINT.v1.TIME; +import { fetchServerNow } from "../api/watchparty"; +import { logApiError } from "../utils/logger"; export function useServerClock(opts?: { refreshMs?: number; @@ -22,19 +19,13 @@ export function useServerClock(opts?: { try { setError(null); const t0 = Date.now(); - logApiRequest("useServerClock", { url: TIME_URL_ENDPOINT }); - const res = await fetch(TIME_URL_ENDPOINT, { cache: "no-store" }); + const serverNow = await fetchServerNow(); const t1 = Date.now(); - logApiResponse("useServerClock", res, { roundTripMs: t1 - t0 }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - if (typeof data?.now !== "number") throw new Error("Bad time payload"); - const s = data.now; - const offset = Math.round(((t0 + t1) / 2) - s); // (+) client ahead, (-) client behind + const offset = Math.round(((t0 + t1) / 2) - serverNow); // (+) client ahead, (-) client behind setOffsetMs(offset); - logApiResponse("useServerClock computed", res, { serverNow: s, offset }); - } catch (e: any) { - setError(e?.message || "time sync failed"); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "time sync failed"; + setError(msg); logApiError("useServerClock", e); } }, []); diff --git a/frontend/src/hooks/useTimeSkew.ts b/frontend/src/hooks/useTimeSkew.ts index 28b772e..6ce7877 100644 --- a/frontend/src/hooks/useTimeSkew.ts +++ b/frontend/src/hooks/useTimeSkew.ts @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { API_ENDPOINT } from "../api/endpoint"; -import { logApiError, logApiRequest, logApiResponse } from "../utils/logger"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { logApiError } from "../utils/logger"; +import { fetchServerNow } from "../api/watchparty"; /** * Measures client clock skew vs server time {now: }. @@ -11,7 +11,6 @@ import { logApiError, logApiRequest, logApiResponse } from "../utils/logger"; * offset ≈ ((t0 + t1)/2) - s * Positive offset => client is AHEAD by that many ms. */ -const TIME_URL_ENDPOINT = API_ENDPOINT.v1.TIME; export function useTimeSkew(opts?: { intervalMs?: number; samples?: number; @@ -28,34 +27,16 @@ export function useTimeSkew(opts?: { const [error, setError] = useState(null); const timerRef = useRef(null); - const measureOnce = async () => { + const measureOnce = useCallback(async () => { const t0 = Date.now(); - logApiRequest("useTimeSkew", { url: TIME_URL_ENDPOINT }); - const res = await fetch(TIME_URL_ENDPOINT, { - cache: "no-store", - headers: { Accept: "application/json" }, - }); + const serverMs = await fetchServerNow(); const t1 = Date.now(); - logApiResponse("useTimeSkew", res, { roundTripMs: t1 - t0 }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - // Robust parsing: number or string, seconds or ms - const data = await res.json() as any; - let serverMs = typeof data?.now === "number" ? data.now : Number(data?.now); - if (!Number.isFinite(serverMs)) { - throw new Error("Bad time payload (expecting { now: number })"); - } - // Heuristic: if it's too small to be ms epoch, treat as seconds - if (serverMs < 1e12) { // ~Sat Sep 09 2001 ms epoch - serverMs = serverMs * 1000; // seconds -> ms - } - const rtt = t1 - t0; const offset = Math.round(((t0 + t1) / 2) - serverMs); return { offset, rtt }; - }; + }, []); - const measure = async () => { + const measure = useCallback(async () => { try { setError(null); const offsets: number[] = []; @@ -72,11 +53,12 @@ export function useTimeSkew(opts?: { const minRtt = Math.min(...rtts); setSkewMs(median); setRttMs(minRtt); - } catch (e: any) { - setError(e?.message || "Time sync failed"); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Time sync failed"; + setError(msg); logApiError("useTimeSkew", e); } - }; + }, [samples, measureOnce]); useEffect(() => { if (!enabled) return; @@ -85,7 +67,7 @@ export function useTimeSkew(opts?: { timerRef.current = window.setInterval(measure, intervalMs); } return () => { if (timerRef.current) window.clearInterval(timerRef.current); }; - }, [intervalMs, samples, enabled]); + }, [intervalMs, samples, enabled, measure]); return { skewMs, rttMs, error, recheck: measure }; } diff --git a/frontend/src/index.css b/frontend/src/index.css index cc8d6e3..8925693 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -492,4 +492,108 @@ kbd { } @supports not (container-type: inline-size) { .timer-hero { font-size: clamp(28px, 8vw, 96px); } -} \ No newline at end of file +} + +/* Toasts */ +.toast-viewport { + position: fixed; + bottom: 16px; + right: 16px; + display: grid; + gap: 8px; + z-index: 200; + max-width: 320px; +} +.toast { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(15, 21, 32, 0.94); + border: 1px solid rgba(255,255,255,0.12); + box-shadow: 0 12px 30px rgba(0,0,0,0.35); +} +.toast.toast-error { border-color: rgba(255, 99, 99, 0.4); } +.toast.toast-warn { border-color: rgba(255, 193, 99, 0.4); } +.toast.toast-info { border-color: rgba(121, 192, 255, 0.4); } +.toast-body { text-align: left; } +.toast-title { font-weight: 700; font-size: 13px; margin-bottom: 2px; } +.toast-message { font-size: 13px; color: var(--text); } +.toast-detail { font-size: 12px; color: var(--subtle); margin-top: 2px; } +.toast-close { + background: transparent; + color: var(--subtle); + border: none; + font-size: 16px; + cursor: pointer; + align-self: start; +} +.toast-close:hover { color: var(--text); } + +/* Debug overlay */ +.debug-overlay { + position: fixed; + bottom: 16px; + left: 16px; + z-index: 190; + color: var(--text); +} +.debug-toggle { + background: rgba(255,255,255,0.08); + color: var(--text); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; +} +.debug-toggle:hover { background: rgba(255,255,255,0.12); } +.debug-panel { + margin-top: 10px; + width: min(420px, 90vw); + max-height: 50vh; + background: rgba(11, 15, 20, 0.96); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; + box-shadow: 0 16px 32px rgba(0,0,0,0.4); + display: flex; + flex-direction: column; +} +.debug-header { + padding: 10px 12px; + font-weight: 700; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.debug-body { + padding: 8px 12px; + overflow: auto; + display: grid; + gap: 6px; +} +.debug-row { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 8px; + align-items: baseline; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.debug-row .debug-label { font-weight: 700; } +.debug-row .debug-details { color: var(--subtle); font-weight: 400; } +.debug-row.level-error { color: #ffaaaa; } +.debug-row.level-warn { color: #ffd27f; } +.debug-row.level-info { color: #9bd4ff; } +.debug-row.level-debug { color: #b3c4ff; } +.debug-empty { color: var(--subtle); font-size: 12px; } + +.link-btn { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-weight: 700; + text-decoration: underline; + padding: 0 2px; +} +.link-btn:hover { color: #bfe3ff; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ce7ad38..42e929d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,11 +3,12 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; import "./index.css"; +import { config } from "./config"; ReactDOM.createRoot(document.getElementById("root")!).render( - + -); \ No newline at end of file +); diff --git a/frontend/src/pages/ShowsPage.tsx b/frontend/src/pages/ShowsPage.tsx index f2996fb..a5ec223 100644 --- a/frontend/src/pages/ShowsPage.tsx +++ b/frontend/src/pages/ShowsPage.tsx @@ -1,20 +1,10 @@ -import { useEffect, useMemo, useState, useRef } from "react"; +import React, { useEffect, useMemo, useState, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { API_ENDPOINT } from "../api/endpoint"; -import { logApiError, logApiRequest, logApiResponse } from "../utils/logger"; +import { logApiError } from "../utils/logger"; +import { toastError, toastInfo } from "../utils/toastBus"; +import { fetchShows, postCurrentEpisode } from "../api/watchparty"; +import type { ShowItem } from "../api/watchparty"; -type Show = { - id: number; - ep_num: number; - ep_title: string; - season_name: string; - start_time: string; - playback_length: string; - date_created: string; -}; - -const GET_URL = API_ENDPOINT.v1.SHOWS; -const POST_URL = API_ENDPOINT.v1.CURRENT; const REDIRECT_DELAY_S = 3; const HHMM = /^(\d{1,2}):([0-5]\d)$/; @@ -36,7 +26,7 @@ function toHHMM(v: string): string | null { } export default function ShowsPage() { - const [shows, setShows] = useState([]); + const [shows, setShows] = useState([]); const [loading, setLoading] = useState(true); const [posting, setPosting] = useState(false); const [error, setError] = useState(null); @@ -68,28 +58,27 @@ export default function ShowsPage() { return v; // fallback (unexpected format) } + const loadShows = React.useCallback(async () => { + setError(null); + try { + setLoading(true); + const data = await fetchShows(); + data.sort((a, b) => a.id - b.id); // ASC + setShows(data); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "番組一覧の取得に失敗しました。"; + setError(msg); + logApiError("fetch shows", e); + toastError("番組一覧の取得に失敗しました", msg); + } finally { + setLoading(false); + } + }, []); + // 一覧取得 useEffect(() => { - let cancelled = false; - (async () => { - try { - setLoading(true); - logApiRequest("fetch shows", { url: GET_URL }); - const res = await fetch(GET_URL, { cache: "no-store" }); - logApiResponse("fetch shows", res); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as Show[]; - data.sort((a, b) => a.id - b.id); // ASC - setShows(data); - } catch (e: any) { - if (!cancelled) setError(e.message || "番組一覧の取得に失敗しました。"); - logApiError("fetch shows", e); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { cancelled = true; }; - }, []); + loadShows().catch(() => {}); + }, [loadShows]); useEffect(() => { return () => { if (redirectTid.current) { @@ -107,7 +96,7 @@ export default function ShowsPage() { const selected = shows.find(s => s.id === selectedId); if (!selected) { setError("選択中のエピソードが見つかりません。"); return; } - const payload: any = { id: selectedId }; + const payload: { id: number; start_time?: string } = { id: selectedId }; if (startTime.trim()) { const normalized = toHHMMSS(startTime); @@ -119,14 +108,8 @@ export default function ShowsPage() { try { setPosting(true); - logApiRequest("set current episode", { url: POST_URL, payload }); - const res = await fetch(POST_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - logApiResponse("set current episode", res); - if (!res.ok) throw new Error(`POST 失敗 (${res.status})`); + await postCurrentEpisode(payload); + toastInfo("現在のエピソードを設定しました"); setRedirectIn(REDIRECT_DELAY_S); const start = Date.now(); redirectTid.current = window.setInterval(() => { @@ -134,9 +117,11 @@ export default function ShowsPage() { if (left <= 0) { window.clearInterval(redirectTid.current!); navigate("/", { replace: true }); } else setRedirectIn(left); }, 250); - } catch (e: any) { - setError(e.message || "現在のエピソード設定に失敗しました。"); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "現在のエピソード設定に失敗しました。"; + setError(msg); logApiError("set current episode", e); + toastError("現在のエピソード設定に失敗しました", msg); } finally { setPosting(false); } @@ -150,7 +135,14 @@ export default function ShowsPage() {

{loading &&
読み込み中…
} - {error &&
{error}
} + {error && ( +
+ {error}{" "} + +
+ )} {!loading && shows.length === 0 &&
エピソードがありません。
} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..4c98ceb --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,27 @@ +// Vite SSR helpers stubbed for Vitest with rolldown build. +// Avoids ReferenceError: __vite_ssr_exportName__ when running unit tests. +(globalThis as any).__vite_ssr_exportName__ = (mod: unknown, key: unknown, value: unknown) => { + if (!(globalThis as any).__debug_ssr__) { + (globalThis as any).__debug_ssr__ = []; + } + const logArr = (globalThis as any).__debug_ssr__ as unknown[]; + if (logArr.length < 5) { + logArr.push([typeof mod, typeof key, typeof value]); + if (logArr.length === 5) { + // eslint-disable-next-line no-console + console.log("ssr helper calls", logArr); + } + } + if (mod && typeof mod === "object" && typeof key === "string") { + (mod as Record)[key] = value; + return value; + } + if (typeof mod === "string" && typeof key === "function") { + (globalThis as Record)[mod] = key(); + return value; + } + if (typeof key === "string") { + (globalThis as Record)[key] = typeof value === "function" ? value() : value; + } + return value; +}; diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts index 41783aa..8bbc4e3 100644 --- a/frontend/src/utils/logger.ts +++ b/frontend/src/utils/logger.ts @@ -1,24 +1,53 @@ -const envMode = (import.meta.env.VITE_APP_MODE || import.meta.env.MODE || "").toString().toLowerCase(); -const enabled = envMode === "debug"; +import { config } from "../config"; + +const envMode = config.mode; +const enabled = config.isDebug; const prefix = "[watch-party]"; +const MAX_LOGS = 50; -type Level = "debug" | "info" | "warn" | "error"; +export type Level = "debug" | "info" | "warn" | "error"; -function write(level: Level, ...args: unknown[]) { +export type DebugLog = { + id: number; + ts: number; + level: Level; + label: string; + details?: unknown; +}; + +let nextId = 1; +const buffer: DebugLog[] = []; +const subscribers = new Set<(logs: DebugLog[]) => void>(); + +function publish(entry: DebugLog) { + buffer.push(entry); + if (buffer.length > MAX_LOGS) buffer.splice(0, buffer.length - MAX_LOGS); + subscribers.forEach((fn) => fn([...buffer])); +} + +function write(level: Level, label: string, details?: unknown) { if (!enabled) return; const fn = console[level] || console.log; - fn(prefix, ...args); + if (details === undefined) fn(prefix, label); + else fn(prefix, label, details); + publish({ id: nextId++, ts: Date.now(), level, label, details }); } export const logger = { enabled, mode: envMode, - debug: (...args: unknown[]) => write("debug", ...args), - info: (...args: unknown[]) => write("info", ...args), - warn: (...args: unknown[]) => write("warn", ...args), - error: (...args: unknown[]) => write("error", ...args), + debug: (label: string, details?: unknown) => write("debug", label, details), + info: (label: string, details?: unknown) => write("info", label, details), + warn: (label: string, details?: unknown) => write("warn", label, details), + error: (label: string, details?: unknown) => write("error", label, details), }; +export function subscribeLogs(fn: (logs: DebugLog[]) => void) { + subscribers.add(fn); + fn([...buffer]); + return () => { subscribers.delete(fn); }; +} + export function logApiRequest(label: string, details?: Record) { if (!enabled) return; logger.debug(`${label}: request`, details || {}); diff --git a/frontend/src/utils/time.test.ts b/frontend/src/utils/time.test.ts new file mode 100644 index 0000000..7a30f50 --- /dev/null +++ b/frontend/src/utils/time.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { parseDurationToSeconds, parseStartTime } from "./time"; + +describe("parseStartTime", () => { + it("parses HH:MM and HH:MM:SS", () => { + expect(parseStartTime("09:15")).toEqual({ hour: 9, minute: 15, second: 0 }); + expect(parseStartTime("21:05:30")).toEqual({ hour: 21, minute: 5, second: 30 }); + }); + + it("returns null for invalid", () => { + expect(parseStartTime("99:00")).toBeNull(); + expect(parseStartTime("10:61")).toBeNull(); + expect(parseStartTime("abc")).toBeNull(); + expect(parseStartTime(undefined)).toBeNull(); + }); +}); + +describe("parseDurationToSeconds", () => { + it("parses MM:SS and HH:MM:SS", () => { + expect(parseDurationToSeconds("05:30")).toBe(330); + expect(parseDurationToSeconds("1:02:03")).toBe(3723); + }); + + it("uses fallback on bad input", () => { + expect(parseDurationToSeconds("bad", 10)).toBe(10); + expect(parseDurationToSeconds(undefined, 99)).toBe(99); + }); +}); diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts new file mode 100644 index 0000000..fbb7194 --- /dev/null +++ b/frontend/src/utils/time.ts @@ -0,0 +1,28 @@ +export type ParsedStart = { hour: number; minute: number; second: number }; + +const START_RE = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/; + +export function parseStartTime(s?: string): ParsedStart | null { + if (!s || typeof s !== "string") return null; + const m = START_RE.exec(s.trim()); + if (!m) return null; + const hour = Number(m[1]); + const minute = Number(m[2]); + const second = m[3] ? Number(m[3]) : 0; + if (hour < 0 || hour > 23) return null; + return { hour, minute, second }; +} + +export function parseDurationToSeconds(s?: string, fallback = 0): number { + if (!s || typeof s !== "string") return fallback; + const parts = s.trim().split(":").map(Number); + if (parts.some((n) => Number.isNaN(n) || n < 0)) return fallback; + if (parts.length === 2) { + const [mm, ss] = parts; + return mm * 60 + ss; + } else if (parts.length === 3) { + const [hh, mm, ss] = parts; + return hh * 3600 + mm * 60 + ss; + } + return fallback; +} diff --git a/frontend/src/utils/toastBus.ts b/frontend/src/utils/toastBus.ts new file mode 100644 index 0000000..fa49afe --- /dev/null +++ b/frontend/src/utils/toastBus.ts @@ -0,0 +1,52 @@ +export type ToastLevel = "info" | "warn" | "error"; + +export type Toast = { + id: number; + level: ToastLevel; + message: string; + detail?: string; + expiresAt?: number; +}; + +const listeners = new Set<(toasts: Toast[]) => void>(); +const queue: Toast[] = []; +let nextId = 1; +const MAX_TOASTS = 4; + +function emitUpdate() { + listeners.forEach((fn) => fn([...queue])); +} + +export function pushToast(toast: Omit) { + const entry: Toast = { + id: nextId++, + ...toast, + }; + queue.push(entry); + if (queue.length > MAX_TOASTS) queue.shift(); + emitUpdate(); +} + +export function dismissToast(id: number) { + const idx = queue.findIndex((t) => t.id === id); + if (idx >= 0) { + queue.splice(idx, 1); + emitUpdate(); + } +} + +export function subscribeToasts(fn: (toasts: Toast[]) => void) { + listeners.add(fn); + fn([...queue]); + return () => { listeners.delete(fn); }; +} + +export function toastError(message: string, detail?: string) { + const expiresAt = Date.now() + 7000; + pushToast({ level: "error", message, detail, expiresAt }); +} + +export function toastInfo(message: string, detail?: string) { + const expiresAt = Date.now() + 4000; + pushToast({ level: "info", message, detail, expiresAt }); +} diff --git a/frontend/tsconfig.vitest.json b/frontend/tsconfig.vitest.json new file mode 100644 index 0000000..e5a292e --- /dev/null +++ b/frontend/tsconfig.vitest.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "verbatimModuleSyntax": false + } +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..3a33031 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + dir: "src", + setupFiles: "./src/test/setup.ts", + deps: { + registerNodeLoader: true, + }, + tsconfig: "tsconfig.vitest.json", + }, +});