diff --git a/.editorconfig b/.editorconfig index dbd6792..2b55f80 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,55 +1,55 @@ -# EditorConfig is awesome: https://editorconfig.org -root = true - -# ------------------------------------------------- -# General settings – apply to all files -# ------------------------------------------------- -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -# ------------------------------------------------- -# Markdown (*.md) -# ------------------------------------------------- -[*.md] -max_line_length = off # Markdown lines can be long -trim_trailing_whitespace = false # Preserve intentional spaces - -# ------------------------------------------------- -# Go (*.go) -# ------------------------------------------------- -[*.go] -indent_style = tab -indent_size = 4 -trim_trailing_whitespace = true -max_line_length = 100 - -# ------------------------------------------------- -# JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx) -# ------------------------------------------------- -[*.{js,jsx,ts,tsx}] -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true -max_line_length = 120 - -# ------------------------------------------------- -# HTML (*.html, *.htm) -# ------------------------------------------------- -[*.{html,htm}] -indent_style = space -indent_size = 2 -max_line_length = 120 -trim_trailing_whitespace = true - -# ------------------------------------------------- -# CSS & SCSS (*.css, *.scss) -# ------------------------------------------------- -[*.{css,scss}] -indent_style = space -indent_size = 2 -max_line_length = 120 -trim_trailing_whitespace = true +# EditorConfig is awesome: https://editorconfig.org +root = true + +# ------------------------------------------------- +# General settings – apply to all files +# ------------------------------------------------- +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# ------------------------------------------------- +# Markdown (*.md) +# ------------------------------------------------- +[*.md] +max_line_length = off # Markdown lines can be long +trim_trailing_whitespace = false # Preserve intentional spaces + +# ------------------------------------------------- +# Go (*.go) +# ------------------------------------------------- +[*.go] +indent_style = tab +indent_size = 4 +trim_trailing_whitespace = true +max_line_length = 100 + +# ------------------------------------------------- +# JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx) +# ------------------------------------------------- +[*.{js,jsx,ts,tsx}] +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 + +# ------------------------------------------------- +# HTML (*.html, *.htm) +# ------------------------------------------------- +[*.{html,htm}] +indent_style = space +indent_size = 2 +max_line_length = 120 +trim_trailing_whitespace = true + +# ------------------------------------------------- +# CSS & SCSS (*.css, *.scss) +# ------------------------------------------------- +[*.{css,scss}] +indent_style = space +indent_size = 2 +max_line_length = 120 +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example index 1158a72..957c321 100644 --- a/.env.example +++ b/.env.example @@ -1,73 +1,73 @@ -# Environment variables for Linear Void -# Copy to .env and fill in real values for local/development use. -# Production secrets should be managed via Docker secrets, Cloudflare, or Hetzner environment. - -# Cloudflare R2 (S3-compatible) -R2_ACCESS_KEY_ID=your-r2-access-key -R2_SECRET_ACCESS_KEY=your-r2-secret-key -R2_BUCKET_NAME=linearvoid-media -R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com -R2_REGION=auto -R2_KEEP_RUNS=2 -R2_MAX_RETENTION_BYTES=10737418240 - -# Cloudflare (for custom domain, cache rules, Workers if used) -CLOUDFLARE_API_TOKEN=your-cloudflare-api-token -CLOUDFLARE_ACCOUNT_ID=your-account-id -MEDIA_DOMAIN=media.linearvoid.tv - -# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts) -# Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager. -VITE_PUBLIC_APP_URL=https://linearvoid.tv -VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws -VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8 -VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin -VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main -VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50 -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me -ADMIN_SESSION_TTL_HOURS=12 -# Optional direct-local fallback when bypassing the public proxy: -# VITE_PUBLIC_APP_URL=http://localhost -# VITE_PUBLIC_WS_URL=ws://localhost/ws -# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8 - -# Gitea Container Registry (for self-hosted Docker images) -CI_REGISTRY_URL=git.allucanget.biz -CI_REGISTRY_USER=your-gitea-username -CI_REGISTRY_TOKEN=your-gitea-token -CI_REGISTRY_NAMESPACE=zwitschi/linearvoid -# Gitea API access (optional, for CI/release automation) -CI_SERVER_URL=https://git.allucanget.biz - -# Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional) -DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable -# Or for SQLite-only local development: -# DATABASE_URL=sqlite://./data/chat.db - -# Redis (for presence, rate limiting, pub/sub) -REDIS_URL=redis://redis:6379/0 - -# Service Ports (for local docker-compose) -PLAYOUT_PORT=8080 -CHAT_PORT=3000 -NGINX_PORT=80 - -# Playout configuration -PLAYLIST_PATH=/app/playlist/playlist.txt -HLS_SEGMENT_DURATION=2 -HLS_WINDOW_SIZE=12 - -# Shared runtime config (written by chat admin via /api/admin/config, read by playout). -# Both resolve to the same host file through the ./data/playlist volume. -CONFIG_PATH=/app/playlist/config.json -PLAYOUT_CONFIG_PATH=/app/playlist/config.json - -# Logging / Monitoring -LOG_LEVEL=info -PROMETHEUS_PORT=9090 -GRAFANA_PORT=3001 - -# Development flags -NODE_ENV=development +# Environment variables for Linear Void +# Copy to .env and fill in real values for local/development use. +# Production secrets should be managed via Docker secrets, Cloudflare, or Hetzner environment. + +# Cloudflare R2 (S3-compatible) +R2_ACCESS_KEY_ID=your-r2-access-key +R2_SECRET_ACCESS_KEY=your-r2-secret-key +R2_BUCKET_NAME=linearvoid-media +R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com +R2_REGION=auto +R2_KEEP_RUNS=2 +R2_MAX_RETENTION_BYTES=10737418240 + +# Cloudflare (for custom domain, cache rules, Workers if used) +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token +CLOUDFLARE_ACCOUNT_ID=your-account-id +MEDIA_DOMAIN=media.linearvoid.tv + +# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts) +# Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager. +VITE_PUBLIC_APP_URL=https://linearvoid.tv +VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws +VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8 +VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin +VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main +VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +ADMIN_SESSION_TTL_HOURS=12 +# Optional direct-local fallback when bypassing the public proxy: +# VITE_PUBLIC_APP_URL=http://localhost +# VITE_PUBLIC_WS_URL=ws://localhost/ws +# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8 + +# Gitea Container Registry (for self-hosted Docker images) +CI_REGISTRY_URL=git.allucanget.biz +CI_REGISTRY_USER=your-gitea-username +CI_REGISTRY_TOKEN=your-gitea-token +CI_REGISTRY_NAMESPACE=zwitschi/linearvoid +# Gitea API access (optional, for CI/release automation) +CI_SERVER_URL=https://git.allucanget.biz + +# Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional) +DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable +# Or for SQLite-only local development: +# DATABASE_URL=sqlite://./data/chat.db + +# Redis (for presence, rate limiting, pub/sub) +REDIS_URL=redis://redis:6379/0 + +# Service Ports (for local docker-compose) +PLAYOUT_PORT=8080 +CHAT_PORT=3000 +NGINX_PORT=80 + +# Playout configuration +PLAYLIST_PATH=/app/playlist/playlist.txt +HLS_SEGMENT_DURATION=2 +HLS_WINDOW_SIZE=12 + +# Shared runtime config (written by chat admin via /api/admin/config, read by playout). +# Both resolve to the same host file through the ./data/playlist volume. +CONFIG_PATH=/app/playlist/config.json +PLAYOUT_CONFIG_PATH=/app/playlist/config.json + +# Logging / Monitoring +LOG_LEVEL=info +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3001 + +# Development flags +NODE_ENV=development GO_ENV=development \ No newline at end of file diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index afc1705..6bedb6e 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -1,288 +1,288 @@ -# CD workflow — TEST LAB DEPLOYMENT -# Deploys to test lab host 192.168.88.40:2375. -# Production Hetzner target TBD — switch DOCKER_HOST when provisioned. -# See DEPLOYMENT.md Production Deployment section for cutover plan. - -name: CD - -on: - workflow_run: - workflows: [CI] - types: [completed] - workflow_dispatch: - -jobs: - deploy: - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} - runs-on: [ubuntu-latest] - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to registry on remote host - env: - DOCKER_HOST: tcp://192.168.88.40:2375 - run: | - echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin - - - name: Prepare environment - run: | - cat > .env << 'EOF' - # Database & Redis - DATABASE_URL="${{ secrets.DATABASE_URL }}" - REDIS_URL=redis://redis:6379/0 - - # Playout - PLAYLIST_PATH=/app/playlist/playlist.txt - HLS_SEGMENT_DURATION=2 - HLS_WINDOW_SIZE=12 - LOG_LEVEL=info - - # Cloudflare R2 (required for HLS segment sync) - R2_ACCESS_KEY_ID="${{ secrets.R2_ACCESS_KEY_ID }}" - R2_SECRET_ACCESS_KEY="${{ secrets.R2_SECRET_ACCESS_KEY }}" - R2_BUCKET_NAME="${{ secrets.R2_BUCKET_NAME }}" - R2_ENDPOINT="${{ secrets.R2_ENDPOINT }}" - R2_REGION=auto - - # Cloudflare - CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}" - CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" - MEDIA_DOMAIN=${{ secrets.MEDIA_DOMAIN }} - - # Frontend public URLs - VITE_PUBLIC_APP_URL=${{ secrets.VITE_PUBLIC_APP_URL }} - VITE_PUBLIC_WS_URL=${{ secrets.VITE_PUBLIC_WS_URL }} - VITE_PUBLIC_HLS_URL=${{ secrets.VITE_PUBLIC_HLS_URL }} - VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }} - VITE_PUBLIC_ADMIN_DEFAULT_ROOM=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }} - VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50 - - # Admin auth - ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}" - ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}" - ADMIN_SESSION_TTL_HOURS=12 - - # Nginx - NGINX_PORT=80 - EOF - - - name: Seed Prometheus config on remote host - env: - DOCKER_HOST: tcp://192.168.88.40:2375 - run: | - docker run --rm \ - -v linearvoid_prometheus-config:/etc/prometheus \ - alpine:latest sh -c ' - cat > /etc/prometheus/prometheus.yml << "PROMEOF" - global: - scrape_interval: 15s - evaluation_interval: 15s - - rule_files: - - "/etc/prometheus/alerts.yml" - - scrape_configs: - - job_name: "chat" - static_configs: - - targets: ["chat:3000"] - - job_name: "playout" - static_configs: - - targets: ["playout:8081"] - - job_name: "nginx" - static_configs: - - targets: ["nginx-exporter:9113"] - - job_name: "redis" - static_configs: - - targets: ["redis-exporter:9121"] - - job_name: "postgres" - static_configs: - - targets: ["postgres-exporter:9187"] - - job_name: "node" - static_configs: - - targets: ["node-exporter:9100"] - - job_name: "prometheus" - static_configs: - - targets: ["localhost:9090"] - PROMEOF - cat > /etc/prometheus/alerts.yml << "ALERTSEOF" - groups: - - name: linearvoid - rules: - - alert: ChatDown - expr: up{job="chat"} == 0 - for: 2m - labels: - severity: critical - annotations: - summary: "Chat service is down" - - alert: PlayoutDown - expr: up{job="playout"} == 0 - for: 2m - labels: - severity: critical - annotations: - summary: "Playout service is down" - - alert: RedisDown - expr: linearvoid_chat_redis_up == 0 - for: 2m - labels: - severity: warning - annotations: - summary: "Redis is unreachable" - - alert: PlayoutSlateActive - expr: linearvoid_playout_mode == 1 - for: 1m - labels: - severity: warning - annotations: - summary: "Playout in slate fallback" - - alert: HighChatLatency - expr: rate(linearvoid_chat_messages_total[5m]) < 0.01 - for: 5m - labels: - severity: warning - annotations: - summary: "Unusually low chat message rate" - - alert: PrometheusWALFailing - expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0 - for: 5m - labels: - severity: warning - annotations: - summary: "Prometheus WAL writes failing" - - alert: HighCPU - expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 - for: 10m - labels: - severity: warning - annotations: - summary: "High CPU usage" - - alert: LowDiskSpace - expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10 - for: 5m - labels: - severity: warning - annotations: - summary: "Low disk space" - ALERTSEOF - ' - - - name: Seed Grafana provisioning on remote host - env: - DOCKER_HOST: tcp://192.168.88.40:2375 - run: | - docker run --rm \ - -v linearvoid_grafana-provisioning:/etc/grafana/provisioning \ - alpine:latest sh -c ' - mkdir -p /etc/grafana/provisioning/datasources /etc/grafana/provisioning/dashboards - cat > /etc/grafana/provisioning/datasources/datasource.yml << "DSEOF" - apiVersion: 1 - datasources: - - name: Prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: true - editable: true - DSEOF - cat > /etc/grafana/provisioning/dashboards/dashboard.yml << "PROVEOF" - apiVersion: 1 - providers: - - name: "default" - orgId: 1 - folder: "" - type: file - disableDeletion: false - updateIntervalSeconds: 10 - allowUiUpdates: true - options: - path: /etc/grafana/provisioning/dashboards - PROVEOF - cat > /etc/grafana/provisioning/dashboards/linearvoid-app.json << "DBEOF" - { - "title": "Linear Void Monitoring", - "panels": [ - { - "title": "Concurrent Viewers", - "type": "timeseries", - "targets": [{ "expr": "linearvoid_chat_connected_clients" }] - }, - { - "title": "Stored Messages", - "type": "stat", - "targets": [{ "expr": "linearvoid_chat_messages_total" }] - }, - { - "title": "Redis Reachable", - "type": "stat", - "targets": [{ "expr": "linearvoid_chat_redis_up" }] - }, - { - "title": "Playout Mode", - "type": "stat", - "targets": [{ "expr": "linearvoid_playout_mode" }] - } - ] - } - DBEOF - ' - - - name: Deploy with docker-compose - env: - DOCKER_HOST: tcp://192.168.88.40:2375 - run: | - docker compose -f docker-compose.prod.yml pull - docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate - - - name: Health check - env: - DOCKER_HOST: tcp://192.168.88.40:2375 - run: | - # Wait for services to stabilize - sleep 30 - - # Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST) - echo "Checking chat health..." - if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then - echo "Chat health check failed" - docker logs linearvoid-chat --tail 50 - exit 1 - fi - - # Check nginx health - echo "Checking nginx health..." - if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then - echo "Nginx health check failed" - docker logs linearvoid-nginx --tail 50 - exit 1 - fi - - # Retry playout health check since it may still be initializing - echo "Checking playout health..." - for i in $(seq 1 6); do - if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then - echo "Playout is healthy" - break - fi - echo "Playout not ready yet (attempt $i/6), waiting 20s..." - sleep 20 - done - if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then - echo "Playout failed to become healthy after 6 attempts" - docker logs linearvoid-playout --tail 50 - exit 1 - fi - - echo "All services healthy" - - - name: Cleanup old images - env: - DOCKER_HOST: tcp://192.168.88.40:2375 - run: | - docker image prune -f --filter "until=24h" +# CD workflow — TEST LAB DEPLOYMENT +# Deploys to test lab host 192.168.88.40:2375. +# Production Hetzner target TBD — switch DOCKER_HOST when provisioned. +# See DEPLOYMENT.md Production Deployment section for cutover plan. + +name: CD + +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + +jobs: + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: [ubuntu-latest] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry on remote host + env: + DOCKER_HOST: tcp://192.168.88.40:2375 + run: | + echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin + + - name: Prepare environment + run: | + cat > .env << 'EOF' + # Database & Redis + DATABASE_URL="${{ secrets.DATABASE_URL }}" + REDIS_URL=redis://redis:6379/0 + + # Playout + PLAYLIST_PATH=/app/playlist/playlist.txt + HLS_SEGMENT_DURATION=2 + HLS_WINDOW_SIZE=12 + LOG_LEVEL=info + + # Cloudflare R2 (required for HLS segment sync) + R2_ACCESS_KEY_ID="${{ secrets.R2_ACCESS_KEY_ID }}" + R2_SECRET_ACCESS_KEY="${{ secrets.R2_SECRET_ACCESS_KEY }}" + R2_BUCKET_NAME="${{ secrets.R2_BUCKET_NAME }}" + R2_ENDPOINT="${{ secrets.R2_ENDPOINT }}" + R2_REGION=auto + + # Cloudflare + CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}" + CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" + MEDIA_DOMAIN=${{ secrets.MEDIA_DOMAIN }} + + # Frontend public URLs + VITE_PUBLIC_APP_URL=${{ secrets.VITE_PUBLIC_APP_URL }} + VITE_PUBLIC_WS_URL=${{ secrets.VITE_PUBLIC_WS_URL }} + VITE_PUBLIC_HLS_URL=${{ secrets.VITE_PUBLIC_HLS_URL }} + VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }} + VITE_PUBLIC_ADMIN_DEFAULT_ROOM=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }} + VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50 + + # Admin auth + ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}" + ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}" + ADMIN_SESSION_TTL_HOURS=12 + + # Nginx + NGINX_PORT=80 + EOF + + - name: Seed Prometheus config on remote host + env: + DOCKER_HOST: tcp://192.168.88.40:2375 + run: | + docker run --rm \ + -v linearvoid_prometheus-config:/etc/prometheus \ + alpine:latest sh -c ' + cat > /etc/prometheus/prometheus.yml << "PROMEOF" + global: + scrape_interval: 15s + evaluation_interval: 15s + + rule_files: + - "/etc/prometheus/alerts.yml" + + scrape_configs: + - job_name: "chat" + static_configs: + - targets: ["chat:3000"] + - job_name: "playout" + static_configs: + - targets: ["playout:8081"] + - job_name: "nginx" + static_configs: + - targets: ["nginx-exporter:9113"] + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + - job_name: "postgres" + static_configs: + - targets: ["postgres-exporter:9187"] + - job_name: "node" + static_configs: + - targets: ["node-exporter:9100"] + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + PROMEOF + cat > /etc/prometheus/alerts.yml << "ALERTSEOF" + groups: + - name: linearvoid + rules: + - alert: ChatDown + expr: up{job="chat"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "Chat service is down" + - alert: PlayoutDown + expr: up{job="playout"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "Playout service is down" + - alert: RedisDown + expr: linearvoid_chat_redis_up == 0 + for: 2m + labels: + severity: warning + annotations: + summary: "Redis is unreachable" + - alert: PlayoutSlateActive + expr: linearvoid_playout_mode == 1 + for: 1m + labels: + severity: warning + annotations: + summary: "Playout in slate fallback" + - alert: HighChatLatency + expr: rate(linearvoid_chat_messages_total[5m]) < 0.01 + for: 5m + labels: + severity: warning + annotations: + summary: "Unusually low chat message rate" + - alert: PrometheusWALFailing + expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0 + for: 5m + labels: + severity: warning + annotations: + summary: "Prometheus WAL writes failing" + - alert: HighCPU + expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 + for: 10m + labels: + severity: warning + annotations: + summary: "High CPU usage" + - alert: LowDiskSpace + expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10 + for: 5m + labels: + severity: warning + annotations: + summary: "Low disk space" + ALERTSEOF + ' + + - name: Seed Grafana provisioning on remote host + env: + DOCKER_HOST: tcp://192.168.88.40:2375 + run: | + docker run --rm \ + -v linearvoid_grafana-provisioning:/etc/grafana/provisioning \ + alpine:latest sh -c ' + mkdir -p /etc/grafana/provisioning/datasources /etc/grafana/provisioning/dashboards + cat > /etc/grafana/provisioning/datasources/datasource.yml << "DSEOF" + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + DSEOF + cat > /etc/grafana/provisioning/dashboards/dashboard.yml << "PROVEOF" + apiVersion: 1 + providers: + - name: "default" + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards + PROVEOF + cat > /etc/grafana/provisioning/dashboards/linearvoid-app.json << "DBEOF" + { + "title": "Linear Void Monitoring", + "panels": [ + { + "title": "Concurrent Viewers", + "type": "timeseries", + "targets": [{ "expr": "linearvoid_chat_connected_clients" }] + }, + { + "title": "Stored Messages", + "type": "stat", + "targets": [{ "expr": "linearvoid_chat_messages_total" }] + }, + { + "title": "Redis Reachable", + "type": "stat", + "targets": [{ "expr": "linearvoid_chat_redis_up" }] + }, + { + "title": "Playout Mode", + "type": "stat", + "targets": [{ "expr": "linearvoid_playout_mode" }] + } + ] + } + DBEOF + ' + + - name: Deploy with docker-compose + env: + DOCKER_HOST: tcp://192.168.88.40:2375 + run: | + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate + + - name: Health check + env: + DOCKER_HOST: tcp://192.168.88.40:2375 + run: | + # Wait for services to stabilize + sleep 30 + + # Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST) + echo "Checking chat health..." + if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then + echo "Chat health check failed" + docker logs linearvoid-chat --tail 50 + exit 1 + fi + + # Check nginx health + echo "Checking nginx health..." + if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then + echo "Nginx health check failed" + docker logs linearvoid-nginx --tail 50 + exit 1 + fi + + # Retry playout health check since it may still be initializing + echo "Checking playout health..." + for i in $(seq 1 6); do + if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then + echo "Playout is healthy" + break + fi + echo "Playout not ready yet (attempt $i/6), waiting 20s..." + sleep 20 + done + if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then + echo "Playout failed to become healthy after 6 attempts" + docker logs linearvoid-playout --tail 50 + exit 1 + fi + + echo "All services healthy" + + - name: Cleanup old images + env: + DOCKER_HOST: tcp://192.168.88.40:2375 + run: | + docker image prune -f --filter "until=24h" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b7d6450..906e6ef 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,147 +1,147 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: [ubuntu-latest] - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Install Frontend Dependencies - run: cd frontend && npm ci - - - name: Lint Frontend - run: cd frontend && npm run lint - - - name: Test Frontend with Coverage - run: cd frontend && npm run test:coverage - - - name: Build Frontend - run: cd frontend && npm run build - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.25" - env: - GOTOOLCHAIN: local - - - name: Download Go modules - run: cd backend/chat && GOTOOLCHAIN=local go mod download - - - name: Validate Go - run: cd backend/chat && GOTOOLCHAIN=local go vet ./... - - - name: Run Tests - run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race - - - name: Check Coverage - run: | - cd backend/chat - COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') - echo "Coverage: $COVERAGE%" - if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then - echo "Coverage $COVERAGE% below 50% threshold" - exit 1 - fi - - docker: - needs: test - runs-on: [ubuntu-latest] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Gitea Container Registry - uses: docker/login-action@v3 - with: - registry: git.allucanget.biz - username: ${{ secrets.CI_REGISTRY_USER }} - password: ${{ secrets.CI_REGISTRY_TOKEN }} - - - name: Extract metadata for Playout - id: meta-playout - uses: docker/metadata-action@v5 - with: - images: git.allucanget.biz/zwitschi/linearvoid/playout - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Playout image - uses: docker/build-push-action@v5 - with: - context: ./backend/playout - file: ./backend/playout/Dockerfile - push: true - tags: ${{ steps.meta-playout.outputs.tags }} - labels: ${{ steps.meta-playout.outputs.labels }} - cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout - cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max - - - name: Extract metadata for Chat - id: meta-chat - uses: docker/metadata-action@v5 - with: - images: git.allucanget.biz/zwitschi/linearvoid/chat - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Chat image - uses: docker/build-push-action@v5 - with: - context: ./backend/chat - push: true - tags: ${{ steps.meta-chat.outputs.tags }} - labels: ${{ steps.meta-chat.outputs.labels }} - cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat - cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max - - - name: Extract metadata for Nginx - id: meta-nginx - uses: docker/metadata-action@v5 - with: - images: git.allucanget.biz/zwitschi/linearvoid/nginx - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Nginx image - uses: docker/build-push-action@v5 - with: - context: . - file: infrastructure/nginx/Dockerfile - push: true - tags: ${{ steps.meta-nginx.outputs.tags }} - labels: ${{ steps.meta-nginx.outputs.labels }} - cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx - cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: [ubuntu-latest] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Frontend Dependencies + run: cd frontend && npm ci + + - name: Lint Frontend + run: cd frontend && npm run lint + + - name: Test Frontend with Coverage + run: cd frontend && npm run test:coverage + + - name: Build Frontend + run: cd frontend && npm run build + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + env: + GOTOOLCHAIN: local + + - name: Download Go modules + run: cd backend/chat && GOTOOLCHAIN=local go mod download + + - name: Validate Go + run: cd backend/chat && GOTOOLCHAIN=local go vet ./... + + - name: Run Tests + run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race + + - name: Check Coverage + run: | + cd backend/chat + COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Coverage: $COVERAGE%" + if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then + echo "Coverage $COVERAGE% below 50% threshold" + exit 1 + fi + + docker: + needs: test + runs-on: [ubuntu-latest] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: git.allucanget.biz + username: ${{ secrets.CI_REGISTRY_USER }} + password: ${{ secrets.CI_REGISTRY_TOKEN }} + + - name: Extract metadata for Playout + id: meta-playout + uses: docker/metadata-action@v5 + with: + images: git.allucanget.biz/zwitschi/linearvoid/playout + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Playout image + uses: docker/build-push-action@v5 + with: + context: ./backend/playout + file: ./backend/playout/Dockerfile + push: true + tags: ${{ steps.meta-playout.outputs.tags }} + labels: ${{ steps.meta-playout.outputs.labels }} + cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout + cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max + + - name: Extract metadata for Chat + id: meta-chat + uses: docker/metadata-action@v5 + with: + images: git.allucanget.biz/zwitschi/linearvoid/chat + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Chat image + uses: docker/build-push-action@v5 + with: + context: ./backend/chat + push: true + tags: ${{ steps.meta-chat.outputs.tags }} + labels: ${{ steps.meta-chat.outputs.labels }} + cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat + cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max + + - name: Extract metadata for Nginx + id: meta-nginx + uses: docker/metadata-action@v5 + with: + images: git.allucanget.biz/zwitschi/linearvoid/nginx + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Nginx image + uses: docker/build-push-action@v5 + with: + context: . + file: infrastructure/nginx/Dockerfile + push: true + tags: ${{ steps.meta-nginx.outputs.tags }} + labels: ${{ steps.meta-nginx.outputs.labels }} + cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx + cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max diff --git a/.github/MEMORY.md b/.github/MEMORY.md index 544bacf..2626c1d 100644 --- a/.github/MEMORY.md +++ b/.github/MEMORY.md @@ -1,154 +1,154 @@ -# MEMORY - -## 2026-08-07 - -- Completed runtime validation section from TODO. -- Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe. -- Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`. -- Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds. -- Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles. -- Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`. -- Redis restart test: - - Admin session endpoint returned 200 before and after Redis restart. - - Presence key survived Redis restart in test-lab configuration. - - Redis health returned to `healthy` post-restart. -- Files changed: - - `.github/instructions/TODO.md` - - `.github/instructions/DONE.md` - - `frontend/src/style.css` - - `CHANGELOG.md` - -## 2026-08-07 (continued) - -- Completed documentation half of recovery runbook TODO item. -- Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template. -- Split TODO item into: - - documented drill (`[x]`) - - manual rehearsal execution (`[ ]`) -- Updated `CHANGELOG.md` with incident runbook drill documentation entry. - -## 2026-08-07 (continued 2) - -- Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture. -- Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections. -- Did not mark manual dashboard items complete; checklist is preparation only. - -## 2026-08-07 (continued 3) - -- Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans. -- Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending. -- Linked scaling playbook in `README.md` resources. - -## 2026-08-09 - -- Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`. -- Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios. -- Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation. -- Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios. -- Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`). -- Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files. -- Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending. -- Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates. - -## 2026-08-09 (continued) - -- Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps). -- Completed Phase 4 (R2 integration): - - Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension: - - `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T` - - `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl` - - unknown extension -> no cache headers - - Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension). - - All 17 playout tests pass (16 pass, 1 pre-existing skip). -- Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual). -- Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete. - -## 2026-08-09 (continued 2) - -- Completed Phase 2 chat tests in `frontend/src/main.test.ts`: - - Presence updates: join/leave system messages add and remove users from presence list, sorted rendering. - - Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames. - - Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly. - - Total frontend tests: 27 passing (2 test files). -- Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern). -- Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked. -- Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked. -- Updated CHANGELOG.md with Phase 2 presence/DM/message tests. - -## 2026-08-09 (continued 3) - -- Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`: - - Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`. - - Added playlist save test (verifies PUT /api/admin/playlist with JSON body). - - Added playlist save error handling test (verifies failure status). - - Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip). - - Total frontend tests: 31 passing (2 test files). -- Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked. -- Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete. - -## 2026-08-10 (continued 2) - -- Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`: - - **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path. - - **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit. - - **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments. - - **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md. - - **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions. - - **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s). -- Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`. -- Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`. -- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure). - -## 2026-08-10 (continued 3) - -- Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests: - - 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role. - - 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live. - - 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability. - - 2 admin responsive tests: tab content visibility, essential tab presence. - - Total: 45 frontend tests passing (2 test files). -- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines). -- Added `test:coverage` script to `package.json`. -- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`. -- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence. -- All Go vet, Go tests, frontend test:coverage passing. -- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code). - -## 2026-08-10 (continued 4) - -- Verified all test suites pass after streaming pipeline implementation: - - Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass - - Chat: 7 packages pass (go vet + go test) - - Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%) -- Edge cache validation script `scripts/validate-edge-cache.sh` exists. -- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks). - -## 2026-08-11 - -- Consolidated all development tasks from `DEVELOPMENT.md` and `.github/instructions/TODO.md` into `.github/instructions/BACKLOG.md` as the single source of truth. `DEVELOPMENT.md` now obsolete and removed. `TODO.md` references `BACKLOG.md` and `DONE.md`. -- Fixed R2 endpoint validation in playout service (`backend/playout/src/playlist.go`): - - Added `isR2Configured()` checking all four required env vars (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`). - - `initR2Client()` now returns a clear error if R2 is not configured instead of creating an S3 client with an empty endpoint. - - Segment watcher (`watchAndPublishSegments`), `syncLatestToR2()`, and `publishManifest()` are now guarded by `r2Client != nil` — no error spam when R2 is unavailable. - - Playout gracefully degrades: local HLS via nginx works without R2. -- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables. -- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets. -- All playout tests pass (20 tests), `go vet` clean. - -## 2026-08-13 - -- Completed all remaining enhancement tasks (only manual/infrastructure phases left): - - **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics. - - **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form. - - **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`. - - **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI. - - **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI. - - **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`. - - **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10. -- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables). -- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed. -- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing). -- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`. -- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray ``); restored structure + made `addMessage` defensive. -- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages. -- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`. +# MEMORY + +## 2026-08-07 + +- Completed runtime validation section from TODO. +- Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe. +- Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`. +- Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds. +- Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles. +- Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`. +- Redis restart test: + - Admin session endpoint returned 200 before and after Redis restart. + - Presence key survived Redis restart in test-lab configuration. + - Redis health returned to `healthy` post-restart. +- Files changed: + - `.github/instructions/TODO.md` + - `.github/instructions/DONE.md` + - `frontend/src/style.css` + - `CHANGELOG.md` + +## 2026-08-07 (continued) + +- Completed documentation half of recovery runbook TODO item. +- Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template. +- Split TODO item into: + - documented drill (`[x]`) + - manual rehearsal execution (`[ ]`) +- Updated `CHANGELOG.md` with incident runbook drill documentation entry. + +## 2026-08-07 (continued 2) + +- Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture. +- Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections. +- Did not mark manual dashboard items complete; checklist is preparation only. + +## 2026-08-07 (continued 3) + +- Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans. +- Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending. +- Linked scaling playbook in `README.md` resources. + +## 2026-08-09 + +- Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`. +- Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios. +- Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation. +- Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios. +- Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`). +- Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files. +- Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending. +- Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates. + +## 2026-08-09 (continued) + +- Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps). +- Completed Phase 4 (R2 integration): + - Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension: + - `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T` + - `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl` + - unknown extension -> no cache headers + - Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension). + - All 17 playout tests pass (16 pass, 1 pre-existing skip). +- Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual). +- Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete. + +## 2026-08-09 (continued 2) + +- Completed Phase 2 chat tests in `frontend/src/main.test.ts`: + - Presence updates: join/leave system messages add and remove users from presence list, sorted rendering. + - Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames. + - Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly. + - Total frontend tests: 27 passing (2 test files). +- Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern). +- Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked. +- Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked. +- Updated CHANGELOG.md with Phase 2 presence/DM/message tests. + +## 2026-08-09 (continued 3) + +- Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`: + - Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`. + - Added playlist save test (verifies PUT /api/admin/playlist with JSON body). + - Added playlist save error handling test (verifies failure status). + - Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip). + - Total frontend tests: 31 passing (2 test files). +- Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked. +- Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete. + +## 2026-08-10 (continued 2) + +- Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`: + - **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path. + - **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit. + - **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments. + - **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md. + - **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions. + - **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s). +- Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`. +- Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`. +- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure). + +## 2026-08-10 (continued 3) + +- Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests: + - 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role. + - 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live. + - 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability. + - 2 admin responsive tests: tab content visibility, essential tab presence. + - Total: 45 frontend tests passing (2 test files). +- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines). +- Added `test:coverage` script to `package.json`. +- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`. +- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence. +- All Go vet, Go tests, frontend test:coverage passing. +- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code). + +## 2026-08-10 (continued 4) + +- Verified all test suites pass after streaming pipeline implementation: + - Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass + - Chat: 7 packages pass (go vet + go test) + - Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%) +- Edge cache validation script `scripts/validate-edge-cache.sh` exists. +- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks). + +## 2026-08-11 + +- Consolidated all development tasks from `DEVELOPMENT.md` and `.github/instructions/TODO.md` into `.github/instructions/BACKLOG.md` as the single source of truth. `DEVELOPMENT.md` now obsolete and removed. `TODO.md` references `BACKLOG.md` and `DONE.md`. +- Fixed R2 endpoint validation in playout service (`backend/playout/src/playlist.go`): + - Added `isR2Configured()` checking all four required env vars (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`). + - `initR2Client()` now returns a clear error if R2 is not configured instead of creating an S3 client with an empty endpoint. + - Segment watcher (`watchAndPublishSegments`), `syncLatestToR2()`, and `publishManifest()` are now guarded by `r2Client != nil` — no error spam when R2 is unavailable. + - Playout gracefully degrades: local HLS via nginx works without R2. +- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables. +- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets. +- All playout tests pass (20 tests), `go vet` clean. + +## 2026-08-13 + +- Completed all remaining enhancement tasks (only manual/infrastructure phases left): + - **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics. + - **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form. + - **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`. + - **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI. + - **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI. + - **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`. + - **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10. +- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables). +- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed. +- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing). +- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`. +- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray ``); restored structure + made `addMessage` defensive. +- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages. +- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`. diff --git a/.github/instructions/BACKLOG.md b/.github/instructions/BACKLOG.md index a28b567..26812b7 100644 --- a/.github/instructions/BACKLOG.md +++ b/.github/instructions/BACKLOG.md @@ -1,136 +1,136 @@ -# BACKLOG - -> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here. - -## Setup phases remaining - -### Phase 5: complete manual Cloudflare and external-service setup - -- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard -- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting -- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier -- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode -- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action -- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin -- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) -- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min) -- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook -- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md` - -### Phase 6: rehearse production deployment end to end - -- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 -- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only -- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs) -- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories -- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password) -- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP -- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket -- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS -- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout -- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL -- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host -- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds - -### Phase 7: baseline scaling and performance - -- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors -- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s -- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache) -- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution -- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%) -- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min -- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring) -- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication) - -### Phase 8: close out remaining test and validation gaps - -- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs) -- [ ] Frontend: coverage for all critical user + admin components and interactions -- [ ] Frontend: document test cases and expected outcomes -- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge -- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`) - -### Playout - -- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual) -- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh` - -## Cloudflare Configuration - -### DNS & Workers - -- [ ] Cloudflare Workers: link custom domain (manual) -- [ ] Create Cloudflare Worker and cut over frontend hosting (manual) - -### Security & TLS - -- [ ] Enable Tiered Cache, WAF, rate limiting (manual) -- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier -- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode -- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action -- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2 -- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS - -## Monitoring & Observability - -### External Monitoring - -- [ ] Set up UptimeRobot external monitoring -- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) -- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min) -- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook - -## External Services - -> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md` - -- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) -- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min) -- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook - -## Production Deployment - -### Host Provisioning (Hetzner — not provisioned) - -- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 -- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only) -- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs) -- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories - -### CI/CD & Secrets - -- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password -- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP - -### Smoke Test & Cutover - -- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket -- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS -- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout - -### Production Operations - -- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL -- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host -- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds - -## Scaling & Performance - -### Load Testing - -- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors -- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s - -### Cache & TTFB Optimization - -- [ ] Optimize segment immutability and manifest TTL for cache efficiency -- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration -- [ ] Measure playback TTFB from multiple US regions — WebPageTest from 3+ regions, target <4s warm cache -- [ ] Track cache hit ratio and R2 ops in production — Cloudflare GraphQL → Grafana panel, alert <90% - -### Capacity & Future Planning - -- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min -- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources -- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication +# BACKLOG + +> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here. + +## Setup phases remaining + +### Phase 5: complete manual Cloudflare and external-service setup + +- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard +- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting +- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier +- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode +- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action +- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin +- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) +- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min) +- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook +- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md` + +### Phase 6: rehearse production deployment end to end + +- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 +- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only +- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs) +- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories +- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password) +- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP +- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket +- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS +- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout +- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL +- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host +- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds + +### Phase 7: baseline scaling and performance + +- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors +- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s +- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache) +- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution +- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%) +- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min +- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring) +- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication) + +### Phase 8: close out remaining test and validation gaps + +- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs) +- [ ] Frontend: coverage for all critical user + admin components and interactions +- [ ] Frontend: document test cases and expected outcomes +- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge +- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`) + +### Playout + +- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual) +- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh` + +## Cloudflare Configuration + +### DNS & Workers + +- [ ] Cloudflare Workers: link custom domain (manual) +- [ ] Create Cloudflare Worker and cut over frontend hosting (manual) + +### Security & TLS + +- [ ] Enable Tiered Cache, WAF, rate limiting (manual) +- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier +- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode +- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action +- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2 +- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS + +## Monitoring & Observability + +### External Monitoring + +- [ ] Set up UptimeRobot external monitoring +- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) +- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min) +- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook + +## External Services + +> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md` + +- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) +- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min) +- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook + +## Production Deployment + +### Host Provisioning (Hetzner — not provisioned) + +- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 +- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only) +- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs) +- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories + +### CI/CD & Secrets + +- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password +- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP + +### Smoke Test & Cutover + +- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket +- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS +- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout + +### Production Operations + +- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL +- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host +- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds + +## Scaling & Performance + +### Load Testing + +- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors +- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s + +### Cache & TTFB Optimization + +- [ ] Optimize segment immutability and manifest TTL for cache efficiency +- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration +- [ ] Measure playback TTFB from multiple US regions — WebPageTest from 3+ regions, target <4s warm cache +- [ ] Track cache hit ratio and R2 ops in production — Cloudflare GraphQL → Grafana panel, alert <90% + +### Capacity & Future Planning + +- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min +- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources +- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication diff --git a/.github/instructions/TODO.md b/.github/instructions/TODO.md index d388af3..d860d27 100644 --- a/.github/instructions/TODO.md +++ b/.github/instructions/TODO.md @@ -1,57 +1,57 @@ -# TODO - -> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md). - -## Chat sidebar - -- [x] remove search bar from chat sidebar (not needed for single-room) -- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable) -- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register" -- [x] when user is logged in, do not show login/registration form -- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load) - -### Plan - -Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`. - -1. **Remove search bar** (single-room) - - `index.html`: delete the `
` block. - - `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays. - -2. **Remove "Users" toggle button** - - `index.html`: delete the ` - - -
-
- -
- Loading... - -
-
- - -
- - - - - - - - + + + + + + + Linear Void - 24/7 Synchronized Channel + + + + + + + +
+
+
+ +
+

Linear Void

+

24/7 Synchronized Channel • Live

+
+
+ +
+ +
+
+ +
+ Loading... + +
+
+ + +
+
+ + + + + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9e93eb8..a0d9b9c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,2140 +1,2140 @@ -{ - "name": "frontend", - "version": "1.0.11", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "frontend", - "version": "1.0.11", - "dependencies": { - "hls.js": "^1.6.16" - }, - "devDependencies": { - "@vitest/coverage-v8": "^4.1.10", - "@vitest/ui": "^4.1.10", - "jsdom": "^30.0.1", - "typescript": "~6.0.2", - "vite": "^8.2.0", - "vitest": "^4.1.10" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", - "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.2.1", - "@csstools/css-color-parser": "^4.1.9", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", - "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.3.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", - "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", - "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", - "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", - "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", - "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", - "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", - "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", - "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", - "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", - "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", - "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", - "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", - "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", - "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "2.0.0-alpha.3", - "@emnapi/runtime": "2.0.0-alpha.3", - "@napi-rs/wasm-runtime": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", - "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", - "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/ui": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", - "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "fflate": "^0.8.2", - "flatted": "^3.4.2", - "pathe": "^2.0.3", - "sirv": "^3.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.10" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "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/ast-v8-to-istanbul": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", - "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", - "dev": true, - "license": "MIT" - }, - "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/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, - "license": "MIT" - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hls.js": { - "version": "1.6.16", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", - "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", - "license": "Apache-2.0" - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", - "@exodus/bytes": "^1.15.1", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.2", - "undici": "^8.9.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "canvas": "^3.2.3" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "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/magicast": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", - "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rolldown": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", - "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.142.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.1", - "@rolldown/binding-darwin-arm64": "1.2.1", - "@rolldown/binding-darwin-x64": "1.2.1", - "@rolldown/binding-freebsd-x64": "1.2.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", - "@rolldown/binding-linux-arm64-gnu": "1.2.1", - "@rolldown/binding-linux-arm64-musl": "1.2.1", - "@rolldown/binding-linux-ppc64-gnu": "1.2.1", - "@rolldown/binding-linux-s390x-gnu": "1.2.1", - "@rolldown/binding-linux-x64-gnu": "1.2.1", - "@rolldown/binding-linux-x64-musl": "1.2.1", - "@rolldown/binding-openharmony-arm64": "1.2.1", - "@rolldown/binding-wasm32-wasi": "1.2.1", - "@rolldown/binding-win32-arm64-msvc": "1.2.1", - "@rolldown/binding-win32-x64-msvc": "1.2.1" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "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/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "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": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "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": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", - "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.10" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", - "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", - "dev": true, - "license": "MIT" - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "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/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", - "esbuild": "^0.27.0 || ^0.28.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" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", - "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^22.14.0 || >=24.0.0" - } - }, - "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/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - } - } -} +{ + "name": "frontend", + "version": "1.0.11", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "1.0.11", + "dependencies": { + "hls.js": "^1.6.16" + }, + "devDependencies": { + "@vitest/coverage-v8": "^4.1.10", + "@vitest/ui": "^4.1.10", + "jsdom": "^30.0.1", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", + "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "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": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "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/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 56ac16a..bf2fa33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,29 +1,29 @@ -{ - "name": "frontend", - "private": true, - "version": "1.0.13", - "type": "module", - "engines": { - "node": ">=22.0.0" - }, - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "lint": "tsc --noEmit", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:watch": "vitest" - }, - "devDependencies": { - "@vitest/coverage-v8": "^4.1.10", - "@vitest/ui": "^4.1.10", - "jsdom": "^30.0.1", - "typescript": "~6.0.2", - "vite": "^8.2.0", - "vitest": "^4.1.10" - }, - "dependencies": { - "hls.js": "^1.6.16" - } -} +{ + "name": "frontend", + "private": true, + "version": "1.0.13", + "type": "module", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest" + }, + "devDependencies": { + "@vitest/coverage-v8": "^4.1.10", + "@vitest/ui": "^4.1.10", + "jsdom": "^30.0.1", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vitest": "^4.1.10" + }, + "dependencies": { + "hls.js": "^1.6.16" + } +} diff --git a/frontend/src/admin.test.ts b/frontend/src/admin.test.ts index c8e57de..622a618 100644 --- a/frontend/src/admin.test.ts +++ b/frontend/src/admin.test.ts @@ -1,378 +1,378 @@ -/// -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin"; - -// Mock fetch globally -let fetchCalls: { url: string; options?: RequestInit }[] = []; -let mockFetchResponse: any = { ok: true, payload: {} }; - -beforeEach(() => { - fetchCalls = []; - globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => { - fetchCalls.push({ url, options }); - const payload = mockFetchResponse.payload ?? {}; - return Promise.resolve( - new Response(JSON.stringify(payload), { - status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500, - headers: { "Content-Type": "application/json" }, - }), - ); - }) as any; - - // Mock localStorage - const storage = new Map(); - Object.defineProperty(window, "localStorage", { - value: { - getItem: vi.fn((key: string) => storage.get(key) ?? null), - setItem: vi.fn((key: string, val: string) => storage.set(key, val)), - removeItem: vi.fn((key: string) => storage.delete(key)), - }, - writable: true, - }); - - // Mock window.confirm/prompt - vi.spyOn(window, "confirm").mockReturnValue(true); - vi.spyOn(window, "prompt").mockReturnValue(null); - vi.spyOn(console, "error").mockImplementation(() => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -// Import after mocks are set -import "../src/admin"; - -describe("admin.ts initialization", () => { - it("loads and exposes tab switching", () => { - // After import, init() runs on DOMContentLoaded - // Test that the document has admin page structure - expect(document.body).toBeDefined(); - }); -}); - -describe("upload progress helpers", () => { - it("renders upload progress correctly", async () => { - // Set up DOM for upload page - document.body.innerHTML = ` - -
- `; - - // Re-import triggers init again — but we need fresh module state - // For now test internal helper access via body - const wrap = document.getElementById("media-upload-progress-wrap")!; - const bar = document.getElementById("media-upload-progress") as HTMLProgressElement; - const text = document.getElementById("media-upload-progress-text")!; - - expect(wrap.hidden).toBe(true); - - // Simulate what setUploadProgress would do - wrap.hidden = false; - bar.max = 100; - bar.value = 50; - text.textContent = "Uploading (50%)"; - - expect(wrap.hidden).toBe(false); - expect(bar.value).toBe(50); - expect(text.textContent).toBe("Uploading (50%)"); - }); -}); - -describe("file fingerprint", () => { - it("generates unique fingerprints for different files", () => { - const f1 = new File(["a"], "video1.mp4", { lastModified: 1 }); - const f2 = new File(["b"], "video2.mp4", { lastModified: 2 }); - - const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`; - const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`; - - expect(fp1).not.toBe(fp2); - expect(fp1).toBe("video1.mp4:1:1"); - expect(fp2).toBe("video2.mp4:1:2"); - }); -}); - -describe("MAX_UPLOAD_BYTES constant", () => { - it("equals 2GB", () => { - expect(2 * 1024 * 1024 * 1024).toBe(2147483648); - }); -}); - -describe("API calls on tab load", () => { - beforeEach(() => { - document.body.innerHTML = ` -
- -
-
-
-
-
- -
-
-
-
-
-
- -
- `; - }); - - it("can be imported without crashing", () => { - // admin.ts initialization should not throw - expect(true).toBe(true); - }); -}); - -describe("admin data refresh", () => { - beforeEach(() => { - document.body.innerHTML = ` -
- - - - - - - - - - - -
- - - - - -
- `; - }); - - it("renders overview stats from the admin overview API", async () => { - mockFetchResponse = { - ok: true, - payload: { - viewers: 12, - storedMessages: 345, - moderationActions: 9, - mediaTotalRuntime: "01:23:45", - mediaCount: 7, - redisClients: 4, - redisEnabled: true, - redisMemoryUsed: "18 MB", - databaseDriver: "sqlite", - generatedAt: "2026-08-08T12:00:00Z", - }, - }; - - await refreshOverview(); - - expect(document.getElementById("stat-viewers")?.textContent).toBe("12"); - expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345"); - expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes"); - expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z"); - }); - - it("renders analytics rows from the analytics API", async () => { - mockFetchResponse = { - ok: true, - payload: { - totalMessages: 1000, - messagesLast24Hours: 75, - distinctRooms: 3, - moderationActions: 11, - generatedAt: "2026-08-08T12:30:00Z", - topRooms: [ - { room: "main", messageCount: 55 }, - { room: "lobby", messageCount: 20 }, - ], - }, - }; - - await refreshAnalytics(); - - expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000"); - expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75"); - expect(document.getElementById("analytics-table-body")?.textContent).toContain("main"); - expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby"); - }); -}); - -describe("playlist management", () => { - beforeEach(() => { - document.body.innerHTML = ` -
- -
    - - `; - }); - - it("saves playlist via PUT request", async () => { - mockFetchResponse = { ok: true, payload: {} }; - - await savePlaylist(); - - const putCall = fetchCalls.find((c) => c.options?.method === "PUT"); - expect(putCall).toBeDefined(); - expect(putCall?.url).toBe("/api/admin/playlist"); - expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" }); - const body = JSON.parse(putCall?.options?.body as string); - expect(body.content).toContain("demo1.mp4"); - }); - - it("sets error status on save failure", async () => { - mockFetchResponse = { ok: false, status: 500, payload: {} }; - - await savePlaylist(); - - const status = document.getElementById("playlist-status"); - expect(status?.textContent).toContain("Failed"); - }); - - it("renders sortable playlist with indexes and paths", () => { - renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n"); - - const list = document.getElementById("playlist-sortable")!; - expect(list.children).toHaveLength(2); - expect(list.innerHTML).toContain("demo1.mp4"); - expect(list.innerHTML).toContain("demo2.mp4"); - expect(list.querySelector(".sortable-index")?.textContent).toBe("1"); - expect(list.querySelector("[data-remove]")).toBeTruthy(); - }); - - it("skips comment and blank lines in sortable", () => { - renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n"); - - const list = document.getElementById("playlist-sortable")!; - expect(list.children).toHaveLength(1); - expect(list.innerHTML).toContain("demo1.mp4"); - }); -}); - -describe("admin accessibility", () => { - beforeEach(() => { - document.body.innerHTML = ` - -
    - `; - }); - - it("nav has aria-label for screen readers", () => { - const nav = document.querySelector(".admin-nav"); - expect(nav?.getAttribute("aria-label")).toBe("Admin navigation"); - }); - - it("active tab has aria-current attribute", () => { - const active = document.querySelector("[aria-current]"); - expect(active).toBeTruthy(); - expect(active?.getAttribute("aria-current")).toBe("page"); - }); - - it("all nav buttons are focusable", () => { - const buttons = document.querySelectorAll(".admin-nav button"); - buttons.forEach((btn) => { - expect(btn.getAttribute("tabindex")).toBeNull(); - expect(btn.getAttribute("disabled")).toBeNull(); - }); - }); -}); - -describe("admin responsive layout", () => { - beforeEach(() => { - document.body.innerHTML = ` - -
    Overview data
    - `; - }); - - it("tab content is present and visible", () => { - const tabContent = document.getElementById("tab-content"); - expect(tabContent).toBeTruthy(); - const style = window.getComputedStyle(tabContent!); - expect(style.display).not.toBe("none"); - }); - - it("admin nav contains essential tabs", () => { - const buttons = document.querySelectorAll(".admin-nav button"); - const tabs = Array.from(buttons).map((b) => b.textContent?.trim()); - expect(tabs).toContain("Dashboard"); - }); -}); - -describe("admin data refresh failures", () => { - beforeEach(() => { - document.body.innerHTML = ` -
    -
    - `; - }); - - it("sets error status when overview fails", async () => { - mockFetchResponse = { ok: false, status: 500, payload: {} }; - await refreshOverview(); - expect(document.getElementById("overview-status")?.textContent).toContain("Failed"); - }); - - it("sets error status when analytics fails", async () => { - mockFetchResponse = { ok: false, status: 500, payload: {} }; - await refreshAnalytics(); - expect(document.getElementById("analytics-status")?.textContent).toContain("Failed"); - }); -}); - -describe("playlist status badges", () => { - beforeEach(() => { - document.body.innerHTML = `
      `; - }); - - it("renders badges for every media status", () => { - const statusMap = new Map([ - ["/app/media/ready.mp4", "ready"], - ["/app/media/needs.mp4", "needs_normalization"], - ["/app/media/normalizing.mp4", "normalizing"], - ["/app/media/failed.mp4", "normalization_failed"], - ["/app/media/other.mp4", "weird"], - ]); - - renderPlaylistSortable( - "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n", - statusMap, - ); - - const list = document.getElementById("playlist-sortable")!; - expect(list.innerHTML).toContain("badge-ok"); - expect(list.innerHTML).toContain("badge-warn"); - expect(list.innerHTML).toContain("badge-info"); - expect(list.innerHTML).toContain("badge-danger"); - expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted - expect(list.children).toHaveLength(6); - }); -}); +/// +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin"; + +// Mock fetch globally +let fetchCalls: { url: string; options?: RequestInit }[] = []; +let mockFetchResponse: any = { ok: true, payload: {} }; + +beforeEach(() => { + fetchCalls = []; + globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => { + fetchCalls.push({ url, options }); + const payload = mockFetchResponse.payload ?? {}; + return Promise.resolve( + new Response(JSON.stringify(payload), { + status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as any; + + // Mock localStorage + const storage = new Map(); + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, val: string) => storage.set(key, val)), + removeItem: vi.fn((key: string) => storage.delete(key)), + }, + writable: true, + }); + + // Mock window.confirm/prompt + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "prompt").mockReturnValue(null); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// Import after mocks are set +import "../src/admin"; + +describe("admin.ts initialization", () => { + it("loads and exposes tab switching", () => { + // After import, init() runs on DOMContentLoaded + // Test that the document has admin page structure + expect(document.body).toBeDefined(); + }); +}); + +describe("upload progress helpers", () => { + it("renders upload progress correctly", async () => { + // Set up DOM for upload page + document.body.innerHTML = ` + +
      + `; + + // Re-import triggers init again — but we need fresh module state + // For now test internal helper access via body + const wrap = document.getElementById("media-upload-progress-wrap")!; + const bar = document.getElementById("media-upload-progress") as HTMLProgressElement; + const text = document.getElementById("media-upload-progress-text")!; + + expect(wrap.hidden).toBe(true); + + // Simulate what setUploadProgress would do + wrap.hidden = false; + bar.max = 100; + bar.value = 50; + text.textContent = "Uploading (50%)"; + + expect(wrap.hidden).toBe(false); + expect(bar.value).toBe(50); + expect(text.textContent).toBe("Uploading (50%)"); + }); +}); + +describe("file fingerprint", () => { + it("generates unique fingerprints for different files", () => { + const f1 = new File(["a"], "video1.mp4", { lastModified: 1 }); + const f2 = new File(["b"], "video2.mp4", { lastModified: 2 }); + + const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`; + const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`; + + expect(fp1).not.toBe(fp2); + expect(fp1).toBe("video1.mp4:1:1"); + expect(fp2).toBe("video2.mp4:1:2"); + }); +}); + +describe("MAX_UPLOAD_BYTES constant", () => { + it("equals 2GB", () => { + expect(2 * 1024 * 1024 * 1024).toBe(2147483648); + }); +}); + +describe("API calls on tab load", () => { + beforeEach(() => { + document.body.innerHTML = ` +
      + +
      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      + +
      + `; + }); + + it("can be imported without crashing", () => { + // admin.ts initialization should not throw + expect(true).toBe(true); + }); +}); + +describe("admin data refresh", () => { + beforeEach(() => { + document.body.innerHTML = ` +
      + + + + + + + + + + + +
      + + + + + +
      + `; + }); + + it("renders overview stats from the admin overview API", async () => { + mockFetchResponse = { + ok: true, + payload: { + viewers: 12, + storedMessages: 345, + moderationActions: 9, + mediaTotalRuntime: "01:23:45", + mediaCount: 7, + redisClients: 4, + redisEnabled: true, + redisMemoryUsed: "18 MB", + databaseDriver: "sqlite", + generatedAt: "2026-08-08T12:00:00Z", + }, + }; + + await refreshOverview(); + + expect(document.getElementById("stat-viewers")?.textContent).toBe("12"); + expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345"); + expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes"); + expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z"); + }); + + it("renders analytics rows from the analytics API", async () => { + mockFetchResponse = { + ok: true, + payload: { + totalMessages: 1000, + messagesLast24Hours: 75, + distinctRooms: 3, + moderationActions: 11, + generatedAt: "2026-08-08T12:30:00Z", + topRooms: [ + { room: "main", messageCount: 55 }, + { room: "lobby", messageCount: 20 }, + ], + }, + }; + + await refreshAnalytics(); + + expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000"); + expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75"); + expect(document.getElementById("analytics-table-body")?.textContent).toContain("main"); + expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby"); + }); +}); + +describe("playlist management", () => { + beforeEach(() => { + document.body.innerHTML = ` +
      + +
        + + `; + }); + + it("saves playlist via PUT request", async () => { + mockFetchResponse = { ok: true, payload: {} }; + + await savePlaylist(); + + const putCall = fetchCalls.find((c) => c.options?.method === "PUT"); + expect(putCall).toBeDefined(); + expect(putCall?.url).toBe("/api/admin/playlist"); + expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" }); + const body = JSON.parse(putCall?.options?.body as string); + expect(body.content).toContain("demo1.mp4"); + }); + + it("sets error status on save failure", async () => { + mockFetchResponse = { ok: false, status: 500, payload: {} }; + + await savePlaylist(); + + const status = document.getElementById("playlist-status"); + expect(status?.textContent).toContain("Failed"); + }); + + it("renders sortable playlist with indexes and paths", () => { + renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n"); + + const list = document.getElementById("playlist-sortable")!; + expect(list.children).toHaveLength(2); + expect(list.innerHTML).toContain("demo1.mp4"); + expect(list.innerHTML).toContain("demo2.mp4"); + expect(list.querySelector(".sortable-index")?.textContent).toBe("1"); + expect(list.querySelector("[data-remove]")).toBeTruthy(); + }); + + it("skips comment and blank lines in sortable", () => { + renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n"); + + const list = document.getElementById("playlist-sortable")!; + expect(list.children).toHaveLength(1); + expect(list.innerHTML).toContain("demo1.mp4"); + }); +}); + +describe("admin accessibility", () => { + beforeEach(() => { + document.body.innerHTML = ` + +
        + `; + }); + + it("nav has aria-label for screen readers", () => { + const nav = document.querySelector(".admin-nav"); + expect(nav?.getAttribute("aria-label")).toBe("Admin navigation"); + }); + + it("active tab has aria-current attribute", () => { + const active = document.querySelector("[aria-current]"); + expect(active).toBeTruthy(); + expect(active?.getAttribute("aria-current")).toBe("page"); + }); + + it("all nav buttons are focusable", () => { + const buttons = document.querySelectorAll(".admin-nav button"); + buttons.forEach((btn) => { + expect(btn.getAttribute("tabindex")).toBeNull(); + expect(btn.getAttribute("disabled")).toBeNull(); + }); + }); +}); + +describe("admin responsive layout", () => { + beforeEach(() => { + document.body.innerHTML = ` + +
        Overview data
        + `; + }); + + it("tab content is present and visible", () => { + const tabContent = document.getElementById("tab-content"); + expect(tabContent).toBeTruthy(); + const style = window.getComputedStyle(tabContent!); + expect(style.display).not.toBe("none"); + }); + + it("admin nav contains essential tabs", () => { + const buttons = document.querySelectorAll(".admin-nav button"); + const tabs = Array.from(buttons).map((b) => b.textContent?.trim()); + expect(tabs).toContain("Dashboard"); + }); +}); + +describe("admin data refresh failures", () => { + beforeEach(() => { + document.body.innerHTML = ` +
        +
        + `; + }); + + it("sets error status when overview fails", async () => { + mockFetchResponse = { ok: false, status: 500, payload: {} }; + await refreshOverview(); + expect(document.getElementById("overview-status")?.textContent).toContain("Failed"); + }); + + it("sets error status when analytics fails", async () => { + mockFetchResponse = { ok: false, status: 500, payload: {} }; + await refreshAnalytics(); + expect(document.getElementById("analytics-status")?.textContent).toContain("Failed"); + }); +}); + +describe("playlist status badges", () => { + beforeEach(() => { + document.body.innerHTML = `
          `; + }); + + it("renders badges for every media status", () => { + const statusMap = new Map([ + ["/app/media/ready.mp4", "ready"], + ["/app/media/needs.mp4", "needs_normalization"], + ["/app/media/normalizing.mp4", "normalizing"], + ["/app/media/failed.mp4", "normalization_failed"], + ["/app/media/other.mp4", "weird"], + ]); + + renderPlaylistSortable( + "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n", + statusMap, + ); + + const list = document.getElementById("playlist-sortable")!; + expect(list.innerHTML).toContain("badge-ok"); + expect(list.innerHTML).toContain("badge-warn"); + expect(list.innerHTML).toContain("badge-info"); + expect(list.innerHTML).toContain("badge-danger"); + expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted + expect(list.children).toHaveLength(6); + }); +}); diff --git a/frontend/src/counter.test.ts b/frontend/src/counter.test.ts index 074ec19..02b962e 100644 --- a/frontend/src/counter.test.ts +++ b/frontend/src/counter.test.ts @@ -1,23 +1,23 @@ -/// -import { describe, it, expect } from "vitest"; -import { setupCounter } from "../src/counter"; - -describe("setupCounter", () => { - it("initializes the button with count 0", () => { - const btn = document.createElement("button"); - setupCounter(btn); - expect(btn.innerHTML).toBe("Count is 0"); - }); - - it("increments the counter on each click", () => { - const btn = document.createElement("button"); - setupCounter(btn); - - btn.click(); - expect(btn.innerHTML).toBe("Count is 1"); - - btn.click(); - btn.click(); - expect(btn.innerHTML).toBe("Count is 3"); - }); -}); +/// +import { describe, it, expect } from "vitest"; +import { setupCounter } from "../src/counter"; + +describe("setupCounter", () => { + it("initializes the button with count 0", () => { + const btn = document.createElement("button"); + setupCounter(btn); + expect(btn.innerHTML).toBe("Count is 0"); + }); + + it("increments the counter on each click", () => { + const btn = document.createElement("button"); + setupCounter(btn); + + btn.click(); + expect(btn.innerHTML).toBe("Count is 1"); + + btn.click(); + btn.click(); + expect(btn.innerHTML).toBe("Count is 3"); + }); +}); diff --git a/frontend/src/main.test.ts b/frontend/src/main.test.ts index 9a86a52..eeb4acd 100644 --- a/frontend/src/main.test.ts +++ b/frontend/src/main.test.ts @@ -1,598 +1,598 @@ -/// -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - resetViewerStateForTests, - resolveManifestUrl, - resolveWebSocketBaseUrl, - showReconnecting, - hideReconnecting, -} from "../src/main"; - -const hlsInstance = { - loadSource: vi.fn(), - attachMedia: vi.fn(), - on: vi.fn(), - destroy: vi.fn(), -}; - -const HlsMock = Object.assign( - vi.fn().mockImplementation(() => hlsInstance), - { - isSupported: vi.fn(() => true), - Events: { - MANIFEST_PARSED: "hlsManifestParsed", - ERROR: "hlsError", - LEVEL_UPDATED: "hlsLevelUpdated", - }, - ErrorTypes: { - NETWORK_ERROR: "networkError", - MEDIA_ERROR: "mediaError", - MUX_ERROR: "muxError", - OTHER_ERROR: "otherError", - }, - }, -); - -// Mock hls.js -vi.mock("hls.js", () => ({ - default: HlsMock, -})); - -// Mock WebSocket -const websocketInstances: MockWebSocket[] = []; -class MockWebSocket { - url: string; - readyState = 0; - onopen: (() => void) | null = null; - onmessage: ((e: { data: string }) => void) | null = null; - onclose: (() => void) | null = null; - send = vi.fn(); - close = vi.fn(); - - constructor(url: string) { - this.url = String(url); - websocketInstances.push(this); - } -} - -// Suppress console noise -beforeEach(() => { - vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(console, "log").mockImplementation(() => {}); -}); - -let origWebSocket: any; -let origFetch: any; - -beforeEach(() => { - origWebSocket = (globalThis as any).WebSocket; - origFetch = globalThis.fetch; - (globalThis as any).WebSocket = MockWebSocket; - websocketInstances.length = 0; - resetViewerStateForTests(); - - // Mock fetch (schedule, etc.) — return empty schedule - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }), - }); - - // Set up DOM from index.html - // Inject minimal CSS for jsdom layout tests (external style.css not loaded) - document.head.innerHTML = ` - - `; - - document.body.innerHTML = ` -
          -
          - -
          -
          -
          - -
          - Loading... - -
          -
          - -
          -
          - - - - - - - `; - - // Stub prompt for DM - (globalThis as any).prompt = vi.fn(); - - vi.useRealTimers(); -}); - -afterEach(() => { - (globalThis as any).WebSocket = origWebSocket; - globalThis.fetch = origFetch; - vi.useRealTimers(); - vi.restoreAllMocks(); -}); - -describe("player startup", () => { - it("starts native HLS playback on loadedmetadata", async () => { - const video = document.getElementById("player") as HTMLVideoElement; - vi.spyOn(video, "canPlayType").mockReturnValue("probably"); - const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any); - - await import("../src/main").then((mod) => mod.initPlayerAndChat()); - - expect(video.src).toContain("/hls/playlist.m3u8?stream="); - video.dispatchEvent(new Event("loadedmetadata")); - expect(playSpy).toHaveBeenCalled(); - }); - - it("starts hls.js playback when native HLS is unavailable", async () => { - const video = document.getElementById("player") as HTMLVideoElement; - Object.defineProperty(video, "canPlayType", { - value: vi.fn(() => ""), - configurable: true, - }); - vi.spyOn(video, "play").mockResolvedValue(undefined as any); - - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - expect(HlsMock.isSupported).toHaveBeenCalled(); - expect(HlsMock).toHaveBeenCalled(); - }); -}); - -describe("chat toggle", () => { - it("toggles chat panel visibility", () => { - const toggle = document.getElementById("chat-toggle")!; - - expect(toggle.textContent).toBe("Hide Chat"); - - window.toggleChatPanel(); - expect(toggle.textContent).toBe("Show Chat"); - - window.toggleChatPanel(); - expect(toggle.textContent).toBe("Hide Chat"); - }); -}); - -describe("sendMessage", () => { - it("clears input after sending non-empty text", () => { - const input = document.getElementById("message-input") as HTMLInputElement; - input.value = "hello!"; - - window.sendMessage(); - expect(input.value).toBe("hello!"); - }); - - it("does nothing with empty input", () => { - const input = document.getElementById("message-input") as HTMLInputElement; - input.value = " "; - window.sendMessage(); - // No error thrown = pass - }); -}); - -describe("switchRoom", () => { - it("updates room name on switch", () => { - window.switchRoom("lobby"); - const roomName = document.getElementById("room-name")!; - expect(roomName.textContent).toBe("Lobby Room"); - }); - - it("shows Main Channel for main room", () => { - window.switchRoom("main"); - const roomName = document.getElementById("room-name")!; - expect(roomName.textContent).toBe("Main Channel"); - }); -}); - -describe("togglePrivateDM", () => { - it("prompts for username and switches room", () => { - // Stub prompt to return a value this time - vi.spyOn(window, "prompt").mockReturnValue("bob"); - - const switchRoomSpy = vi.spyOn(window, "switchRoom"); - window.togglePrivateDM(); - - expect(window.prompt).toHaveBeenCalled(); - expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly - - switchRoomSpy.mockRestore(); - }); -}); - -describe("WebSocket reconnect", () => { - it("reconnects after onclose", async () => { - vi.useFakeTimers(); - - window.switchRoom("lobby"); - expect(websocketInstances).toHaveLength(1); - - websocketInstances[0].onclose?.(); - await vi.advanceTimersByTimeAsync(3000); - - expect(websocketInstances).toHaveLength(2); - expect(websocketInstances[1].url).toContain("room=lobby"); - }); - - it("does not reconnect from a socket that was intentionally replaced", async () => { - vi.useFakeTimers(); - - window.switchRoom("lobby"); - expect(websocketInstances).toHaveLength(1); - const replaced = websocketInstances[0]; - - // A room switch intentionally replaces the socket; the old socket must not - // schedule its own reconnect, otherwise history is re-pulled on a loop. - window.switchRoom("main"); - expect(websocketInstances).toHaveLength(2); - expect(replaced.onclose).toBeNull(); - - replaced.onclose?.(); - await vi.advanceTimersByTimeAsync(3000); - - expect(websocketInstances).toHaveLength(2); - }); -}); - -describe("URL resolution", () => { - it("falls back to same-origin manifest and websocket URLs", () => { - vi.stubEnv("VITE_PUBLIC_HLS_URL", ""); - vi.stubEnv("VITE_PUBLIC_WS_URL", ""); - vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", ""); - - Object.defineProperty(window, "location", { - value: new URL("https://linearvoid.tv/viewer"), - writable: true, - }); - - expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); - expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws"); - }); - - it("preserves an allowed cross-origin manifest URL", () => { - vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8"); - vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true"); - - expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); - }); - - it("falls back to same-origin manifest when cross-origin is not allowed", () => { - vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8"); - vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false"); - - expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); - }); - - it("uses configured websocket URL when present", () => { - vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws"); - - expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws"); - }); -}); - -describe("presence updates", () => { - it("adds user to presence list on join system message", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); - - const presenceList = document.getElementById("presence-list")!; - expect(presenceList.textContent).toContain("alice"); - }); - - it("removes user from presence list on leave system message", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) }); - - const presenceList = document.getElementById("presence-list")!; - expect(presenceList.textContent).not.toContain("alice"); - }); - - it("renders multiple users sorted", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) }); - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); - - const presenceList = document.getElementById("presence-list")!; - const text = presenceList.textContent ?? ""; - const aliceIdx = text.indexOf("alice"); - const zoeIdx = text.indexOf("zoe"); - expect(aliceIdx).toBeLessThan(zoeIdx); - }); -}); - -describe("private DM flow", () => { - it("creates dm: room name from sorted usernames", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - vi.spyOn(window, "prompt").mockReturnValue("bob"); - window.togglePrivateDM(); - - const roomName = document.getElementById("room-name")!; - // username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer" - // switchRoom capitalizes first char: "Dm:bob-viewer Room" - expect(roomName.textContent).toContain("bob-viewer"); - }); -}); - -describe("message rendering", () => { - it("renders system messages", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) }); - - const messages = document.getElementById("messages")!; - expect(messages.innerHTML).toContain("Server restarting"); - expect(messages.querySelector(".message-system")).toBeTruthy(); - }); - - it("renders self messages as message-self", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ - data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }), - }); - - const messages = document.getElementById("messages")!; - expect(messages.innerHTML).toContain("hello world"); - expect(messages.querySelector(".message-self")).toBeTruthy(); - }); - - it("renders other-user messages as message-other", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ - data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }), - }); - - const messages = document.getElementById("messages")!; - expect(messages.innerHTML).toContain("hey"); - expect(messages.innerHTML).toContain("alice"); - expect(messages.querySelector(".message-other")).toBeTruthy(); - }); -}); - -describe("reconnecting overlay", () => { - it("shows and hides the overlay with a custom message", () => { - showReconnecting("Custom message"); - const overlay = document.getElementById("reconnecting-overlay")!; - expect(overlay.classList.contains("hidden")).toBe(false); - expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message"); - - hideReconnecting(); - expect(overlay.classList.contains("hidden")).toBe(true); - }); -}); - -describe("typing indicator", () => { - it("shows typing on a typing event and clears on the next message", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) }); - - const indicator = document.getElementById("typing-indicator")!; - expect(indicator.textContent).toBe("alice is typing..."); - expect(indicator.hidden).toBe(false); - - ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) }); - expect(indicator.hidden).toBe(true); - expect(indicator.textContent).toBe(""); - }); - - it("ignores typing events from the current user", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) }); - - const indicator = document.getElementById("typing-indicator")!; - expect(indicator.textContent).toBe(""); - expect(indicator.hidden).toBe(true); - }); -}); - -describe("sendMessage over websocket", () => { - it("sends a JSON message and clears the input", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const input = document.getElementById("message-input") as HTMLInputElement; - input.value = "hello world"; - window.sendMessage(); - - const ws = websocketInstances[0]; - expect(ws.send).toHaveBeenCalledTimes(1); - expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world")); - expect(input.value).toBe(""); - }); -}); - -describe("auth wiring", () => { - it("logs in and updates the auth UI", async () => { - await import("../src/main"); - - globalThis.fetch = vi.fn(async (url: any) => { - const u = String(url); - if (u.includes("/api/auth/login")) { - return { - ok: true, - status: 200, - json: async () => ({ username: "alice", authenticated: true }), - text: async () => "", - }; - } - return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" }; - }) as any; - - document.dispatchEvent(new Event("DOMContentLoaded")); - - const usernameInput = document.getElementById("auth-username") as HTMLInputElement; - const passwordInput = document.getElementById("auth-password") as HTMLInputElement; - usernameInput.value = "alice"; - passwordInput.value = "secret"; - - document.getElementById("auth-login-btn")!.click(); - await new Promise((r) => setTimeout(r, 0)); - - expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice"); - expect(document.getElementById("chat-user-label")!.textContent).toBe("alice"); - }); -}); - -describe("player error recovery", () => { - it("shows reconnecting overlay on fatal network error", async () => { - const main = await import("../src/main"); - await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any); - - // Find the error handler registered by initPlayerAndChat - const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as - | [string, Function] - | undefined; - - if (errorHandler) { - const handler = errorHandler[1]; - handler("hlsError", { - type: "networkError", - details: "manifestLoadError", - fatal: true, - }); - - const overlay = document.getElementById("reconnecting-overlay")!; - expect(overlay.classList.contains("hidden")).toBe(false); - expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting..."); - } - }); -}); - -describe("accessibility", () => { - it("player video has controls and aria attributes", () => { - const video = document.getElementById("player") as HTMLVideoElement; - expect(video.controls).toBe(true); - expect(video.hasAttribute("autoplay")).toBe(true); - expect(video.hasAttribute("playsinline")).toBe(true); - }); - - it("chat panel has aria-labels for live region", () => { - const messages = document.getElementById("messages"); - expect(messages?.getAttribute("aria-live")).toBe("polite"); - - const chatPanel = document.getElementById("chat-panel"); - expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel"); - }); - - it("chat toggle exposes aria-expanded state", () => { - const toggle = document.getElementById("chat-toggle")!; - expect(toggle.getAttribute("aria-expanded")).toBe("true"); - - window.toggleChatPanel(); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - }); - - it("message input has associated label", () => { - const input = document.getElementById("message-input") as HTMLInputElement; - expect(input.getAttribute("type")).toBe("text"); - expect(input.placeholder).toBe("Type a message..."); - const label = document.querySelector("label[for='message-input']"); - expect(label).toBeTruthy(); - expect(label?.textContent).toBe("Message input"); - }); - - it("reconnecting overlay is an aria-live assertive region", () => { - const overlay = document.getElementById("reconnecting-overlay"); - expect(overlay?.getAttribute("role")).toBe("alert"); - expect(overlay?.getAttribute("aria-live")).toBe("assertive"); - }); -}); - -describe("responsive layout", () => { - it("chat-collapsed class hides sidebar at narrow viewport", () => { - const shell = document.querySelector(".viewer-shell")!; - expect(shell.classList.contains("chat-collapsed")).toBe(false); - - window.toggleChatPanel(); - expect(shell.classList.contains("chat-collapsed")).toBe(true); - - window.toggleChatPanel(); - expect(shell.classList.contains("chat-collapsed")).toBe(false); - }); - - it("viewer-shell uses CSS grid for content layout", () => { - const content = document.querySelector(".viewer-content"); - expect(content).toBeTruthy(); - const style = window.getComputedStyle(content!); - expect(style.display).toBe("grid"); - }); - - it("now-playing overlay is accessible at small sizes", () => { - const np = document.getElementById("now-playing"); - expect(np?.getAttribute("aria-live")).toBe("polite"); - }); -}); - -describe("presence flash", () => { - it("flashes join/leave messages instead of appending chat rows", async () => { - await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); - - const ws = websocketInstances[0]; - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); - - const flash = document.getElementById("chat-flash")!; - expect(flash.textContent).toBe("alice joined"); - expect(flash.hidden).toBe(false); - - // Join/leave must not be rendered as chat rows. - const messages = document.getElementById("messages")!; - expect(messages.textContent).not.toContain("joined"); - - ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) }); - expect(flash.textContent).toBe("alice left"); - }); -}); +/// +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + resetViewerStateForTests, + resolveManifestUrl, + resolveWebSocketBaseUrl, + showReconnecting, + hideReconnecting, +} from "../src/main"; + +const hlsInstance = { + loadSource: vi.fn(), + attachMedia: vi.fn(), + on: vi.fn(), + destroy: vi.fn(), +}; + +const HlsMock = Object.assign( + vi.fn().mockImplementation(() => hlsInstance), + { + isSupported: vi.fn(() => true), + Events: { + MANIFEST_PARSED: "hlsManifestParsed", + ERROR: "hlsError", + LEVEL_UPDATED: "hlsLevelUpdated", + }, + ErrorTypes: { + NETWORK_ERROR: "networkError", + MEDIA_ERROR: "mediaError", + MUX_ERROR: "muxError", + OTHER_ERROR: "otherError", + }, + }, +); + +// Mock hls.js +vi.mock("hls.js", () => ({ + default: HlsMock, +})); + +// Mock WebSocket +const websocketInstances: MockWebSocket[] = []; +class MockWebSocket { + url: string; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + send = vi.fn(); + close = vi.fn(); + + constructor(url: string) { + this.url = String(url); + websocketInstances.push(this); + } +} + +// Suppress console noise +beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +let origWebSocket: any; +let origFetch: any; + +beforeEach(() => { + origWebSocket = (globalThis as any).WebSocket; + origFetch = globalThis.fetch; + (globalThis as any).WebSocket = MockWebSocket; + websocketInstances.length = 0; + resetViewerStateForTests(); + + // Mock fetch (schedule, etc.) — return empty schedule + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }), + }); + + // Set up DOM from index.html + // Inject minimal CSS for jsdom layout tests (external style.css not loaded) + document.head.innerHTML = ` + + `; + + document.body.innerHTML = ` +
          +
          + +
          +
          +
          + +
          + Loading... + +
          +
          + +
          +
          + + + + + + + `; + + // Stub prompt for DM + (globalThis as any).prompt = vi.fn(); + + vi.useRealTimers(); +}); + +afterEach(() => { + (globalThis as any).WebSocket = origWebSocket; + globalThis.fetch = origFetch; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("player startup", () => { + it("starts native HLS playback on loadedmetadata", async () => { + const video = document.getElementById("player") as HTMLVideoElement; + vi.spyOn(video, "canPlayType").mockReturnValue("probably"); + const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any); + + await import("../src/main").then((mod) => mod.initPlayerAndChat()); + + expect(video.src).toContain("/hls/playlist.m3u8?stream="); + video.dispatchEvent(new Event("loadedmetadata")); + expect(playSpy).toHaveBeenCalled(); + }); + + it("starts hls.js playback when native HLS is unavailable", async () => { + const video = document.getElementById("player") as HTMLVideoElement; + Object.defineProperty(video, "canPlayType", { + value: vi.fn(() => ""), + configurable: true, + }); + vi.spyOn(video, "play").mockResolvedValue(undefined as any); + + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + expect(HlsMock.isSupported).toHaveBeenCalled(); + expect(HlsMock).toHaveBeenCalled(); + }); +}); + +describe("chat toggle", () => { + it("toggles chat panel visibility", () => { + const toggle = document.getElementById("chat-toggle")!; + + expect(toggle.textContent).toBe("Hide Chat"); + + window.toggleChatPanel(); + expect(toggle.textContent).toBe("Show Chat"); + + window.toggleChatPanel(); + expect(toggle.textContent).toBe("Hide Chat"); + }); +}); + +describe("sendMessage", () => { + it("clears input after sending non-empty text", () => { + const input = document.getElementById("message-input") as HTMLInputElement; + input.value = "hello!"; + + window.sendMessage(); + expect(input.value).toBe("hello!"); + }); + + it("does nothing with empty input", () => { + const input = document.getElementById("message-input") as HTMLInputElement; + input.value = " "; + window.sendMessage(); + // No error thrown = pass + }); +}); + +describe("switchRoom", () => { + it("updates room name on switch", () => { + window.switchRoom("lobby"); + const roomName = document.getElementById("room-name")!; + expect(roomName.textContent).toBe("Lobby Room"); + }); + + it("shows Main Channel for main room", () => { + window.switchRoom("main"); + const roomName = document.getElementById("room-name")!; + expect(roomName.textContent).toBe("Main Channel"); + }); +}); + +describe("togglePrivateDM", () => { + it("prompts for username and switches room", () => { + // Stub prompt to return a value this time + vi.spyOn(window, "prompt").mockReturnValue("bob"); + + const switchRoomSpy = vi.spyOn(window, "switchRoom"); + window.togglePrivateDM(); + + expect(window.prompt).toHaveBeenCalled(); + expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly + + switchRoomSpy.mockRestore(); + }); +}); + +describe("WebSocket reconnect", () => { + it("reconnects after onclose", async () => { + vi.useFakeTimers(); + + window.switchRoom("lobby"); + expect(websocketInstances).toHaveLength(1); + + websocketInstances[0].onclose?.(); + await vi.advanceTimersByTimeAsync(3000); + + expect(websocketInstances).toHaveLength(2); + expect(websocketInstances[1].url).toContain("room=lobby"); + }); + + it("does not reconnect from a socket that was intentionally replaced", async () => { + vi.useFakeTimers(); + + window.switchRoom("lobby"); + expect(websocketInstances).toHaveLength(1); + const replaced = websocketInstances[0]; + + // A room switch intentionally replaces the socket; the old socket must not + // schedule its own reconnect, otherwise history is re-pulled on a loop. + window.switchRoom("main"); + expect(websocketInstances).toHaveLength(2); + expect(replaced.onclose).toBeNull(); + + replaced.onclose?.(); + await vi.advanceTimersByTimeAsync(3000); + + expect(websocketInstances).toHaveLength(2); + }); +}); + +describe("URL resolution", () => { + it("falls back to same-origin manifest and websocket URLs", () => { + vi.stubEnv("VITE_PUBLIC_HLS_URL", ""); + vi.stubEnv("VITE_PUBLIC_WS_URL", ""); + vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", ""); + + Object.defineProperty(window, "location", { + value: new URL("https://linearvoid.tv/viewer"), + writable: true, + }); + + expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); + expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws"); + }); + + it("preserves an allowed cross-origin manifest URL", () => { + vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8"); + vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true"); + + expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); + }); + + it("falls back to same-origin manifest when cross-origin is not allowed", () => { + vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8"); + vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false"); + + expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/); + }); + + it("uses configured websocket URL when present", () => { + vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws"); + + expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws"); + }); +}); + +describe("presence updates", () => { + it("adds user to presence list on join system message", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); + + const presenceList = document.getElementById("presence-list")!; + expect(presenceList.textContent).toContain("alice"); + }); + + it("removes user from presence list on leave system message", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) }); + + const presenceList = document.getElementById("presence-list")!; + expect(presenceList.textContent).not.toContain("alice"); + }); + + it("renders multiple users sorted", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) }); + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); + + const presenceList = document.getElementById("presence-list")!; + const text = presenceList.textContent ?? ""; + const aliceIdx = text.indexOf("alice"); + const zoeIdx = text.indexOf("zoe"); + expect(aliceIdx).toBeLessThan(zoeIdx); + }); +}); + +describe("private DM flow", () => { + it("creates dm: room name from sorted usernames", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + vi.spyOn(window, "prompt").mockReturnValue("bob"); + window.togglePrivateDM(); + + const roomName = document.getElementById("room-name")!; + // username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer" + // switchRoom capitalizes first char: "Dm:bob-viewer Room" + expect(roomName.textContent).toContain("bob-viewer"); + }); +}); + +describe("message rendering", () => { + it("renders system messages", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) }); + + const messages = document.getElementById("messages")!; + expect(messages.innerHTML).toContain("Server restarting"); + expect(messages.querySelector(".message-system")).toBeTruthy(); + }); + + it("renders self messages as message-self", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ + data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }), + }); + + const messages = document.getElementById("messages")!; + expect(messages.innerHTML).toContain("hello world"); + expect(messages.querySelector(".message-self")).toBeTruthy(); + }); + + it("renders other-user messages as message-other", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ + data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }), + }); + + const messages = document.getElementById("messages")!; + expect(messages.innerHTML).toContain("hey"); + expect(messages.innerHTML).toContain("alice"); + expect(messages.querySelector(".message-other")).toBeTruthy(); + }); +}); + +describe("reconnecting overlay", () => { + it("shows and hides the overlay with a custom message", () => { + showReconnecting("Custom message"); + const overlay = document.getElementById("reconnecting-overlay")!; + expect(overlay.classList.contains("hidden")).toBe(false); + expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message"); + + hideReconnecting(); + expect(overlay.classList.contains("hidden")).toBe(true); + }); +}); + +describe("typing indicator", () => { + it("shows typing on a typing event and clears on the next message", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) }); + + const indicator = document.getElementById("typing-indicator")!; + expect(indicator.textContent).toBe("alice is typing..."); + expect(indicator.hidden).toBe(false); + + ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) }); + expect(indicator.hidden).toBe(true); + expect(indicator.textContent).toBe(""); + }); + + it("ignores typing events from the current user", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) }); + + const indicator = document.getElementById("typing-indicator")!; + expect(indicator.textContent).toBe(""); + expect(indicator.hidden).toBe(true); + }); +}); + +describe("sendMessage over websocket", () => { + it("sends a JSON message and clears the input", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const input = document.getElementById("message-input") as HTMLInputElement; + input.value = "hello world"; + window.sendMessage(); + + const ws = websocketInstances[0]; + expect(ws.send).toHaveBeenCalledTimes(1); + expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world")); + expect(input.value).toBe(""); + }); +}); + +describe("auth wiring", () => { + it("logs in and updates the auth UI", async () => { + await import("../src/main"); + + globalThis.fetch = vi.fn(async (url: any) => { + const u = String(url); + if (u.includes("/api/auth/login")) { + return { + ok: true, + status: 200, + json: async () => ({ username: "alice", authenticated: true }), + text: async () => "", + }; + } + return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" }; + }) as any; + + document.dispatchEvent(new Event("DOMContentLoaded")); + + const usernameInput = document.getElementById("auth-username") as HTMLInputElement; + const passwordInput = document.getElementById("auth-password") as HTMLInputElement; + usernameInput.value = "alice"; + passwordInput.value = "secret"; + + document.getElementById("auth-login-btn")!.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice"); + expect(document.getElementById("chat-user-label")!.textContent).toBe("alice"); + }); +}); + +describe("player error recovery", () => { + it("shows reconnecting overlay on fatal network error", async () => { + const main = await import("../src/main"); + await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any); + + // Find the error handler registered by initPlayerAndChat + const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as + | [string, Function] + | undefined; + + if (errorHandler) { + const handler = errorHandler[1]; + handler("hlsError", { + type: "networkError", + details: "manifestLoadError", + fatal: true, + }); + + const overlay = document.getElementById("reconnecting-overlay")!; + expect(overlay.classList.contains("hidden")).toBe(false); + expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting..."); + } + }); +}); + +describe("accessibility", () => { + it("player video has controls and aria attributes", () => { + const video = document.getElementById("player") as HTMLVideoElement; + expect(video.controls).toBe(true); + expect(video.hasAttribute("autoplay")).toBe(true); + expect(video.hasAttribute("playsinline")).toBe(true); + }); + + it("chat panel has aria-labels for live region", () => { + const messages = document.getElementById("messages"); + expect(messages?.getAttribute("aria-live")).toBe("polite"); + + const chatPanel = document.getElementById("chat-panel"); + expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel"); + }); + + it("chat toggle exposes aria-expanded state", () => { + const toggle = document.getElementById("chat-toggle")!; + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + + window.toggleChatPanel(); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + }); + + it("message input has associated label", () => { + const input = document.getElementById("message-input") as HTMLInputElement; + expect(input.getAttribute("type")).toBe("text"); + expect(input.placeholder).toBe("Type a message..."); + const label = document.querySelector("label[for='message-input']"); + expect(label).toBeTruthy(); + expect(label?.textContent).toBe("Message input"); + }); + + it("reconnecting overlay is an aria-live assertive region", () => { + const overlay = document.getElementById("reconnecting-overlay"); + expect(overlay?.getAttribute("role")).toBe("alert"); + expect(overlay?.getAttribute("aria-live")).toBe("assertive"); + }); +}); + +describe("responsive layout", () => { + it("chat-collapsed class hides sidebar at narrow viewport", () => { + const shell = document.querySelector(".viewer-shell")!; + expect(shell.classList.contains("chat-collapsed")).toBe(false); + + window.toggleChatPanel(); + expect(shell.classList.contains("chat-collapsed")).toBe(true); + + window.toggleChatPanel(); + expect(shell.classList.contains("chat-collapsed")).toBe(false); + }); + + it("viewer-shell uses CSS grid for content layout", () => { + const content = document.querySelector(".viewer-content"); + expect(content).toBeTruthy(); + const style = window.getComputedStyle(content!); + expect(style.display).toBe("grid"); + }); + + it("now-playing overlay is accessible at small sizes", () => { + const np = document.getElementById("now-playing"); + expect(np?.getAttribute("aria-live")).toBe("polite"); + }); +}); + +describe("presence flash", () => { + it("flashes join/leave messages instead of appending chat rows", async () => { + await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any)); + + const ws = websocketInstances[0]; + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) }); + + const flash = document.getElementById("chat-flash")!; + expect(flash.textContent).toBe("alice joined"); + expect(flash.hidden).toBe(false); + + // Join/leave must not be rendered as chat rows. + const messages = document.getElementById("messages")!; + expect(messages.textContent).not.toContain("joined"); + + ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) }); + expect(flash.textContent).toBe("alice left"); + }); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ba8fc13..5fb3f21 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,519 +1,519 @@ -import "./style.css"; - -type HlsClass = (typeof import("hls.js"))["default"]; - -declare global { - interface Window { - switchRoom: (room: string) => void; - sendMessage: () => void; - togglePrivateDM: () => void; - toggleChatPanel: () => void; - } -} - -let hls: InstanceType | null = null; -let ws: WebSocket | null = null; -let currentRoom = "main"; -let username = "viewer" + Math.floor(Math.random() * 1000); -let authenticated = false; -let chatVisible = true; -let typingUser = ""; -let lastTypingSent = 0; -let typingTimeout: number | undefined; -let flashTimeout: number | undefined; -let flashFadeTimeout: number | undefined; -const onlineUsers = new Set(); - -// Player error recovery state (STREAMING.md contract) -let playerErrorCount = 0; -const PLAYER_MAX_RETRIES = 5; -const PLAYER_BASE_DELAY_MS = 2000; - -export function resetViewerStateForTests() { - hls = null; - ws = null; - currentRoom = "main"; - username = "viewer"; - authenticated = false; - chatVisible = true; - typingUser = ""; - lastTypingSent = 0; - onlineUsers.clear(); - playerErrorCount = 0; -} - -export function resolveWebSocketBaseUrl() { - const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim(); - - if (configuredUrl) { - return configuredUrl; - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${window.location.host}/ws`; -} - -export function resolveManifestUrl() { - const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim(); - const cacheBust = `stream=${Date.now()}`; - const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin); - sameOriginManifestUrl.searchParams.set("stream", cacheBust); - - const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true"; - - if (configuredUrl) { - const manifestUrl = new URL(configuredUrl); - manifestUrl.searchParams.set("stream", cacheBust); - - if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) { - return sameOriginManifestUrl.toString(); - } - - return manifestUrl.toString(); - } - - return sameOriginManifestUrl.toString(); -} - -function connectWebSocket(room: string) { - // Clear previous messages so reconnects render a fresh history instead of - // appending duplicates on top of the old log. - clearMessages(); - - // Detach the old socket's close handler before closing it. Otherwise an - // intentional reconnect (login/logout/room switch/session refresh) would - // have the replaced socket schedule yet another reconnect, and each new - // connection re-pulls the room's history — filling the panel with old - // messages on a loop. - if (ws) { - ws.onclose = null; - ws.close(); - } - - const socketUrl = new URL(resolveWebSocketBaseUrl()); - socketUrl.searchParams.set("username", username); - socketUrl.searchParams.set("room", room); - const socket = new WebSocket(socketUrl); - ws = socket; - - socket.onopen = () => { - console.log(`Connected to chat room: ${room}`); - addMessage("system", "Connected to " + room); - }; - - socket.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.type === "typing") { - showTyping(msg.username); - return; - } - hideTyping(msg.username); - - // Presence join/leave announcements become transient flashes, not chat rows. - if (msg.type === "system" && msg.username === "system") { - const joined = msg.text?.match(/^(\S+) joined /); - const left = msg.text?.match(/^(\S+) left /); - if (joined) { - onlineUsers.add(joined[1]); - renderPresence(); - showFlash(`${joined[1]} joined`); - return; - } else if (left) { - onlineUsers.delete(left[1]); - renderPresence(); - showFlash(`${left[1]} left`); - return; - } - } - - addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp); - }; - - socket.onclose = () => { - // Ignore close events from sockets that were intentionally replaced. - if (ws !== socket) return; - ws = null; - console.log("Chat disconnected. Reconnecting in 3s..."); - setTimeout(() => connectWebSocket(room), 3000); - }; -} - -function clearMessages() { - const messages = document.getElementById("messages"); - if (messages) messages.innerHTML = ""; -} - -function addMessage(type: string, text: string, sender = "", timestamp = new Date()) { - const messages = document.getElementById("messages"); - if (!messages) return; - const div = document.createElement("div"); - div.className = "message-row"; - - if (type === "system") { - div.classList.add("message-system-row"); - div.innerHTML = `
          ${text}
          `; - } else { - div.classList.add(sender === username ? "message-self-row" : "message-other-row"); - div.innerHTML = ` -
          - ${sender && sender !== username ? `
          ${sender}
          ` : ""} -
          ${text}
          - -
          - `; - } - - messages.appendChild(div); - messages.scrollTop = messages.scrollHeight; -} - -function showFlash(text: string) { - const el = document.getElementById("chat-flash"); - if (!el) return; - el.textContent = text; - el.hidden = false; - el.classList.remove("fade-out"); - window.clearTimeout(flashTimeout); - window.clearTimeout(flashFadeTimeout); - flashTimeout = window.setTimeout(() => { - el.classList.add("fade-out"); - flashFadeTimeout = window.setTimeout(() => { - el.hidden = true; - el.classList.remove("fade-out"); - }, 500); - }, 3000); -} - -function renderPresence() { - const list = document.getElementById("presence-list"); - if (!list) return; - const sorted = [...onlineUsers].sort(); - list.innerHTML = sorted - .map((u) => `${u}`) - .join(""); -} - -async function refreshSchedule() { - try { - const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; - const res = await fetch(`${apiUrl}/api/schedule`); - if (!res.ok) return; - const data = await res.json(); - const entries = data.entries as Array<{ title: string; status: string }>; - if (entries.length > 0) { - const np = document.getElementById("np-title"); - const nn = document.getElementById("np-next"); - if (np) np.textContent = `Now: ${entries[0].title}`; - if (nn && entries.length > 1) { - nn.textContent = `Up next: ${entries[1].title}`; - } - } - } catch { - // Schedule unavailable — non-critical - } -} - -export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) { - const video = document.getElementById("player") as HTMLVideoElement; - const manifestUrl = resolveManifestUrl(); - - if (video.canPlayType("application/vnd.apple.mpegurl")) { - video.src = manifestUrl; - video.addEventListener("loadedmetadata", () => video.play()); - } else { - try { - const { default: Hls } = await loadHlsModule(); - if (Hls.isSupported()) { - hls = new Hls({ - liveDurationInfinity: true, - liveSyncDurationCount: 3, - }); - hls.loadSource(manifestUrl); - hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => video.play()); - - // Player error recovery (STREAMING.md contract) - hls.on(Hls.Events.ERROR, (_event, data) => { - if (!data.fatal) return; - - playerErrorCount++; - - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - if (playerErrorCount <= PLAYER_MAX_RETRIES) { - const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1); - console.warn( - `HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`, - ); - showReconnecting("Reconnecting..."); - setTimeout(() => { - hls?.startLoad(); - }, delay); - } else { - console.error("HLS network error: max retries exhausted"); - showReconnecting("Stream temporarily unavailable"); - } - break; - - case Hls.ErrorTypes.MEDIA_ERROR: - if (playerErrorCount <= 1) { - console.warn("HLS media error, attempting recovery..."); - showReconnecting("Recovering..."); - hls!.recoverMediaError(); - } else { - console.error("HLS media error: recovery failed"); - hls!.destroy(); - showReconnecting("Stream temporarily unavailable"); - } - break; - - default: - console.error("HLS fatal error:", data.type, data.details); - hls!.destroy(); - showReconnecting("Stream temporarily unavailable"); - break; - } - }); - - // Reset error count on successful manifest parse - hls.on(Hls.Events.MANIFEST_PARSED, () => { - playerErrorCount = 0; - hideReconnecting(); - }); - } - } catch (error) { - console.error("Failed to load HLS player", error); - addMessage("system", "Unable to initialize video player."); - } - } - - // Resolve the user session first so we connect once with the correct username - // (avoids pushing chat history twice on initial load). - await sessionReady; - connectWebSocket("main"); - - // Start schedule polling - refreshSchedule(); - setInterval(refreshSchedule, 30000); - - console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready"); -} - -export function showReconnecting(message = "Reconnecting...") { - const overlay = document.getElementById("reconnecting-overlay"); - const text = document.getElementById("reconnecting-text"); - if (overlay) { - overlay.classList.remove("hidden"); - } - if (text) { - text.textContent = message; - } -} - -export function hideReconnecting() { - const overlay = document.getElementById("reconnecting-overlay"); - if (overlay) { - overlay.classList.add("hidden"); - } -} - -function toggleChatPanel() { - const shell = document.querySelector(".viewer-shell"); - const toggle = document.getElementById("chat-toggle"); - if (!shell || !toggle) return; - - chatVisible = !chatVisible; - shell.classList.toggle("chat-collapsed", !chatVisible); - toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat"; - toggle.setAttribute("aria-expanded", String(chatVisible)); -} - -function sendMessage() { - const input = document.getElementById("message-input") as HTMLInputElement; - const text = input.value.trim(); - if (!text || !ws) return; - - const msg = { - room: currentRoom, - username: username, - text: text, - type: currentRoom.startsWith("dm:") ? "private" : "public", - }; - - ws.send(JSON.stringify(msg)); - input.value = ""; -} - -function switchRoom(room: string) { - currentRoom = room; - document.getElementById("room-name")!.textContent = - room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; - connectWebSocket(room); -} - -function togglePrivateDM() { - const target = prompt("Enter username to start private chat with:"); - if (target && target !== username) { - const dmRoom = `dm:${[username, target].sort().join("-")}`; - switchRoom(dmRoom); - } -} - -window.switchRoom = switchRoom; -window.sendMessage = sendMessage; -window.togglePrivateDM = togglePrivateDM; -window.toggleChatPanel = toggleChatPanel; - -function openAuthModal() { - const panel = document.getElementById("auth-panel"); - if (panel) panel.hidden = false; -} - -function closeAuthModal() { - const panel = document.getElementById("auth-panel"); - if (panel) panel.hidden = true; -} - -function updateAuthUI() { - const label = document.getElementById("chat-user-label"); - const toggle = document.getElementById("auth-toggle-btn"); - const logoutBtn = document.getElementById("auth-logout-btn"); - const panel = document.getElementById("auth-panel"); - if (label) { - label.textContent = authenticated ? username : ""; - label.hidden = !authenticated; - } - if (toggle) toggle.hidden = authenticated; - if (logoutBtn) logoutBtn.hidden = !authenticated; - if (panel) panel.hidden = true; -} - -let sessionReady: Promise = Promise.resolve(); - -function refreshUserSession(): void { - sessionReady = (async () => { - try { - const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; - const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" }); - if (!res.ok) return; - const data = await res.json(); - if (data.authenticated && data.username) { - authenticated = true; - username = data.username; - } else { - authenticated = false; - } - updateAuthUI(); - } catch { - /* non-critical */ - } - })(); -} - -async function submitAuth(path: string) { - const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim(); - const p = (document.getElementById("auth-password") as HTMLInputElement).value; - const status = document.getElementById("auth-status"); - if (!u || !p) { - if (status) { - status.textContent = "Username and password required."; - status.className = "status-line status-error"; - } - return; - } - try { - const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; - const res = await fetch(`${apiUrl}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: u, password: p }), - }); - if (!res.ok) { - const text = (await res.text()).trim(); - throw new Error(text || `HTTP ${res.status}`); - } - const data = await res.json(); - username = data.username || u; - authenticated = true; - updateAuthUI(); - if (status) { - status.textContent = `Signed in as ${username}.`; - status.className = "status-line"; - } - connectWebSocket(currentRoom); - } catch (err) { - if (status) { - status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`; - status.className = "status-line status-error"; - } - } -} - -function login() { - void submitAuth("/api/auth/login"); -} - -function register() { - void submitAuth("/api/auth/register"); -} - -async function logout() { - try { - const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; - await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" }); - } catch { - /* ignore */ - } - authenticated = false; - username = "viewer" + Math.floor(Math.random() * 1000); - updateAuthUI(); - connectWebSocket(currentRoom); -} - -function showTyping(name: string) { - if (name === username) return; - const el = document.getElementById("typing-indicator"); - if (!el) return; - typingUser = name; - el.textContent = `${name} is typing...`; - el.hidden = false; - window.clearTimeout(typingTimeout); - typingTimeout = window.setTimeout(() => { - typingUser = ""; - el.hidden = true; - el.textContent = ""; - }, 3000); -} - -function hideTyping(name: string) { - if (name !== typingUser) return; - const el = document.getElementById("typing-indicator"); - typingUser = ""; - window.clearTimeout(typingTimeout); - if (el) { - el.hidden = true; - el.textContent = ""; - } -} - -function sendTyping() { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const now = Date.now(); - if (now - lastTypingSent < 2000) return; - lastTypingSent = now; - ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" })); -} - -document.addEventListener("DOMContentLoaded", () => { - void initPlayerAndChat(); - refreshUserSession(); - document.getElementById("auth-toggle-btn")?.addEventListener("click", () => { - if (!authenticated) openAuthModal(); - }); - document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal); - document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal); - document.getElementById("auth-login-btn")?.addEventListener("click", login); - document.getElementById("auth-register-btn")?.addEventListener("click", register); - document.getElementById("auth-logout-btn")?.addEventListener("click", logout); - document.getElementById("message-input")?.addEventListener("input", sendTyping); -}); +import "./style.css"; + +type HlsClass = (typeof import("hls.js"))["default"]; + +declare global { + interface Window { + switchRoom: (room: string) => void; + sendMessage: () => void; + togglePrivateDM: () => void; + toggleChatPanel: () => void; + } +} + +let hls: InstanceType | null = null; +let ws: WebSocket | null = null; +let currentRoom = "main"; +let username = "viewer" + Math.floor(Math.random() * 1000); +let authenticated = false; +let chatVisible = true; +let typingUser = ""; +let lastTypingSent = 0; +let typingTimeout: number | undefined; +let flashTimeout: number | undefined; +let flashFadeTimeout: number | undefined; +const onlineUsers = new Set(); + +// Player error recovery state (STREAMING.md contract) +let playerErrorCount = 0; +const PLAYER_MAX_RETRIES = 5; +const PLAYER_BASE_DELAY_MS = 2000; + +export function resetViewerStateForTests() { + hls = null; + ws = null; + currentRoom = "main"; + username = "viewer"; + authenticated = false; + chatVisible = true; + typingUser = ""; + lastTypingSent = 0; + onlineUsers.clear(); + playerErrorCount = 0; +} + +export function resolveWebSocketBaseUrl() { + const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim(); + + if (configuredUrl) { + return configuredUrl; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/ws`; +} + +export function resolveManifestUrl() { + const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim(); + const cacheBust = `stream=${Date.now()}`; + const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin); + sameOriginManifestUrl.searchParams.set("stream", cacheBust); + + const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true"; + + if (configuredUrl) { + const manifestUrl = new URL(configuredUrl); + manifestUrl.searchParams.set("stream", cacheBust); + + if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) { + return sameOriginManifestUrl.toString(); + } + + return manifestUrl.toString(); + } + + return sameOriginManifestUrl.toString(); +} + +function connectWebSocket(room: string) { + // Clear previous messages so reconnects render a fresh history instead of + // appending duplicates on top of the old log. + clearMessages(); + + // Detach the old socket's close handler before closing it. Otherwise an + // intentional reconnect (login/logout/room switch/session refresh) would + // have the replaced socket schedule yet another reconnect, and each new + // connection re-pulls the room's history — filling the panel with old + // messages on a loop. + if (ws) { + ws.onclose = null; + ws.close(); + } + + const socketUrl = new URL(resolveWebSocketBaseUrl()); + socketUrl.searchParams.set("username", username); + socketUrl.searchParams.set("room", room); + const socket = new WebSocket(socketUrl); + ws = socket; + + socket.onopen = () => { + console.log(`Connected to chat room: ${room}`); + addMessage("system", "Connected to " + room); + }; + + socket.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === "typing") { + showTyping(msg.username); + return; + } + hideTyping(msg.username); + + // Presence join/leave announcements become transient flashes, not chat rows. + if (msg.type === "system" && msg.username === "system") { + const joined = msg.text?.match(/^(\S+) joined /); + const left = msg.text?.match(/^(\S+) left /); + if (joined) { + onlineUsers.add(joined[1]); + renderPresence(); + showFlash(`${joined[1]} joined`); + return; + } else if (left) { + onlineUsers.delete(left[1]); + renderPresence(); + showFlash(`${left[1]} left`); + return; + } + } + + addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp); + }; + + socket.onclose = () => { + // Ignore close events from sockets that were intentionally replaced. + if (ws !== socket) return; + ws = null; + console.log("Chat disconnected. Reconnecting in 3s..."); + setTimeout(() => connectWebSocket(room), 3000); + }; +} + +function clearMessages() { + const messages = document.getElementById("messages"); + if (messages) messages.innerHTML = ""; +} + +function addMessage(type: string, text: string, sender = "", timestamp = new Date()) { + const messages = document.getElementById("messages"); + if (!messages) return; + const div = document.createElement("div"); + div.className = "message-row"; + + if (type === "system") { + div.classList.add("message-system-row"); + div.innerHTML = `
          ${text}
          `; + } else { + div.classList.add(sender === username ? "message-self-row" : "message-other-row"); + div.innerHTML = ` +
          + ${sender && sender !== username ? `
          ${sender}
          ` : ""} +
          ${text}
          + +
          + `; + } + + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; +} + +function showFlash(text: string) { + const el = document.getElementById("chat-flash"); + if (!el) return; + el.textContent = text; + el.hidden = false; + el.classList.remove("fade-out"); + window.clearTimeout(flashTimeout); + window.clearTimeout(flashFadeTimeout); + flashTimeout = window.setTimeout(() => { + el.classList.add("fade-out"); + flashFadeTimeout = window.setTimeout(() => { + el.hidden = true; + el.classList.remove("fade-out"); + }, 500); + }, 3000); +} + +function renderPresence() { + const list = document.getElementById("presence-list"); + if (!list) return; + const sorted = [...onlineUsers].sort(); + list.innerHTML = sorted + .map((u) => `${u}`) + .join(""); +} + +async function refreshSchedule() { + try { + const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; + const res = await fetch(`${apiUrl}/api/schedule`); + if (!res.ok) return; + const data = await res.json(); + const entries = data.entries as Array<{ title: string; status: string }>; + if (entries.length > 0) { + const np = document.getElementById("np-title"); + const nn = document.getElementById("np-next"); + if (np) np.textContent = `Now: ${entries[0].title}`; + if (nn && entries.length > 1) { + nn.textContent = `Up next: ${entries[1].title}`; + } + } + } catch { + // Schedule unavailable — non-critical + } +} + +export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) { + const video = document.getElementById("player") as HTMLVideoElement; + const manifestUrl = resolveManifestUrl(); + + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = manifestUrl; + video.addEventListener("loadedmetadata", () => video.play()); + } else { + try { + const { default: Hls } = await loadHlsModule(); + if (Hls.isSupported()) { + hls = new Hls({ + liveDurationInfinity: true, + liveSyncDurationCount: 3, + }); + hls.loadSource(manifestUrl); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => video.play()); + + // Player error recovery (STREAMING.md contract) + hls.on(Hls.Events.ERROR, (_event, data) => { + if (!data.fatal) return; + + playerErrorCount++; + + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + if (playerErrorCount <= PLAYER_MAX_RETRIES) { + const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1); + console.warn( + `HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`, + ); + showReconnecting("Reconnecting..."); + setTimeout(() => { + hls?.startLoad(); + }, delay); + } else { + console.error("HLS network error: max retries exhausted"); + showReconnecting("Stream temporarily unavailable"); + } + break; + + case Hls.ErrorTypes.MEDIA_ERROR: + if (playerErrorCount <= 1) { + console.warn("HLS media error, attempting recovery..."); + showReconnecting("Recovering..."); + hls!.recoverMediaError(); + } else { + console.error("HLS media error: recovery failed"); + hls!.destroy(); + showReconnecting("Stream temporarily unavailable"); + } + break; + + default: + console.error("HLS fatal error:", data.type, data.details); + hls!.destroy(); + showReconnecting("Stream temporarily unavailable"); + break; + } + }); + + // Reset error count on successful manifest parse + hls.on(Hls.Events.MANIFEST_PARSED, () => { + playerErrorCount = 0; + hideReconnecting(); + }); + } + } catch (error) { + console.error("Failed to load HLS player", error); + addMessage("system", "Unable to initialize video player."); + } + } + + // Resolve the user session first so we connect once with the correct username + // (avoids pushing chat history twice on initial load). + await sessionReady; + connectWebSocket("main"); + + // Start schedule polling + refreshSchedule(); + setInterval(refreshSchedule, 30000); + + console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready"); +} + +export function showReconnecting(message = "Reconnecting...") { + const overlay = document.getElementById("reconnecting-overlay"); + const text = document.getElementById("reconnecting-text"); + if (overlay) { + overlay.classList.remove("hidden"); + } + if (text) { + text.textContent = message; + } +} + +export function hideReconnecting() { + const overlay = document.getElementById("reconnecting-overlay"); + if (overlay) { + overlay.classList.add("hidden"); + } +} + +function toggleChatPanel() { + const shell = document.querySelector(".viewer-shell"); + const toggle = document.getElementById("chat-toggle"); + if (!shell || !toggle) return; + + chatVisible = !chatVisible; + shell.classList.toggle("chat-collapsed", !chatVisible); + toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat"; + toggle.setAttribute("aria-expanded", String(chatVisible)); +} + +function sendMessage() { + const input = document.getElementById("message-input") as HTMLInputElement; + const text = input.value.trim(); + if (!text || !ws) return; + + const msg = { + room: currentRoom, + username: username, + text: text, + type: currentRoom.startsWith("dm:") ? "private" : "public", + }; + + ws.send(JSON.stringify(msg)); + input.value = ""; +} + +function switchRoom(room: string) { + currentRoom = room; + document.getElementById("room-name")!.textContent = + room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; + connectWebSocket(room); +} + +function togglePrivateDM() { + const target = prompt("Enter username to start private chat with:"); + if (target && target !== username) { + const dmRoom = `dm:${[username, target].sort().join("-")}`; + switchRoom(dmRoom); + } +} + +window.switchRoom = switchRoom; +window.sendMessage = sendMessage; +window.togglePrivateDM = togglePrivateDM; +window.toggleChatPanel = toggleChatPanel; + +function openAuthModal() { + const panel = document.getElementById("auth-panel"); + if (panel) panel.hidden = false; +} + +function closeAuthModal() { + const panel = document.getElementById("auth-panel"); + if (panel) panel.hidden = true; +} + +function updateAuthUI() { + const label = document.getElementById("chat-user-label"); + const toggle = document.getElementById("auth-toggle-btn"); + const logoutBtn = document.getElementById("auth-logout-btn"); + const panel = document.getElementById("auth-panel"); + if (label) { + label.textContent = authenticated ? username : ""; + label.hidden = !authenticated; + } + if (toggle) toggle.hidden = authenticated; + if (logoutBtn) logoutBtn.hidden = !authenticated; + if (panel) panel.hidden = true; +} + +let sessionReady: Promise = Promise.resolve(); + +function refreshUserSession(): void { + sessionReady = (async () => { + try { + const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; + const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + if (data.authenticated && data.username) { + authenticated = true; + username = data.username; + } else { + authenticated = false; + } + updateAuthUI(); + } catch { + /* non-critical */ + } + })(); +} + +async function submitAuth(path: string) { + const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim(); + const p = (document.getElementById("auth-password") as HTMLInputElement).value; + const status = document.getElementById("auth-status"); + if (!u || !p) { + if (status) { + status.textContent = "Username and password required."; + status.className = "status-line status-error"; + } + return; + } + try { + const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; + const res = await fetch(`${apiUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: u, password: p }), + }); + if (!res.ok) { + const text = (await res.text()).trim(); + throw new Error(text || `HTTP ${res.status}`); + } + const data = await res.json(); + username = data.username || u; + authenticated = true; + updateAuthUI(); + if (status) { + status.textContent = `Signed in as ${username}.`; + status.className = "status-line"; + } + connectWebSocket(currentRoom); + } catch (err) { + if (status) { + status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`; + status.className = "status-line status-error"; + } + } +} + +function login() { + void submitAuth("/api/auth/login"); +} + +function register() { + void submitAuth("/api/auth/register"); +} + +async function logout() { + try { + const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin; + await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" }); + } catch { + /* ignore */ + } + authenticated = false; + username = "viewer" + Math.floor(Math.random() * 1000); + updateAuthUI(); + connectWebSocket(currentRoom); +} + +function showTyping(name: string) { + if (name === username) return; + const el = document.getElementById("typing-indicator"); + if (!el) return; + typingUser = name; + el.textContent = `${name} is typing...`; + el.hidden = false; + window.clearTimeout(typingTimeout); + typingTimeout = window.setTimeout(() => { + typingUser = ""; + el.hidden = true; + el.textContent = ""; + }, 3000); +} + +function hideTyping(name: string) { + if (name !== typingUser) return; + const el = document.getElementById("typing-indicator"); + typingUser = ""; + window.clearTimeout(typingTimeout); + if (el) { + el.hidden = true; + el.textContent = ""; + } +} + +function sendTyping() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const now = Date.now(); + if (now - lastTypingSent < 2000) return; + lastTypingSent = now; + ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" })); +} + +document.addEventListener("DOMContentLoaded", () => { + void initPlayerAndChat(); + refreshUserSession(); + document.getElementById("auth-toggle-btn")?.addEventListener("click", () => { + if (!authenticated) openAuthModal(); + }); + document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal); + document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal); + document.getElementById("auth-login-btn")?.addEventListener("click", login); + document.getElementById("auth-register-btn")?.addEventListener("click", register); + document.getElementById("auth-logout-btn")?.addEventListener("click", logout); + document.getElementById("message-input")?.addEventListener("input", sendTyping); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css index e878312..a637185 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,1244 +1,1244 @@ -:root { - color-scheme: dark; - - --font-body: "Tektur", "Segoe UI", "Helvetica Neue", sans-serif; - --font-heading: "Doto", "Segoe UI", "Helvetica Neue", sans-serif; - --font-display: "Space Grotesk", "Segoe UI", sans-serif; - --font-mono: "IBM Plex Mono", Consolas, monospace; - - --bg-0: #000000; - --bg-1: #0a0a0a; - --bg-2: #000000; - --ink-0: #ede8e6; - --ink-1: #b0a8a4; - --ink-soft: #78706c; - - --panel: #111111; - --panel-2: #1a1a1a; - --line: #2a2a2a; - --line-soft: #1f1f1f; - - --brand: #587792; - --brand-strong: #4a6780; - --brand-contrast: #f4f8fb; - --ok: #04650d; - --warn: #d49624; - --danger: #c44a5e; - --danger-soft: #1f1216; - - --focus: #2ebfa5; - --shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5); - --shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6); - - --bg-grad-a: #000000; - --bg-grad-b: #000000; - --bg-grad-c: #000000; - - --live-dot: #ff6b6b; - --overlay-bg: rgba(0, 0, 0, 0.88); - - --input-bg: #0d0d0d; - --input-border: var(--line-soft); - --placeholder: #6a6260; - - --badge-start: #2ebfa5; - --badge-end: #1a8c78; - - --radius-1: 0; - --radius-2: 0; - --radius-3: 0; - - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-5: 1.5rem; - --space-6: 2rem; - --space-7: 3rem; - - --fast: 160ms ease; -} - -@media (prefers-color-scheme: light) { - :root { - color-scheme: light; - - --bg-0: #e8e8e8; - --bg-1: #dcdcdc; - --bg-2: #f0f0f0; - --ink-0: #0d0d0d; - --ink-1: #2a2a2a; - --ink-soft: #555555; - - --panel: #ffffff; - --panel-2: #f5f5f5; - --line: #cccccc; - --line-soft: #dddddd; - - --brand: #587792; - --brand-strong: #4a6780; - --brand-contrast: #f4f8fb; - --ok: #04650d; - --warn: #a86200; - --danger: #a7283f; - --danger-soft: #fff3f6; - - --focus: #2ebfa5; - --shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08); - --shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12); - - --bg-grad-a: #e8e8e8; - --bg-grad-b: var(--bg-0); - --bg-grad-c: #f0f0f0; - - --live-dot: #cb271a; - --overlay-bg: rgba(0, 0, 0, 0.88); - - --input-bg: #ffffff; - --input-border: #cccccc; - --placeholder: #888888; - - --badge-start: #2ebfa5; - --badge-end: #1a8c78; - } -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; - height: 100%; - min-height: 100%; -} - -body { - font-family: var(--font-body); - color: var(--ink-0); - background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%); - line-height: 1.45; -} - -h1, -h2, -h3 { - margin: 0; - font-family: var(--font-heading); - color: var(--ink-0); - letter-spacing: -0.02em; -} - -p { - margin: 0; -} - -a { - color: var(--brand-strong); -} - -a:hover { - color: var(--brand); -} - -button, -input, -textarea, -select { - font: inherit; -} - -:focus-visible { - outline: 3px solid var(--focus); - outline-offset: 2px; -} - -.hidden { - display: none !important; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.small-muted { - color: var(--ink-soft); - font-size: 0.9rem; -} - -.status-line { - margin-top: var(--space-2); - color: var(--ink-soft); - min-height: 1.4rem; -} - -.status-error { - color: var(--danger); -} - -.sortable-list { - list-style: none; - margin: 0 0 0.75rem; - padding: 0; - max-height: 300px; - overflow-y: auto; - border: 1px solid var(--line-soft); - border-radius: 4px; -} - -.sortable-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--line-soft); - cursor: grab; - transition: background 0.15s; -} - -.sortable-item:last-child { - border-bottom: none; -} -.sortable-item:hover { - background: var(--panel-2); -} -.sortable-item.dragging { - opacity: 0.4; -} - -.drag-handle { - color: var(--ink-soft); - font-size: 0.8rem; - letter-spacing: 1px; - cursor: grab; - user-select: none; -} - -.sortable-index { - font-size: 0.7rem; - color: var(--ink-soft); - min-width: 1.5rem; - text-align: right; -} - -.sortable-path { - flex: 1; - font-size: 0.8rem; - color: var(--ink-0); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status-warnings { - background: rgba(212, 150, 36, 0.1); - border: 1px solid var(--warn); - padding: 0.5rem 0.75rem; - margin-bottom: 0.75rem; - border-radius: 4px; -} - -.warning-msg { - font-size: 0.8rem; - color: var(--warn); - line-height: 1.5; -} - -.badge { - display: inline-block; - padding: 0.1rem 0.5rem; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 600; - line-height: 1.5; - white-space: nowrap; -} - -.badge-ok { - color: var(--ok); - background: rgba(4, 101, 13, 0.12); -} - -.badge-warn { - color: var(--warn); - background: rgba(212, 150, 36, 0.12); -} - -.badge-info { - color: var(--brand); - background: rgba(88, 119, 146, 0.14); -} - -.badge-danger { - color: var(--danger); - background: rgba(196, 74, 94, 0.12); -} - -.badge-muted { - color: var(--ink-soft); - background: var(--bg-2); -} - -.chat-header-actions { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.chat-user-label { - font-size: 0.78rem; - font-weight: 600; - color: var(--ink-soft); - max-width: 10rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.auth-modal { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; -} - -.auth-modal[hidden] { - display: none; -} - -.auth-modal-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.6); -} - -.auth-modal-content { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - gap: 0.5rem; - width: min(20rem, calc(100vw - 2rem)); - padding: 1.25rem; - border: 1px solid var(--bg-2); - border-radius: 0.5rem; - background: var(--panel-2); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); -} - -.auth-close { - position: absolute; - top: 0.5rem; - right: 0.5rem; - padding: 0.25rem 0.5rem; - font-size: 1.2rem; - line-height: 1; -} - -.auth-modal-title { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--ink-0); -} - -.typing-indicator { - font-size: 0.78rem; - font-style: italic; - color: var(--ink-soft); - padding: 0.25rem 0.75rem 0; -} - -.chat-flash { - position: absolute; - top: 0.5rem; - left: 0.5rem; - right: 0.5rem; - z-index: 5; - padding: 0.4rem 0.75rem; - font-size: 0.83rem; - text-align: center; - color: var(--ink-0); - background: color-mix(in srgb, var(--brand) 30%, var(--panel-2)); - border: 1px solid var(--line); - border-radius: var(--radius-1); - opacity: 1; - transition: opacity 0.5s ease; - pointer-events: none; -} - -.chat-flash.fade-out { - opacity: 0; -} - -.btn { - border: 1px solid transparent; - border-radius: var(--radius-1); - padding: 0.2rem 0.5rem; - font-weight: 650; - cursor: pointer; - transition: - transform var(--fast), - background-color var(--fast), - color var(--fast), - border-color var(--fast); -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.btn-primary { - background: var(--brand); - color: var(--brand-contrast); - border-color: var(--brand); -} - -.btn-primary:hover { - background: var(--brand-strong); - border-color: var(--brand-strong); -} - -.btn-secondary { - background: var(--panel); - color: var(--ink-1); - border-color: var(--line); -} - -.btn-secondary:hover { - background: var(--panel-2); -} - -.btn-danger { - background: var(--danger-soft); - color: var(--danger); - border-color: color-mix(in srgb, var(--danger) 45%, transparent); -} - -.btn-danger:hover { - background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger)); -} - -.btn-block { - width: 100%; -} - -.btn-large { - padding: 0.85rem 1.2rem; -} - -.btn-small { - padding: 0.3rem 0.58rem; - font-size: 0.85rem; -} - -.text-input { - width: 100%; - border: 1px solid var(--line); - border-radius: var(--radius-1); - background: var(--input-bg); - color: var(--ink-0); - padding: 0.65rem 0.8rem; -} - -.text-input::placeholder { - color: var(--placeholder); -} - -.text-area { - min-height: 160px; - resize: vertical; -} - -.form-stack { - display: grid; - gap: var(--space-3); -} - -.form-stack label { - display: grid; - gap: var(--space-2); - font-weight: 600; - color: var(--ink-1); -} - -.chat-log-filters input { - padding: 0.2rem 0.5rem; -} - -.table-wrap { - width: 100%; - overflow-x: auto; -} - -.data-table { - width: 100%; - border-collapse: collapse; - min-width: 520px; -} - -.data-table th, -.data-table td { - text-align: left; - padding: 0.72rem 0.65rem; - border-bottom: 1px solid var(--line-soft); - vertical-align: top; -} - -.data-table th { - color: var(--ink-soft); - font-size: 0.83rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -#chat-log-table-body td { - font-size: 0.72rem; - color: var(--ink-0); -} - -.action-cell { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.kv-list { - margin: 0; - display: grid; - gap: var(--space-3); -} - -.kv-list div { - display: grid; - gap: var(--space-1); -} - -.kv-list dt { - font-size: 0.88rem; - color: var(--ink-soft); -} - -.kv-list dd { - margin: 0; - font-weight: 700; -} - -.lv-body { - min-height: 100vh; - width: 100%; - height: 100vh; - padding: 0; - overflow: hidden; -} - -.viewer-shell { - width: 100%; - height: 100vh; - margin: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - background: linear-gradient( - 170deg, - color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%, - var(--panel) 45%, - color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100% - ); - border: 1px solid var(--line); - border-radius: 0; - box-shadow: var(--shadow-2); - overflow: hidden; -} - -.viewer-content { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); - min-height: 0; - height: 100%; -} - -.viewer-header { - padding: var(--space-3) var(--space-4); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - border-bottom: 1px solid var(--line); -} - -.viewer-brand-group { - display: flex; - align-items: center; - gap: var(--space-4); -} - -.brand-badge { - width: 2.5rem; - height: 2.5rem; - border-radius: 0; - display: grid; - place-items: center; - font-weight: 800; - font-size: 0.95rem; - letter-spacing: 0.05em; - color: var(--brand-contrast); - background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%); -} - -.brand-text, -.viewer-title { - font-family: var(--font-heading); -} - -.viewer-title { - font-size: clamp(1.35rem, 2.2vw, 2rem); -} - -.viewer-subtitle { - color: var(--ink-soft); - font-size: 0.95rem; -} - -.now-playing-pill { - display: inline-flex; - align-items: center; - gap: var(--space-2); - border-radius: 0; - background: var(--panel); - border: 1px solid var(--line); - color: var(--ink-1); - padding: 0.35rem 0.8rem; -} - -.live-dot, -.chat-live-marker { - color: var(--live-dot); - font-weight: 800; -} - -.player-stage { - min-height: 0; - height: 100%; - padding: var(--space-4); - display: grid; -} - -.player { - width: 100%; - height: 100%; - border-radius: 0; - border: 1px solid var(--line-soft); - background: #000; - box-shadow: var(--shadow-1); - object-fit: contain; -} - -.now-playing-overlay { - padding: 0.5rem 0.75rem; - background: var(--panel); - border: 1px solid var(--line-soft); - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8rem; - color: var(--ink-0); -} - -.np-next { - color: var(--ink-soft); -} - -.now-playing-overlay { - position: absolute; - right: calc(var(--space-5) + 0.7rem); - bottom: calc(var(--space-5) + 0.7rem); -} - -.now-playing-overlay-inner { - display: flex; - align-items: center; - gap: var(--space-2); - background: var(--overlay-bg); - color: #f0fffb; - padding: 0.44rem 0.72rem; - border-radius: 0; - backdrop-filter: blur(3px); -} - -.live-label { - background: var(--live-dot); - color: #fff; - border-radius: 0; - padding: 0.1rem 0.45rem; - font-size: 0.72rem; - font-weight: 700; -} - -.overlay-title { - font-size: 0.86rem; -} - -.chat-toggle-btn { - white-space: nowrap; -} - -.chat-sidebar { - background: color-mix(in srgb, var(--panel) 82%, var(--bg-1)); - border-left: 1px solid var(--line); - display: grid; - grid-template-rows: auto 1fr auto; - min-height: 0; - position: relative; -} - -.chat-header, -.chat-input-area { - padding: var(--space-4); -} - -.chat-header { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--line-soft); - gap: var(--space-3); -} - -.chat-room-title { - font-weight: 700; - display: inline-flex; - gap: var(--space-2); - align-items: center; -} - -.chat-messages { - padding: var(--space-4); - overflow-y: auto; - display: grid; - align-content: start; - gap: var(--space-2); -} - -.presence-panel { - padding: 0.5rem 1rem; - border-top: 1px solid var(--line-soft); - max-height: 120px; - overflow-y: auto; -} - -.presence-list { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; -} - -.presence-dot { - display: inline-flex; - align-items: center; - gap: 0.3rem; - font-size: 0.75rem; - color: var(--ink-1); -} - -.presence-indicator { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--ok); - flex-shrink: 0; -} - -.message-row { - display: flex; -} - -.message-self-row { - justify-content: flex-end; -} - -.message-other-row { - justify-content: flex-start; -} - -.message-system-row { - justify-content: center; -} - -.message-bubble { - max-width: 83%; - border-radius: 0; - padding: 0.58rem 0.72rem; - box-shadow: var(--shadow-1); - animation: messageIn var(--fast); -} - -.message-other { - background: var(--panel); - border: 1px solid var(--line-soft); -} - -.message-self { - background: var(--brand); - color: var(--brand-contrast); -} - -.message-system { - font-size: 0.7rem; - color: var(--ink-1); - background: color-mix(in srgb, var(--brand) 22%, var(--panel-2)); - border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line)); - border-radius: 0; - padding: 0.2rem 0.5rem; - animation: messageIn var(--fast); -} - -.message-sender { - font-size: 0.75rem; - opacity: 0.85; - margin-bottom: 0.1rem; -} - -.message-text { - word-break: break-word; - font-size: 0.95rem; -} - -.message-time { - display: block; - margin-top: 0.2rem; - font-size: 0.7rem; - opacity: 0.75; -} - -.chat-input-row { - display: grid; - grid-template-columns: 1fr auto; - gap: var(--space-2); -} - -.chat-note { - margin-top: var(--space-2); - color: var(--ink-soft); - font-size: 0.85rem; -} - -.input { - width: 100%; - border: 1px solid var(--input-border); - border-radius: var(--radius-1); - padding: 0.65rem 0.8rem; - background: var(--input-bg); - color: var(--ink-0); -} - -.admin-body { - min-height: 100vh; - padding: var(--space-4); - background: #000000; -} - -.admin-shell { - width: min(1320px, 100%); - margin: 0 auto; - display: grid; - grid-template-columns: 260px minmax(0, 1fr); - border-radius: var(--radius-3); - overflow: hidden; - border: 1px solid var(--line); - background: color-mix(in srgb, var(--panel) 88%, var(--bg-1)); - box-shadow: var(--shadow-2); - min-height: calc(100vh - 2 * var(--space-4)); -} - -.admin-sidebar { - background: var(--bg-2); - color: var(--ink-0); - display: grid; - grid-template-rows: auto auto 1fr; - gap: var(--space-4); - padding: var(--space-5); -} - -.brand-block { - display: flex; - align-items: center; - gap: var(--space-3); -} - -.brand-text { - font-weight: 700; - letter-spacing: 0.03em; -} - -.admin-nav { - display: grid; - gap: var(--space-2); - align-content: start; -} - -.tab-trigger { - width: 100%; - text-align: left; - background: transparent; - color: var(--ink-1); - border: 1px solid transparent; - border-radius: var(--radius-1); - padding: 0.62rem 0.72rem; - cursor: pointer; - transition: - background-color var(--fast), - border-color var(--fast); -} - -.tab-trigger:hover { - background: rgba(255, 255, 255, 0.08); -} - -.tab-trigger.is-active { - background: var(--brand); - color: var(--brand-contrast); - border-color: color-mix(in srgb, var(--brand) 55%, #fff); -} - -.admin-main { - padding: var(--space-6); - overflow: auto; -} - -.viewer-shell.chat-collapsed .viewer-content { - grid-template-columns: 1fr; -} - -.viewer-shell.chat-collapsed .chat-sidebar { - display: none; -} - -.tab-panel { - display: grid; - gap: var(--space-4); -} - -.tab-panel[hidden] { - display: none; -} - -.panel-header-row { - display: flex; - justify-content: space-between; - align-items: end; - gap: var(--space-4); - flex-wrap: wrap; -} - -.panel-card { - border: 1px solid var(--line-soft); - border-radius: var(--radius-2); - background: var(--panel); - padding: var(--space-2); - box-shadow: var(--shadow-1); -} - -.button-row { - display: flex; - gap: var(--space-2); - flex-wrap: wrap; -} - -.upload-dropzone { - border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line)); - border-radius: var(--radius-2); - padding: var(--space-5); - background: color-mix(in srgb, var(--brand) 12%, var(--panel-2)); - display: grid; - justify-items: start; - gap: var(--space-2); -} - -.upload-dropzone.is-dragover { - border-color: var(--brand); - background: color-mix(in srgb, var(--brand) 20%, var(--panel-2)); -} - -.upload-progress { - display: grid; - gap: var(--space-1); - width: min(540px, 100%); -} - -.upload-progress progress { - width: 100%; - height: 0.75rem; -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--space-3); -} - -.stat-card { - border: 1px solid var(--line-soft); - border-radius: var(--radius-2); - background: var(--panel); - padding: var(--space-4); - box-shadow: var(--shadow-1); -} - -.stat-value { - margin-top: var(--space-2); - font-size: clamp(1.35rem, 2.8vw, 2rem); - font-family: var(--font-mono); -} - -.stat-caption { - margin-top: var(--space-1); - color: var(--ink-soft); -} - -.moderation-grid { - display: grid; - grid-template-columns: minmax(300px, 1fr) minmax(0, 1.5fr); - gap: var(--space-4); -} - -.split-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4); -} - -.service-list { - display: grid; - gap: var(--space-2); -} - -.service-row { - border: 1px solid var(--line-soft); - border-radius: var(--radius-1); - padding: 0.75rem; - display: flex; - justify-content: space-between; - gap: var(--space-3); - flex-wrap: wrap; -} - -.service-row-right { - text-align: right; -} - -.service-detail-table { - width: 100%; - border-collapse: collapse; - text-align: left; -} - -.service-status { - margin: 0; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.service-status-healthy { - color: var(--ok); -} - -.service-status-degraded { - color: var(--warn); -} - -.service-status-unhealthy { - color: var(--danger); -} - -.auth-body { - min-height: 100vh; - display: grid; - place-items: center; - padding: var(--space-4); -} - -.auth-shell { - width: min(460px, 100%); -} - -.auth-card { - border: 1px solid var(--line); - border-radius: var(--radius-3); - background: var(--panel); - box-shadow: var(--shadow-2); - padding: var(--space-6); - display: grid; - gap: var(--space-3); -} - -.auth-kicker { - font-family: var(--font-display); - color: var(--brand); - text-transform: uppercase; - letter-spacing: 0.2em; - font-size: 0.72rem; -} - -.auth-subtitle { - color: var(--ink-soft); -} - -@keyframes messageIn { - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 1080px) { - .admin-shell { - grid-template-columns: 1fr; - } - - .viewer-content { - grid-template-columns: 1fr; - grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr); - } - - .chat-sidebar { - border-left: 0; - border-top: 1px solid var(--line); - } - - .admin-sidebar { - grid-template-rows: auto; - } - - .admin-nav { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .admin-main { - padding: var(--space-5); - } - - .moderation-grid, - .split-grid, - .stat-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .service-row-right { - text-align: left; - } -} - -@media (max-width: 760px) { - .lv-body, - .admin-body { - padding: var(--space-2); - } - - .viewer-header, - .player-stage, - .chat-header, - .chat-input-area { - padding: var(--space-3); - } - - .viewer-header { - align-items: flex-start; - flex-direction: column; - } - - .admin-nav { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .admin-main { - padding: var(--space-3); - } - - .moderation-grid, - .split-grid, - .stat-grid, - .chat-input-row { - grid-template-columns: 1fr; - } - - .viewer-shell.chat-collapsed .viewer-content { - grid-template-rows: 1fr; - } - - .auth-card { - padding: var(--space-5); - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 1ms !important; - animation-iteration-count: 1 !important; - transition-duration: 1ms !important; - scroll-behavior: auto !important; - } -} - -/* Reconnecting overlay (player error recovery) */ -.reconnecting-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.85); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-4, 1rem); - z-index: 1000; - backdrop-filter: blur(8px); -} - -.reconnecting-overlay.hidden { - display: none; -} - -.reconnecting-spinner { - width: 48px; - height: 48px; - border: 3px solid var(--line, #2a2a2a); - border-top-color: var(--brand, #587792); - border-radius: 50%; - animation: lv-spin 0.8s linear infinite; -} - -@keyframes lv-spin { - to { - transform: rotate(360deg); - } -} - -#reconnecting-text { - color: var(--ink-0, #ede8e6); - font-family: var(--font-body); - font-size: 1.125rem; -} +:root { + color-scheme: dark; + + --font-body: "Tektur", "Segoe UI", "Helvetica Neue", sans-serif; + --font-heading: "Doto", "Segoe UI", "Helvetica Neue", sans-serif; + --font-display: "Space Grotesk", "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", Consolas, monospace; + + --bg-0: #000000; + --bg-1: #0a0a0a; + --bg-2: #000000; + --ink-0: #ede8e6; + --ink-1: #b0a8a4; + --ink-soft: #78706c; + + --panel: #111111; + --panel-2: #1a1a1a; + --line: #2a2a2a; + --line-soft: #1f1f1f; + + --brand: #587792; + --brand-strong: #4a6780; + --brand-contrast: #f4f8fb; + --ok: #04650d; + --warn: #d49624; + --danger: #c44a5e; + --danger-soft: #1f1216; + + --focus: #2ebfa5; + --shadow-1: 0 14px 30px rgba(0, 0, 0, 0.5); + --shadow-2: 0 22px 44px rgba(0, 0, 0, 0.6); + + --bg-grad-a: #000000; + --bg-grad-b: #000000; + --bg-grad-c: #000000; + + --live-dot: #ff6b6b; + --overlay-bg: rgba(0, 0, 0, 0.88); + + --input-bg: #0d0d0d; + --input-border: var(--line-soft); + --placeholder: #6a6260; + + --badge-start: #2ebfa5; + --badge-end: #1a8c78; + + --radius-1: 0; + --radius-2: 0; + --radius-3: 0; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 3rem; + + --fast: 160ms ease; +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + + --bg-0: #e8e8e8; + --bg-1: #dcdcdc; + --bg-2: #f0f0f0; + --ink-0: #0d0d0d; + --ink-1: #2a2a2a; + --ink-soft: #555555; + + --panel: #ffffff; + --panel-2: #f5f5f5; + --line: #cccccc; + --line-soft: #dddddd; + + --brand: #587792; + --brand-strong: #4a6780; + --brand-contrast: #f4f8fb; + --ok: #04650d; + --warn: #a86200; + --danger: #a7283f; + --danger-soft: #fff3f6; + + --focus: #2ebfa5; + --shadow-1: 0 8px 20px rgba(0, 0, 0, 0.08); + --shadow-2: 0 16px 36px rgba(0, 0, 0, 0.12); + + --bg-grad-a: #e8e8e8; + --bg-grad-b: var(--bg-0); + --bg-grad-c: #f0f0f0; + + --live-dot: #cb271a; + --overlay-bg: rgba(0, 0, 0, 0.88); + + --input-bg: #ffffff; + --input-border: #cccccc; + --placeholder: #888888; + + --badge-start: #2ebfa5; + --badge-end: #1a8c78; + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + min-height: 100%; +} + +body { + font-family: var(--font-body); + color: var(--ink-0); + background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%); + line-height: 1.45; +} + +h1, +h2, +h3 { + margin: 0; + font-family: var(--font-heading); + color: var(--ink-0); + letter-spacing: -0.02em; +} + +p { + margin: 0; +} + +a { + color: var(--brand-strong); +} + +a:hover { + color: var(--brand); +} + +button, +input, +textarea, +select { + font: inherit; +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.hidden { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.small-muted { + color: var(--ink-soft); + font-size: 0.9rem; +} + +.status-line { + margin-top: var(--space-2); + color: var(--ink-soft); + min-height: 1.4rem; +} + +.status-error { + color: var(--danger); +} + +.sortable-list { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--line-soft); + border-radius: 4px; +} + +.sortable-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--line-soft); + cursor: grab; + transition: background 0.15s; +} + +.sortable-item:last-child { + border-bottom: none; +} +.sortable-item:hover { + background: var(--panel-2); +} +.sortable-item.dragging { + opacity: 0.4; +} + +.drag-handle { + color: var(--ink-soft); + font-size: 0.8rem; + letter-spacing: 1px; + cursor: grab; + user-select: none; +} + +.sortable-index { + font-size: 0.7rem; + color: var(--ink-soft); + min-width: 1.5rem; + text-align: right; +} + +.sortable-path { + flex: 1; + font-size: 0.8rem; + color: var(--ink-0); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-warnings { + background: rgba(212, 150, 36, 0.1); + border: 1px solid var(--warn); + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + border-radius: 4px; +} + +.warning-msg { + font-size: 0.8rem; + color: var(--warn); + line-height: 1.5; +} + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +} + +.badge-ok { + color: var(--ok); + background: rgba(4, 101, 13, 0.12); +} + +.badge-warn { + color: var(--warn); + background: rgba(212, 150, 36, 0.12); +} + +.badge-info { + color: var(--brand); + background: rgba(88, 119, 146, 0.14); +} + +.badge-danger { + color: var(--danger); + background: rgba(196, 74, 94, 0.12); +} + +.badge-muted { + color: var(--ink-soft); + background: var(--bg-2); +} + +.chat-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chat-user-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--ink-soft); + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auth-modal { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-modal[hidden] { + display: none; +} + +.auth-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); +} + +.auth-modal-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + width: min(20rem, calc(100vw - 2rem)); + padding: 1.25rem; + border: 1px solid var(--bg-2); + border-radius: 0.5rem; + background: var(--panel-2); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); +} + +.auth-close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.25rem 0.5rem; + font-size: 1.2rem; + line-height: 1; +} + +.auth-modal-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--ink-0); +} + +.typing-indicator { + font-size: 0.78rem; + font-style: italic; + color: var(--ink-soft); + padding: 0.25rem 0.75rem 0; +} + +.chat-flash { + position: absolute; + top: 0.5rem; + left: 0.5rem; + right: 0.5rem; + z-index: 5; + padding: 0.4rem 0.75rem; + font-size: 0.83rem; + text-align: center; + color: var(--ink-0); + background: color-mix(in srgb, var(--brand) 30%, var(--panel-2)); + border: 1px solid var(--line); + border-radius: var(--radius-1); + opacity: 1; + transition: opacity 0.5s ease; + pointer-events: none; +} + +.chat-flash.fade-out { + opacity: 0; +} + +.btn { + border: 1px solid transparent; + border-radius: var(--radius-1); + padding: 0.2rem 0.5rem; + font-weight: 650; + cursor: pointer; + transition: + transform var(--fast), + background-color var(--fast), + color var(--fast), + border-color var(--fast); +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--brand); + color: var(--brand-contrast); + border-color: var(--brand); +} + +.btn-primary:hover { + background: var(--brand-strong); + border-color: var(--brand-strong); +} + +.btn-secondary { + background: var(--panel); + color: var(--ink-1); + border-color: var(--line); +} + +.btn-secondary:hover { + background: var(--panel-2); +} + +.btn-danger { + background: var(--danger-soft); + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 45%, transparent); +} + +.btn-danger:hover { + background: color-mix(in srgb, var(--danger-soft) 70%, var(--danger)); +} + +.btn-block { + width: 100%; +} + +.btn-large { + padding: 0.85rem 1.2rem; +} + +.btn-small { + padding: 0.3rem 0.58rem; + font-size: 0.85rem; +} + +.text-input { + width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius-1); + background: var(--input-bg); + color: var(--ink-0); + padding: 0.65rem 0.8rem; +} + +.text-input::placeholder { + color: var(--placeholder); +} + +.text-area { + min-height: 160px; + resize: vertical; +} + +.form-stack { + display: grid; + gap: var(--space-3); +} + +.form-stack label { + display: grid; + gap: var(--space-2); + font-weight: 600; + color: var(--ink-1); +} + +.chat-log-filters input { + padding: 0.2rem 0.5rem; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 520px; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.72rem 0.65rem; + border-bottom: 1px solid var(--line-soft); + vertical-align: top; +} + +.data-table th { + color: var(--ink-soft); + font-size: 0.83rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#chat-log-table-body td { + font-size: 0.72rem; + color: var(--ink-0); +} + +.action-cell { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.kv-list { + margin: 0; + display: grid; + gap: var(--space-3); +} + +.kv-list div { + display: grid; + gap: var(--space-1); +} + +.kv-list dt { + font-size: 0.88rem; + color: var(--ink-soft); +} + +.kv-list dd { + margin: 0; + font-weight: 700; +} + +.lv-body { + min-height: 100vh; + width: 100%; + height: 100vh; + padding: 0; + overflow: hidden; +} + +.viewer-shell { + width: 100%; + height: 100vh; + margin: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: linear-gradient( + 170deg, + color-mix(in srgb, var(--panel-2) 72%, var(--panel)) 0%, + var(--panel) 45%, + color-mix(in srgb, var(--panel) 75%, var(--bg-1)) 100% + ); + border: 1px solid var(--line); + border-radius: 0; + box-shadow: var(--shadow-2); + overflow: hidden; +} + +.viewer-content { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); + min-height: 0; + height: 100%; +} + +.viewer-header { + padding: var(--space-3) var(--space-4); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + border-bottom: 1px solid var(--line); +} + +.viewer-brand-group { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.brand-badge { + width: 2.5rem; + height: 2.5rem; + border-radius: 0; + display: grid; + place-items: center; + font-weight: 800; + font-size: 0.95rem; + letter-spacing: 0.05em; + color: var(--brand-contrast); + background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%); +} + +.brand-text, +.viewer-title { + font-family: var(--font-heading); +} + +.viewer-title { + font-size: clamp(1.35rem, 2.2vw, 2rem); +} + +.viewer-subtitle { + color: var(--ink-soft); + font-size: 0.95rem; +} + +.now-playing-pill { + display: inline-flex; + align-items: center; + gap: var(--space-2); + border-radius: 0; + background: var(--panel); + border: 1px solid var(--line); + color: var(--ink-1); + padding: 0.35rem 0.8rem; +} + +.live-dot, +.chat-live-marker { + color: var(--live-dot); + font-weight: 800; +} + +.player-stage { + min-height: 0; + height: 100%; + padding: var(--space-4); + display: grid; +} + +.player { + width: 100%; + height: 100%; + border-radius: 0; + border: 1px solid var(--line-soft); + background: #000; + box-shadow: var(--shadow-1); + object-fit: contain; +} + +.now-playing-overlay { + padding: 0.5rem 0.75rem; + background: var(--panel); + border: 1px solid var(--line-soft); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; + color: var(--ink-0); +} + +.np-next { + color: var(--ink-soft); +} + +.now-playing-overlay { + position: absolute; + right: calc(var(--space-5) + 0.7rem); + bottom: calc(var(--space-5) + 0.7rem); +} + +.now-playing-overlay-inner { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--overlay-bg); + color: #f0fffb; + padding: 0.44rem 0.72rem; + border-radius: 0; + backdrop-filter: blur(3px); +} + +.live-label { + background: var(--live-dot); + color: #fff; + border-radius: 0; + padding: 0.1rem 0.45rem; + font-size: 0.72rem; + font-weight: 700; +} + +.overlay-title { + font-size: 0.86rem; +} + +.chat-toggle-btn { + white-space: nowrap; +} + +.chat-sidebar { + background: color-mix(in srgb, var(--panel) 82%, var(--bg-1)); + border-left: 1px solid var(--line); + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 0; + position: relative; +} + +.chat-header, +.chat-input-area { + padding: var(--space-4); +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line-soft); + gap: var(--space-3); +} + +.chat-room-title { + font-weight: 700; + display: inline-flex; + gap: var(--space-2); + align-items: center; +} + +.chat-messages { + padding: var(--space-4); + overflow-y: auto; + display: grid; + align-content: start; + gap: var(--space-2); +} + +.presence-panel { + padding: 0.5rem 1rem; + border-top: 1px solid var(--line-soft); + max-height: 120px; + overflow-y: auto; +} + +.presence-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.presence-dot { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + color: var(--ink-1); +} + +.presence-indicator { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ok); + flex-shrink: 0; +} + +.message-row { + display: flex; +} + +.message-self-row { + justify-content: flex-end; +} + +.message-other-row { + justify-content: flex-start; +} + +.message-system-row { + justify-content: center; +} + +.message-bubble { + max-width: 83%; + border-radius: 0; + padding: 0.58rem 0.72rem; + box-shadow: var(--shadow-1); + animation: messageIn var(--fast); +} + +.message-other { + background: var(--panel); + border: 1px solid var(--line-soft); +} + +.message-self { + background: var(--brand); + color: var(--brand-contrast); +} + +.message-system { + font-size: 0.7rem; + color: var(--ink-1); + background: color-mix(in srgb, var(--brand) 22%, var(--panel-2)); + border: 1px solid color-mix(in srgb, var(--brand) 45%, var(--line)); + border-radius: 0; + padding: 0.2rem 0.5rem; + animation: messageIn var(--fast); +} + +.message-sender { + font-size: 0.75rem; + opacity: 0.85; + margin-bottom: 0.1rem; +} + +.message-text { + word-break: break-word; + font-size: 0.95rem; +} + +.message-time { + display: block; + margin-top: 0.2rem; + font-size: 0.7rem; + opacity: 0.75; +} + +.chat-input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-2); +} + +.chat-note { + margin-top: var(--space-2); + color: var(--ink-soft); + font-size: 0.85rem; +} + +.input { + width: 100%; + border: 1px solid var(--input-border); + border-radius: var(--radius-1); + padding: 0.65rem 0.8rem; + background: var(--input-bg); + color: var(--ink-0); +} + +.admin-body { + min-height: 100vh; + padding: var(--space-4); + background: #000000; +} + +.admin-shell { + width: min(1320px, 100%); + margin: 0 auto; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + border-radius: var(--radius-3); + overflow: hidden; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--panel) 88%, var(--bg-1)); + box-shadow: var(--shadow-2); + min-height: calc(100vh - 2 * var(--space-4)); +} + +.admin-sidebar { + background: var(--bg-2); + color: var(--ink-0); + display: grid; + grid-template-rows: auto auto 1fr; + gap: var(--space-4); + padding: var(--space-5); +} + +.brand-block { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.brand-text { + font-weight: 700; + letter-spacing: 0.03em; +} + +.admin-nav { + display: grid; + gap: var(--space-2); + align-content: start; +} + +.tab-trigger { + width: 100%; + text-align: left; + background: transparent; + color: var(--ink-1); + border: 1px solid transparent; + border-radius: var(--radius-1); + padding: 0.62rem 0.72rem; + cursor: pointer; + transition: + background-color var(--fast), + border-color var(--fast); +} + +.tab-trigger:hover { + background: rgba(255, 255, 255, 0.08); +} + +.tab-trigger.is-active { + background: var(--brand); + color: var(--brand-contrast); + border-color: color-mix(in srgb, var(--brand) 55%, #fff); +} + +.admin-main { + padding: var(--space-6); + overflow: auto; +} + +.viewer-shell.chat-collapsed .viewer-content { + grid-template-columns: 1fr; +} + +.viewer-shell.chat-collapsed .chat-sidebar { + display: none; +} + +.tab-panel { + display: grid; + gap: var(--space-4); +} + +.tab-panel[hidden] { + display: none; +} + +.panel-header-row { + display: flex; + justify-content: space-between; + align-items: end; + gap: var(--space-4); + flex-wrap: wrap; +} + +.panel-card { + border: 1px solid var(--line-soft); + border-radius: var(--radius-2); + background: var(--panel); + padding: var(--space-2); + box-shadow: var(--shadow-1); +} + +.button-row { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.upload-dropzone { + border: 2px dashed color-mix(in srgb, var(--brand) 55%, var(--line)); + border-radius: var(--radius-2); + padding: var(--space-5); + background: color-mix(in srgb, var(--brand) 12%, var(--panel-2)); + display: grid; + justify-items: start; + gap: var(--space-2); +} + +.upload-dropzone.is-dragover { + border-color: var(--brand); + background: color-mix(in srgb, var(--brand) 20%, var(--panel-2)); +} + +.upload-progress { + display: grid; + gap: var(--space-1); + width: min(540px, 100%); +} + +.upload-progress progress { + width: 100%; + height: 0.75rem; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); +} + +.stat-card { + border: 1px solid var(--line-soft); + border-radius: var(--radius-2); + background: var(--panel); + padding: var(--space-4); + box-shadow: var(--shadow-1); +} + +.stat-value { + margin-top: var(--space-2); + font-size: clamp(1.35rem, 2.8vw, 2rem); + font-family: var(--font-mono); +} + +.stat-caption { + margin-top: var(--space-1); + color: var(--ink-soft); +} + +.moderation-grid { + display: grid; + grid-template-columns: minmax(300px, 1fr) minmax(0, 1.5fr); + gap: var(--space-4); +} + +.split-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +.service-list { + display: grid; + gap: var(--space-2); +} + +.service-row { + border: 1px solid var(--line-soft); + border-radius: var(--radius-1); + padding: 0.75rem; + display: flex; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.service-row-right { + text-align: right; +} + +.service-detail-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.service-status { + margin: 0; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.service-status-healthy { + color: var(--ok); +} + +.service-status-degraded { + color: var(--warn); +} + +.service-status-unhealthy { + color: var(--danger); +} + +.auth-body { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-4); +} + +.auth-shell { + width: min(460px, 100%); +} + +.auth-card { + border: 1px solid var(--line); + border-radius: var(--radius-3); + background: var(--panel); + box-shadow: var(--shadow-2); + padding: var(--space-6); + display: grid; + gap: var(--space-3); +} + +.auth-kicker { + font-family: var(--font-display); + color: var(--brand); + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.72rem; +} + +.auth-subtitle { + color: var(--ink-soft); +} + +@keyframes messageIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1080px) { + .admin-shell { + grid-template-columns: 1fr; + } + + .viewer-content { + grid-template-columns: 1fr; + grid-template-rows: minmax(320px, 52vh) minmax(0, 1fr); + } + + .chat-sidebar { + border-left: 0; + border-top: 1px solid var(--line); + } + + .admin-sidebar { + grid-template-rows: auto; + } + + .admin-nav { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .admin-main { + padding: var(--space-5); + } + + .moderation-grid, + .split-grid, + .stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .service-row-right { + text-align: left; + } +} + +@media (max-width: 760px) { + .lv-body, + .admin-body { + padding: var(--space-2); + } + + .viewer-header, + .player-stage, + .chat-header, + .chat-input-area { + padding: var(--space-3); + } + + .viewer-header { + align-items: flex-start; + flex-direction: column; + } + + .admin-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-main { + padding: var(--space-3); + } + + .moderation-grid, + .split-grid, + .stat-grid, + .chat-input-row { + grid-template-columns: 1fr; + } + + .viewer-shell.chat-collapsed .viewer-content { + grid-template-rows: 1fr; + } + + .auth-card { + padding: var(--space-5); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + scroll-behavior: auto !important; + } +} + +/* Reconnecting overlay (player error recovery) */ +.reconnecting-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-4, 1rem); + z-index: 1000; + backdrop-filter: blur(8px); +} + +.reconnecting-overlay.hidden { + display: none; +} + +.reconnecting-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--line, #2a2a2a); + border-top-color: var(--brand, #587792); + border-radius: 50%; + animation: lv-spin 0.8s linear infinite; +} + +@keyframes lv-spin { + to { + transform: rotate(360deg); + } +} + +#reconnecting-text { + color: var(--ink-0, #ede8e6); + font-family: var(--font-body); + font-size: 1.125rem; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 783a0ee..d76351c 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,20 +1,20 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "jsdom", - include: ["src/**/*.test.ts"], - coverage: { - provider: "v8", - reporter: ["text", "text-summary"], - include: ["src/**/*.ts"], - exclude: ["src/**/*.test.ts"], - thresholds: { - statements: 20, - branches: 15, - functions: 15, - lines: 20, - }, - }, - }, -}); +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "text-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + thresholds: { + statements: 20, + branches: 15, + functions: 15, + lines: 20, + }, + }, + }, +}); diff --git a/frontend/wrangler.toml b/frontend/wrangler.toml index fdc4481..17a6a52 100644 --- a/frontend/wrangler.toml +++ b/frontend/wrangler.toml @@ -1,6 +1,6 @@ -name = "linearvoid-frontend" -compatibility_date = "2025-08-01" - -[assets] -directory = "./dist" -not_found_handling = "single-page-application" +name = "linearvoid-frontend" +compatibility_date = "2025-08-01" + +[assets] +directory = "./dist" +not_found_handling = "single-page-application" diff --git a/infrastructure/nginx/Dockerfile b/infrastructure/nginx/Dockerfile index 4d2776f..78dc01e 100644 --- a/infrastructure/nginx/Dockerfile +++ b/infrastructure/nginx/Dockerfile @@ -1,21 +1,21 @@ -# Stage 1: Build frontend -FROM node:20-alpine AS builder -WORKDIR /build -COPY frontend/package*.json ./ -RUN npm ci -COPY frontend/ . -RUN npm run build - -# Stage 2: Nginx with bundled frontend -FROM nginx:alpine - -COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf - -# Copy prebuilt frontend from builder stage -COPY --from=builder /build/dist/ /srv/frontend/ - -# Create HLS directory -RUN mkdir -p /srv/hls - -EXPOSE 80 +# Stage 1: Build frontend +FROM node:20-alpine AS builder +WORKDIR /build +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +# Stage 2: Nginx with bundled frontend +FROM nginx:alpine + +COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf + +# Copy prebuilt frontend from builder stage +COPY --from=builder /build/dist/ /srv/frontend/ + +# Create HLS directory +RUN mkdir -p /srv/hls + +EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 2ca415e..c14444c 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -1,285 +1,285 @@ -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - resolver 127.0.0.11 ipv6=off valid=30s; - - map_hash_bucket_size 128; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - map $http_origin $cors_origin { - default ""; - "https://linearvoid.tv" $http_origin; - "https://www.linearvoid.tv" $http_origin; - "https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin; - "~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin; - } - - server { - listen 80; - server_name www.linearvoid.tv; - - return 301 https://linearvoid.tv$request_uri; - } - - server { - listen 80 default_server; - server_name linearvoid.tv localhost 127.0.0.1; - - # Resumable admin upload chunks are 8MB by default; allow safe headroom. - client_max_body_size 64m; - - root /srv/frontend; - index index.html; - - location = /admin { - return 301 /admin/; - } - - location = /admin/login { - return 301 /admin/login/; - } - - location = /_admin_auth { - internal; - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream/api/admin/session; - proxy_http_version 1.1; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header Cookie $http_cookie; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - location /admin/login/ { - try_files $uri $uri/ /admin/login/index.html; - } - - location /admin/ { - auth_request /_admin_auth; - error_page 401 = @admin_login_redirect; - try_files $uri $uri/ /admin/index.html; - } - - location /assets/ { - try_files $uri =404; - } - - # Serve static frontend - location / { - try_files $uri $uri/ /index.html; - } - - # Proxy chat WebSocket - location /ws { - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - location = /api/admin/login { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin $cors_origin; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; - add_header Access-Control-Allow-Headers "Content-Type, Authorization"; - add_header Access-Control-Allow-Credentials true; - add_header Access-Control-Max-Age 86400; - return 204; - } - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Cookie $http_cookie; - add_header Access-Control-Allow-Origin $cors_origin always; - add_header Access-Control-Allow-Credentials true always; - } - - location = /api/admin/logout { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin $cors_origin; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; - add_header Access-Control-Allow-Headers "Content-Type, Authorization"; - add_header Access-Control-Allow-Credentials true; - add_header Access-Control-Max-Age 86400; - return 204; - } - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Cookie $http_cookie; - add_header Access-Control-Allow-Origin $cors_origin always; - add_header Access-Control-Allow-Credentials true always; - } - - location = /api/admin/session { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin $cors_origin; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; - add_header Access-Control-Allow-Headers "Content-Type, Authorization"; - add_header Access-Control-Allow-Credentials true; - add_header Access-Control-Max-Age 86400; - return 204; - } - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Cookie $http_cookie; - add_header Access-Control-Allow-Origin $cors_origin always; - add_header Access-Control-Allow-Credentials true always; - } - - location /api/admin/ { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin $cors_origin; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; - add_header Access-Control-Allow-Headers "Content-Type, Authorization"; - add_header Access-Control-Allow-Credentials true; - add_header Access-Control-Max-Age 86400; - return 204; - } - auth_request /_admin_auth; - error_page 401 = @admin_api_unauthorized; - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Cookie $http_cookie; - add_header Access-Control-Allow-Origin $cors_origin always; - add_header Access-Control-Allow-Credentials true always; - } - - location /api/ { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin $cors_origin; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; - add_header Access-Control-Allow-Headers "Content-Type, Authorization"; - add_header Access-Control-Allow-Credentials true; - add_header Access-Control-Max-Age 86400; - return 204; - } - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - add_header Access-Control-Allow-Origin $cors_origin always; - add_header Access-Control-Allow-Credentials true always; - } - - location = /metrics { - set $chat_upstream http://chat:3000; - proxy_pass $chat_upstream; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - location @admin_api_unauthorized { - add_header Content-Type application/json; - return 401 '{"error":"unauthorized"}'; - } - - location @admin_login_redirect { - return 302 /admin/login/; - } - - location = /hls/playlist.m3u8 { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin "*"; - add_header Access-Control-Allow-Methods "GET, OPTIONS"; - add_header Access-Control-Max-Age 86400; - return 204; - } - alias /srv/hls/playlist.m3u8; - add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; - add_header Access-Control-Allow-Origin "*"; - } - - location = /hls/init.mp4 { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin "*"; - add_header Access-Control-Allow-Methods "GET, OPTIONS"; - add_header Access-Control-Max-Age 86400; - return 204; - } - alias /srv/hls/init.mp4; - add_header Cache-Control "public, max-age=31536000, immutable"; - add_header Access-Control-Allow-Origin "*"; - } - - location ~* ^/hls/(.+\.(ts|m4s))$ { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin "*"; - add_header Access-Control-Allow-Methods "GET, OPTIONS"; - add_header Access-Control-Max-Age 86400; - return 204; - } - alias /srv/hls/$1; - add_header Cache-Control "public, max-age=31536000, immutable"; - add_header Access-Control-Allow-Origin "*"; - } - - # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN. - location /hls/ { - if ($request_method = OPTIONS) { - add_header Access-Control-Allow-Origin "*"; - add_header Access-Control-Allow-Methods "GET, OPTIONS"; - add_header Access-Control-Max-Age 86400; - return 204; - } - alias /srv/hls/; - add_header Cache-Control "public, max-age=60"; - add_header Access-Control-Allow-Origin "*"; - } - - # Health checks — aggregated from upstream services - location /health { - default_type application/json; - return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}'; - } - - location /nginx-status { - stub_status on; - allow 127.0.0.1; - allow 172.16.0.0/12; - allow 192.168.0.0/16; - deny all; - } - } +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + resolver 127.0.0.11 ipv6=off valid=30s; + + map_hash_bucket_size 128; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + map $http_origin $cors_origin { + default ""; + "https://linearvoid.tv" $http_origin; + "https://www.linearvoid.tv" $http_origin; + "https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin; + "~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin; + } + + server { + listen 80; + server_name www.linearvoid.tv; + + return 301 https://linearvoid.tv$request_uri; + } + + server { + listen 80 default_server; + server_name linearvoid.tv localhost 127.0.0.1; + + # Resumable admin upload chunks are 8MB by default; allow safe headroom. + client_max_body_size 64m; + + root /srv/frontend; + index index.html; + + location = /admin { + return 301 /admin/; + } + + location = /admin/login { + return 301 /admin/login/; + } + + location = /_admin_auth { + internal; + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream/api/admin/session; + proxy_http_version 1.1; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Cookie $http_cookie; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /admin/login/ { + try_files $uri $uri/ /admin/login/index.html; + } + + location /admin/ { + auth_request /_admin_auth; + error_page 401 = @admin_login_redirect; + try_files $uri $uri/ /admin/index.html; + } + + location /assets/ { + try_files $uri =404; + } + + # Serve static frontend + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy chat WebSocket + location /ws { + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /api/admin/login { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $cors_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + add_header Access-Control-Allow-Credentials true; + add_header Access-Control-Max-Age 86400; + return 204; + } + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Cookie $http_cookie; + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Credentials true always; + } + + location = /api/admin/logout { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $cors_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + add_header Access-Control-Allow-Credentials true; + add_header Access-Control-Max-Age 86400; + return 204; + } + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Cookie $http_cookie; + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Credentials true always; + } + + location = /api/admin/session { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $cors_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + add_header Access-Control-Allow-Credentials true; + add_header Access-Control-Max-Age 86400; + return 204; + } + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Cookie $http_cookie; + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Credentials true always; + } + + location /api/admin/ { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $cors_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + add_header Access-Control-Allow-Credentials true; + add_header Access-Control-Max-Age 86400; + return 204; + } + auth_request /_admin_auth; + error_page 401 = @admin_api_unauthorized; + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Cookie $http_cookie; + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Credentials true always; + } + + location /api/ { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $cors_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + add_header Access-Control-Allow-Credentials true; + add_header Access-Control-Max-Age 86400; + return 204; + } + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Access-Control-Allow-Origin $cors_origin always; + add_header Access-Control-Allow-Credentials true always; + } + + location = /metrics { + set $chat_upstream http://chat:3000; + proxy_pass $chat_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location @admin_api_unauthorized { + add_header Content-Type application/json; + return 401 '{"error":"unauthorized"}'; + } + + location @admin_login_redirect { + return 302 /admin/login/; + } + + location = /hls/playlist.m3u8 { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Max-Age 86400; + return 204; + } + alias /srv/hls/playlist.m3u8; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Access-Control-Allow-Origin "*"; + } + + location = /hls/init.mp4 { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Max-Age 86400; + return 204; + } + alias /srv/hls/init.mp4; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Access-Control-Allow-Origin "*"; + } + + location ~* ^/hls/(.+\.(ts|m4s))$ { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Max-Age 86400; + return 204; + } + alias /srv/hls/$1; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Access-Control-Allow-Origin "*"; + } + + # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN. + location /hls/ { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Max-Age 86400; + return 204; + } + alias /srv/hls/; + add_header Cache-Control "public, max-age=60"; + add_header Access-Control-Allow-Origin "*"; + } + + # Health checks — aggregated from upstream services + location /health { + default_type application/json; + return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}'; + } + + location /nginx-status { + stub_status on; + allow 127.0.0.1; + allow 172.16.0.0/12; + allow 192.168.0.0/16; + deny all; + } + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..23d9456 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1148 @@ +{ + "name": "linearvoid", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "vitest": "^4.1.11" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "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==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d75f656 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "vitest": "^4.1.11" + } +} diff --git a/scripts/cache-hit-ratio.js b/scripts/cache-hit-ratio.js index 1092ad9..0987921 100644 --- a/scripts/cache-hit-ratio.js +++ b/scripts/cache-hit-ratio.js @@ -1,106 +1,106 @@ -#!/usr/bin/env node - -const apiToken = process.env.CLOUDFLARE_API_TOKEN; -const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; -const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv"; -const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10); -const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql"; - -if (!apiToken || !zoneTag) { - console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG"); - process.exit(1); -} - -const end = new Date(); -const start = new Date(end.getTime() - windowMinutes * 60 * 1000); - -const iso = (value) => value.toISOString(); - -const query = ` - query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) { - viewer { - zones(filter: { zoneTag: $zoneTag }) { - requests: httpRequestsAdaptiveGroups( - limit: 1000 - filter: { - datetime_geq: $start - datetime_lt: $end - clientRequestHTTPHost: $hostname - requestSource: "eyeball" - } - ) { - count - dimensions { - cacheStatus - } - } - } - } - } -`; - -async function main() { - const response = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - query, - variables: { - zoneTag, - hostname, - start: iso(start), - end: iso(end), - }, - }), - }); - - const payload = await response.json(); - if (!response.ok || payload.errors) { - const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`; - throw new Error(message); - } - - const groups = payload?.data?.viewer?.zones?.[0]?.requests || []; - let totalRequests = 0; - let hitRequests = 0; - - for (const group of groups) { - const count = Number(group?.count || 0); - const status = String(group?.dimensions?.cacheStatus || "").toUpperCase(); - totalRequests += count; - if (status === "HIT") { - hitRequests += count; - } - } - - const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0; - - console.log( - `# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`, - ); - console.log(`# TYPE linearvoid_cache_hit_ratio gauge`); - console.log( - `linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`, - ); - console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`); - console.log(`# TYPE linearvoid_cache_hit_requests_total counter`); - console.log( - `linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`, - ); - console.log( - `# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`, - ); - console.log(`# TYPE linearvoid_cache_total_requests_total counter`); - console.log( - `linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`, - ); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); +#!/usr/bin/env node + +const apiToken = process.env.CLOUDFLARE_API_TOKEN; +const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; +const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv"; +const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10); +const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql"; + +if (!apiToken || !zoneTag) { + console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG"); + process.exit(1); +} + +const end = new Date(); +const start = new Date(end.getTime() - windowMinutes * 60 * 1000); + +const iso = (value) => value.toISOString(); + +const query = ` + query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + requests: httpRequestsAdaptiveGroups( + limit: 1000 + filter: { + datetime_geq: $start + datetime_lt: $end + clientRequestHTTPHost: $hostname + requestSource: "eyeball" + } + ) { + count + dimensions { + cacheStatus + } + } + } + } + } +`; + +async function main() { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + query, + variables: { + zoneTag, + hostname, + start: iso(start), + end: iso(end), + }, + }), + }); + + const payload = await response.json(); + if (!response.ok || payload.errors) { + const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`; + throw new Error(message); + } + + const groups = payload?.data?.viewer?.zones?.[0]?.requests || []; + let totalRequests = 0; + let hitRequests = 0; + + for (const group of groups) { + const count = Number(group?.count || 0); + const status = String(group?.dimensions?.cacheStatus || "").toUpperCase(); + totalRequests += count; + if (status === "HIT") { + hitRequests += count; + } + } + + const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0; + + console.log( + `# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`, + ); + console.log(`# TYPE linearvoid_cache_hit_ratio gauge`); + console.log( + `linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`, + ); + console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`); + console.log(`# TYPE linearvoid_cache_hit_requests_total counter`); + console.log( + `linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`, + ); + console.log( + `# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`, + ); + console.log(`# TYPE linearvoid_cache_total_requests_total counter`); + console.log( + `linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/cache-hit-ratio.ps1 b/scripts/cache-hit-ratio.ps1 index 0e9f7c2..5bc16d3 100644 --- a/scripts/cache-hit-ratio.ps1 +++ b/scripts/cache-hit-ratio.ps1 @@ -1,8 +1,8 @@ -#!/usr/bin/env pwsh -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js' - -& node $nodeScript @args -exit $LASTEXITCODE +#!/usr/bin/env pwsh +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js' + +& node $nodeScript @args +exit $LASTEXITCODE diff --git a/scripts/cache-hit-ratio.sh b/scripts/cache-hit-ratio.sh new file mode 100644 index 0000000..7ee12f0 --- /dev/null +++ b/scripts/cache-hit-ratio.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# cache-hit-ratio.sh — Thin wrapper that runs the Node.js cache-hit-ratio tool. +# Mirrors scripts/cache-hit-ratio.ps1. +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "$script_dir/cache-hit-ratio.js" "$@" diff --git a/scripts/check-env.sh b/scripts/check-env.sh new file mode 100644 index 0000000..5ba216a --- /dev/null +++ b/scripts/check-env.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# check-env.sh — Verify the deployment environment for Linear Void. +# Mirrors scripts/check-env.ps1. +# +# Checks, against the target Docker host (default test lab 192.168.88.40): +# 1. Docker Engine reachability over the TCP socket (port 2375). +# 2. Coolify helper stack (proxy/Traefik, sentinel, cloudflared). +# 3. Linear Void containers (playout, chat, nginx, redis, db, prometheus, grafana). +# 4. Public endpoints (app, HLS manifest, admin). +# 5. Health endpoints exposed by the services. +# +# Usage: +# ./scripts/check-env.sh +# ./scripts/check-env.sh -t 192.168.88.40 +# ./scripts/check-env.sh -t 192.168.88.40 -p 2375 --only-containers +# +# Exit code is 0 when all required checks pass, 1 otherwise. +set -uo pipefail + +TARGET=192.168.88.40 +DOCKER_PORT=2375 +ONLY_CONTAINERS=0 +ONLY_ENDPOINTS=0 +TIMEOUT=10 + +while [[ $# -gt 0 ]]; do + case "$1" in + -t|--target) TARGET="$2"; shift 2;; + -p|--docker-port) DOCKER_PORT="$2"; shift 2;; + --only-containers) ONLY_CONTAINERS=1; shift;; + --only-endpoints) ONLY_ENDPOINTS=1; shift;; + --timeout) TIMEOUT="$2"; shift 2;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done + +export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}" + +FAILED=0 +fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; } +ok() { printf ' [OK] %s\n' "$1"; } +warn() { printf ' [WARN] %s\n' "$1"; } + +echo "== Linear Void environment check ==" +echo "Target Docker host : $DOCKER_HOST" +echo "Date : $(date -Iseconds)" + +# --------------------------------------------------------------------------- +# 1. Docker Engine reachability +# --------------------------------------------------------------------------- +if [[ "$ONLY_ENDPOINTS" -eq 0 ]]; then + echo "" + echo "-- Docker Engine (${TARGET}:${DOCKER_PORT}) --" + if ver=$(docker version --format '{{.Server.Version}}' 2>&1) && [[ "$ver" =~ [0-9] ]]; then + ok "Docker Engine reachable (server version $ver)" + else + fail "Docker Engine not reachable: $ver" + fi + + # ------------------------------------------------------------------------- + # 2. Coolify helper stack + # ------------------------------------------------------------------------- + echo "" + echo "-- Coolify managed-server helpers --" + for c in coolify-proxy coolify-sentinel coolify-cloudflared; do + if state=$(docker inspect "$c" --format '{{.State.Status}} ({{.State.Health.Status}})' 2>/dev/null); then + if [[ "$state" =~ running.*(healthy|running) ]]; then ok "coolify helper: $c -> $state"; else warn "coolify helper: $c -> $state"; fi + else + warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)" + fi + done + + # ------------------------------------------------------------------------- + # 3. Linear Void containers + # ------------------------------------------------------------------------- + echo "" + echo "-- Linear Void containers --" + required=(linearvoid-playout linearvoid-chat linearvoid-nginx linearvoid-redis linearvoid-db linearvoid-prometheus linearvoid-grafana) + running="$(docker ps --format '{{.Names}}' 2>/dev/null)" + all="$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null)" + for name in "${required[@]}"; do + if grep -qx "$name" <<<"$running"; then + ok "container: $name is running" + elif grep -q "^$name[[:space:]]" <<<"$all"; then + status="$(grep "^$name[[:space:]]" <<<"$all" | head -1)" + fail "container: $name EXISTS but not running -> $status" + else + fail "container: $name MISSING" + fi + done +fi + +# --------------------------------------------------------------------------- +# 4. Health endpoints (via Docker host) +# --------------------------------------------------------------------------- +if [[ "$ONLY_CONTAINERS" -eq 0 ]]; then + echo "" + echo "-- Health endpoints --" + checks=( + "chat /health|http://${TARGET}:3000/health" + "nginx /health|http://${TARGET}:8081/health" + "prometheus|http://${TARGET}:9090/-/healthy" + "grafana /api/health|http://${TARGET}:3001/api/health" + ) + for entry in "${checks[@]}"; do + name="${entry%%|*}"; url="${entry#*|}" + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$url" 2>/dev/null) + if [[ "$code" == "200" ]]; then ok "$name -> 200"; elif [[ "$code" =~ ^[0-9]{3}$ ]]; then warn "$name -> $code"; else fail "$name -> unreachable"; fi + done + + # ------------------------------------------------------------------------- + # 5. Public endpoints (routed through the Docker host for reliable TLS) + # ------------------------------------------------------------------------- + echo "" + echo "-- Public endpoints (via Docker host) --" + pub=( + "linearvoid.tv app|https://linearvoid.tv/" + "media HLS manifest|https://media.linearvoid.tv/hls/playlist.m3u8" + "coolify dashboard|https://coolify.allucanget.biz/" + ) + for entry in "${pub[@]}"; do + name="${entry%%|*}"; url="${entry#*|}" + code=$(docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" -L "$url" 2>/dev/null | tr -d '[:space:]') + if [[ "$code" =~ ^[0-9]{3}$ ]]; then ok "$name -> HTTP $code"; else warn "$name -> $code"; fi + done +fi + +# --------------------------------------------------------------------------- +echo "" +if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi +echo "RESULT: OK" +exit 0 diff --git a/scripts/check-tokens.sh b/scripts/check-tokens.sh new file mode 100644 index 0000000..1c3ecf8 --- /dev/null +++ b/scripts/check-tokens.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# check-tokens.sh — Verify that the credentials in the environment actually work. +# Mirrors scripts/check-tokens.ps1. +# +# Reads .env (or a supplied file) and validates access against live services: +# 1. Gitea container registry (CI_REGISTRY_TOKEN / CI_REGISTRY_USER). +# 2. Cloudflare API token(s) (CLOUDFLARE_API_TOKEN, and any R2 admin token). +# 3. R2/S3 bucket access (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY) — lists buckets. +# 4. Linear Void admin login (ADMIN_USERNAME / ADMIN_PASSWORD) against the app origin. +# +# NOTE: Runs the checks through the target Docker host (default 192.168.88.40) so +# network + TLS work regardless of this machine's proxy/firewall. Uses the +# curlimages/curl and amazon/aws-cli images, which are pulled on demand. +# +# Usage: +# ./scripts/check-tokens.sh +# ./scripts/check-tokens.sh -e .env +# ./scripts/check-tokens.sh -t 192.168.88.40 +# +# Exit code 0 = all credentials valid; 1 = at least one check failed. +set -uo pipefail + +ENV_FILE="" +TARGET=192.168.88.40 +DOCKER_PORT=2375 +TIMEOUT=20 + +while [[ $# -gt 0 ]]; do + case "$1" in + -e|--env-file) ENV_FILE="$2"; shift 2;; + -t|--target) TARGET="$2"; shift 2;; + -p|--docker-port) DOCKER_PORT="$2"; shift 2;; + --timeout) TIMEOUT="$2"; shift 2;; + *) echo "Unknown option: $1" >&2; exit 2;; + esac +done + +export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}" + +FAILED=0 +fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; } +ok() { printf ' [OK] %s\n' "$1"; } +warn() { printf ' [WARN] %s\n' "$1"; } + +# Resolve env file: explicit, or $PWD/.env, or repo root. +if [[ -z "$ENV_FILE" ]]; then + for cand in "$PWD/.env" "$(dirname "$PWD")/.env"; do + if [[ -f "$cand" ]]; then ENV_FILE="$cand"; break; fi + done +fi +if [[ -z "$ENV_FILE" || ! -f "$ENV_FILE" ]]; then + echo "No .env file found. Pass one with -e." >&2 + exit 1 +fi + +# Parse KEY=VALUE lines into an associative array. +declare -A envs +while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then + envs["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}" + fi +done < "$ENV_FILE" + +get_val() { [[ -n "${envs[$1]+x}" ]] && echo "${envs[$1]}"; } + +# Pull helper images once. +echo "Preparing helper images (curl, aws-cli) on $TARGET..." +docker pull curlimages/curl:latest >/dev/null 2>&1 || true +docker pull amazon/aws-cli:latest >/dev/null 2>&1 || true + +# dc_curl URL [bearer] [token] — run curl via the curlimages/curl container. +dc_curl() { + local url="$1" bearer="${2:-}" token="${3:-}" + local -a args=(run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT") + [[ -n "$bearer" ]] && args+=( -H "Authorization: Bearer $bearer" ) + [[ -n "$token" ]] && args+=( -H "Authorization: token $token" ) + args+=( "$url" ) + docker "${args[@]}" 2>&1 | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + +# --------------------------------------------------------------------------- +echo "== Linear Void credential / token check ==" +echo "Env file : $ENV_FILE" +echo "Docker host: $DOCKER_HOST" + +# --- 1. Gitea registry token --- +echo "" +echo "-- Gitea container registry --" +reg_user="$(get_val CI_REGISTRY_USER)" +reg_token="$(get_val CI_REGISTRY_TOKEN)" +if [[ -n "$reg_user" && -n "$reg_token" ]]; then + # Non-sensitive probe: /api/v1/user returns a scope message for token-based creds. + out="$(dc_curl 'https://git.allucanget.biz/api/v1/user' "" "$reg_token")" + if [[ "$out" =~ token.does.not.have.at.least.one.of.required.scope || "$out" =~ '"login"' || "$out" =~ invalid_grant ]]; then + if [[ "$out" =~ invalid_grant|Unauthorized|invalid ]]; then + fail "Gitea registry token rejected ($reg_user): $out" + else + ok "Gitea registry token valid for '$reg_user' (scope: write:package/write:repository)" + fi + else + warn "Gitea registry token response unexpected: $out" + fi +else + warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check" +fi + +# --- 2. Cloudflare API token --- +echo "" +echo "-- Cloudflare API tokens --" +cf_token="$(get_val CLOUDFLARE_API_TOKEN)" +if [[ -n "$cf_token" ]]; then + out="$(dc_curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' "$cf_token")" + if [[ "$out" =~ '"success":true' ]]; then + if [[ "$out" =~ '"status":"active"' ]]; then ok 'Cloudflare API token valid and ACTIVE'; else warn "Cloudflare API token valid but status: $out"; fi + else + fail "Cloudflare API token rejected: $out" + fi +else + warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check" +fi + +# --- 3. Cloudflare R2 / S3 bucket access --- +echo "" +echo "-- Cloudflare R2 (S3-compatible) --" +r2_key="$(get_val R2_ACCESS_KEY_ID)" +r2_secret="$(get_val R2_SECRET_ACCESS_KEY)" +r2_ep="$(get_val R2_ENDPOINT)" +bucket="$(get_val R2_BUCKET_NAME)" + +# Use the admin key pair (which has ListBuckets) when present, else the regular key. +list_k="$(get_val R2_ADMIN_ACCESS_KEY_ID)"; list_s="$(get_val R2_ADMIN_SECRET_ACCESS_KEY)" +if [[ -z "$list_k" || -z "$list_s" ]]; then list_k="$r2_key"; list_s="$r2_secret"; fi + +if [[ -n "$list_k" && -n "$list_s" && -n "$r2_ep" ]]; then + out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$list_k'; export AWS_SECRET_ACCESS_KEY='$list_s'; aws s3 ls --endpoint-url '$r2_ep' --region auto 2>&1" 2>&1)" + if grep -q "$bucket" <<<"$out"; then ok "R2 bucket listing OK, '$bucket' present" + elif grep -q 'AccessDenied' <<<"$out"; then fail "R2 listing denied (no ListBuckets permission): $out" + elif grep -qE '^[^[:space:]]+[[:space:]]+' <<<"$out"; then warn "R2 listing returned (unexpected): $out" + else fail "R2 access failed: $out"; fi +else + warn "R2 credentials / endpoint not fully set; skipping R2 check" +fi + +# Verify the specific application bucket is reachable with the primary key (read). +if [[ -n "$r2_key" && -n "$r2_secret" && -n "$r2_ep" && -n "$bucket" ]]; then + out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$r2_key'; export AWS_SECRET_ACCESS_KEY='$r2_secret'; aws s3 ls s3://$bucket/ --endpoint-url '$r2_ep' --region auto --max-keys 5 2>&1 | head -5" 2>&1)" + if grep -q 'AccessDenied' <<<"$out"; then fail "Bucket '$bucket' read denied with primary key" + elif [[ -z "$out" ]]; then ok "Bucket '$bucket' reachable (empty listing, expected)" + elif grep -q 'NoSuchBucket' <<<"$out"; then fail "Bucket '$bucket' NOT FOUND" + else ok "Bucket '$bucket' reachable"; fi +elif [[ -z "$bucket" ]]; then + warn "R2_BUCKET_NAME not set; skipping bucket-level check" +fi + +# --- 4. Linear Void admin login --- +echo "" +echo "-- Linear Void admin login --" +admin_user="$(get_val ADMIN_USERNAME)" +admin_pass="$(get_val ADMIN_PASSWORD)" +if [[ -n "$admin_user" && -n "$admin_pass" ]]; then + # Hit the chat backend directly (public origin is caught by nginx SPA fallback). + url="http://${TARGET}:3000/api/admin/login" + body="{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}" + wrong_body="{\"username\":\"$admin_user\",\"password\":\"__definitely_wrong__\"}" + ok_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null | tr -d '[:space:]')" + wrong_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$wrong_body" "$url" 2>/dev/null | tr -d '[:space:]')" + if [[ "$ok_code" == "200" && "$wrong_code" == "401" ]]; then ok "Admin credentials valid (correct=200, wrong=401)" + else fail "Admin login unexpected (correct=$ok_code, wrong=$wrong_code)"; fi +else + warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check" +fi + +# --------------------------------------------------------------------------- +echo "" +if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi +echo "RESULT: OK (no failing checks)" +exit 0 diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md index 844346f..e0490de 100644 --- a/scripts/loadtest/README.md +++ b/scripts/loadtest/README.md @@ -2,66 +2,42 @@ These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md). -## WebSocket chat +## WebSocket chat (artillery) -Run with an explicit WebSocket URL, for example: +Run with an explicit WebSocket URL, capturing output to an artifact file: ```bash -WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml -``` - -Capture output to an artifact file for the worksheet: - -```bash -WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json -``` - -PowerShell equivalent: - -```powershell -$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main' -New-Item -ItemType Directory -Force artifacts | Out-Null +export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main' +mkdir -p artifacts npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json ``` Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report: -```powershell -pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json +```bash +scripts/loadtest/summarize-artillery.sh artifacts/ws-chat-report.json ``` -## Admin API +## Admin API (k6) -Run with a cookie or session header that can access admin endpoints: +Run with a cookie or session header that can access admin endpoints, capturing machine-readable metrics and a text summary: ```bash -ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js -``` - -Capture machine-readable metrics and a text summary: - -```bash -ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt -``` - -PowerShell equivalent: - -```powershell -$env:ADMIN_BASE_URL = 'https://linearvoid.tv' -$env:ADMIN_SESSION_COOKIE = 'session=...' -New-Item -ItemType Directory -Force artifacts | Out-Null -k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt +export ADMIN_BASE_URL='https://linearvoid.tv' +export ADMIN_SESSION_COOKIE='session=...' +mkdir -p artifacts +k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt ``` The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route. Summarize endpoint p95 and failure rates from a saved k6 JSON summary: -```powershell -pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json +```bash +scripts/loadtest/summarize-k6.sh artifacts/admin-api-summary.json ``` -## PowerShell helper +## Runner helper Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder: @@ -69,16 +45,11 @@ Use the helper to run one scenario or both and automatically write outputs into - WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles. - Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values. -```powershell -pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -``` - -```powershell -pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...' -``` - -```powershell -pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...' +```bash +scripts/loadtest/run-loadtests.sh -s ws -w 'wss://linearvoid.tv/ws?username=loadtest&room=main' +scripts/loadtest/run-loadtests.sh -s admin -a 'https://linearvoid.tv' -c 'session=...' +scripts/loadtest/run-loadtests.sh -s all -w 'wss://linearvoid.tv/ws?username=loadtest&room=main' \ + -a 'https://linearvoid.tv' -c 'session=...' ``` ## Notes diff --git a/scripts/loadtest/admin-api.js b/scripts/loadtest/admin-api.js index 884fad3..d2b1a2a 100644 --- a/scripts/loadtest/admin-api.js +++ b/scripts/loadtest/admin-api.js @@ -1,58 +1,58 @@ -import http from "k6/http"; -import { check, sleep } from "k6"; - -const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv"; -const adminCookie = __ENV.ADMIN_SESSION_COOKIE || ""; -const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {}; - -const endpoints = [ - { name: "overview", path: "/api/admin/overview" }, - { name: "analytics", path: "/api/admin/analytics" }, - { name: "chat_log", path: "/api/admin/chat-log" }, - { name: "users", path: "/api/admin/users" }, -]; - -export const options = { - stages: [ - { duration: "30s", target: 10 }, - { duration: "60s", target: 50 }, - { duration: "60s", target: 100 }, - { duration: "30s", target: 0 }, - ], - thresholds: { - http_req_duration: ["p(95)<1000"], - http_req_failed: ["rate<0.01"], - "http_req_duration{endpoint:overview}": ["p(95)<1000"], - "http_req_duration{endpoint:analytics}": ["p(95)<1000"], - "http_req_duration{endpoint:chat_log}": ["p(95)<1000"], - "http_req_duration{endpoint:users}": ["p(95)<1000"], - "http_req_failed{endpoint:overview}": ["rate<0.01"], - "http_req_failed{endpoint:analytics}": ["rate<0.01"], - "http_req_failed{endpoint:chat_log}": ["rate<0.01"], - "http_req_failed{endpoint:users}": ["rate<0.01"], - }, -}; - -export default function () { - const requests = endpoints.map((endpoint) => [ - "GET", - `${baseUrl}${endpoint.path}`, - null, - { - ...requestParams, - tags: { - endpoint: endpoint.name, - }, - }, - ]); - const responses = http.batch(requests); - - responses.forEach((response, index) => { - const endpointPath = endpoints[index].path; - check(response, { - [`status is 200 for ${endpointPath}`]: (r) => r.status === 200, - }); - }); - - sleep(1); -} +import http from "k6/http"; +import { check, sleep } from "k6"; + +const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv"; +const adminCookie = __ENV.ADMIN_SESSION_COOKIE || ""; +const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {}; + +const endpoints = [ + { name: "overview", path: "/api/admin/overview" }, + { name: "analytics", path: "/api/admin/analytics" }, + { name: "chat_log", path: "/api/admin/chat-log" }, + { name: "users", path: "/api/admin/users" }, +]; + +export const options = { + stages: [ + { duration: "30s", target: 10 }, + { duration: "60s", target: 50 }, + { duration: "60s", target: 100 }, + { duration: "30s", target: 0 }, + ], + thresholds: { + http_req_duration: ["p(95)<1000"], + http_req_failed: ["rate<0.01"], + "http_req_duration{endpoint:overview}": ["p(95)<1000"], + "http_req_duration{endpoint:analytics}": ["p(95)<1000"], + "http_req_duration{endpoint:chat_log}": ["p(95)<1000"], + "http_req_duration{endpoint:users}": ["p(95)<1000"], + "http_req_failed{endpoint:overview}": ["rate<0.01"], + "http_req_failed{endpoint:analytics}": ["rate<0.01"], + "http_req_failed{endpoint:chat_log}": ["rate<0.01"], + "http_req_failed{endpoint:users}": ["rate<0.01"], + }, +}; + +export default function () { + const requests = endpoints.map((endpoint) => [ + "GET", + `${baseUrl}${endpoint.path}`, + null, + { + ...requestParams, + tags: { + endpoint: endpoint.name, + }, + }, + ]); + const responses = http.batch(requests); + + responses.forEach((response, index) => { + const endpointPath = endpoints[index].path; + check(response, { + [`status is 200 for ${endpointPath}`]: (r) => r.status === 200, + }); + }); + + sleep(1); +} diff --git a/scripts/loadtest/run-loadtests.ps1 b/scripts/loadtest/run-loadtests.ps1 index b09ec7b..f0f3d11 100644 --- a/scripts/loadtest/run-loadtests.ps1 +++ b/scripts/loadtest/run-loadtests.ps1 @@ -1,99 +1,99 @@ -#!/usr/bin/env pwsh -param( - [ValidateSet('ws', 'admin', 'all')] - [string]$Scenario = 'all', - [string]$ArtifactRoot = 'artifacts', - [string]$WsUrl = $env:WS_URL, - [string]$AdminBaseUrl = $env:ADMIN_BASE_URL, - [string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE -) - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..') -$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml' -$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1' -$adminScriptPath = Join-Path $scriptDir 'admin-api.js' -$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1' - -if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) { - $artifactBase = $ArtifactRoot -} else { - $artifactBase = Join-Path $repoRoot $ArtifactRoot -} - -if ($Scenario -in @('ws', 'all')) { - if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { - throw 'npx is required for ws scenario but was not found in PATH.' - } -} - -if ($Scenario -in @('admin', 'all')) { - if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) { - throw 'k6 is required for admin scenario but was not found in PATH.' - } -} - -$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' -$artifactDir = Join-Path $artifactBase $timestamp -New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null - -function Invoke-WebSocketLoadTest { - param( - [string]$TargetWsUrl, - [string]$OutputPath - ) - - if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) { - throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.' - } - - $env:WS_URL = $TargetWsUrl - & npx artillery run $wsScenarioPath --output $OutputPath -} - -function Invoke-AdminApiLoadTest { - param( - [string]$BaseUrl, - [string]$SessionCookie, - [string]$SummaryPath, - [string]$OutputPath - ) - - if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.' - } - if ([string]::IsNullOrWhiteSpace($SessionCookie)) { - throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.' - } - - $env:ADMIN_BASE_URL = $BaseUrl - $env:ADMIN_SESSION_COOKIE = $SessionCookie - & k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath -} - -if ($Scenario -in @('ws', 'all')) { - if (-not (Test-Path $wsSummaryScriptPath)) { - throw "Missing summary helper: $wsSummaryScriptPath" - } - - $wsOutput = Join-Path $artifactDir 'ws-chat-report.json' - $wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt' - Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput - & pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary -} - -if ($Scenario -in @('admin', 'all')) { - if (-not (Test-Path $adminSummaryScriptPath)) { - throw "Missing summary helper: $adminSummaryScriptPath" - } - - $adminSummary = Join-Path $artifactDir 'admin-api-summary.json' - $adminOutput = Join-Path $artifactDir 'admin-api-output.txt' - $adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt' - Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput - & pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary -} - +#!/usr/bin/env pwsh +param( + [ValidateSet('ws', 'admin', 'all')] + [string]$Scenario = 'all', + [string]$ArtifactRoot = 'artifacts', + [string]$WsUrl = $env:WS_URL, + [string]$AdminBaseUrl = $env:ADMIN_BASE_URL, + [string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..') +$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml' +$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1' +$adminScriptPath = Join-Path $scriptDir 'admin-api.js' +$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1' + +if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) { + $artifactBase = $ArtifactRoot +} else { + $artifactBase = Join-Path $repoRoot $ArtifactRoot +} + +if ($Scenario -in @('ws', 'all')) { + if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { + throw 'npx is required for ws scenario but was not found in PATH.' + } +} + +if ($Scenario -in @('admin', 'all')) { + if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) { + throw 'k6 is required for admin scenario but was not found in PATH.' + } +} + +$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' +$artifactDir = Join-Path $artifactBase $timestamp +New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + +function Invoke-WebSocketLoadTest { + param( + [string]$TargetWsUrl, + [string]$OutputPath + ) + + if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) { + throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.' + } + + $env:WS_URL = $TargetWsUrl + & npx artillery run $wsScenarioPath --output $OutputPath +} + +function Invoke-AdminApiLoadTest { + param( + [string]$BaseUrl, + [string]$SessionCookie, + [string]$SummaryPath, + [string]$OutputPath + ) + + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.' + } + if ([string]::IsNullOrWhiteSpace($SessionCookie)) { + throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.' + } + + $env:ADMIN_BASE_URL = $BaseUrl + $env:ADMIN_SESSION_COOKIE = $SessionCookie + & k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath +} + +if ($Scenario -in @('ws', 'all')) { + if (-not (Test-Path $wsSummaryScriptPath)) { + throw "Missing summary helper: $wsSummaryScriptPath" + } + + $wsOutput = Join-Path $artifactDir 'ws-chat-report.json' + $wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt' + Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput + & pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary +} + +if ($Scenario -in @('admin', 'all')) { + if (-not (Test-Path $adminSummaryScriptPath)) { + throw "Missing summary helper: $adminSummaryScriptPath" + } + + $adminSummary = Join-Path $artifactDir 'admin-api-summary.json' + $adminOutput = Join-Path $artifactDir 'admin-api-output.txt' + $adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt' + Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput + & pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary +} + Write-Host "Artifacts written to $artifactDir" \ No newline at end of file diff --git a/scripts/loadtest/run-loadtests.sh b/scripts/loadtest/run-loadtests.sh new file mode 100644 index 0000000..3d9e5ad --- /dev/null +++ b/scripts/loadtest/run-loadtests.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# run-loadtests.sh — Run an artillery (ws) and/or k6 (admin) load-test scenario +# and write outputs into a dated artifacts folder. +# Mirrors scripts/loadtest/run-loadtests.ps1. +# +# Usage: +# ./scripts/loadtest/run-loadtests.sh -s ws -w 'wss://...' +# ./scripts/loadtest/run-loadtests.sh -s admin -a 'https://...' -c 'session=...' +# ./scripts/loadtest/run-loadtests.sh -s all -w 'wss://...' -a 'https://...' -c 'session=...' +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +SCENARIO="all" +ARTIFACT_ROOT="artifacts" +WS_URL="${WS_URL:-}" +ADMIN_BASE_URL="${ADMIN_BASE_URL:-}" +ADMIN_SESSION_COOKIE="${ADMIN_SESSION_COOKIE:-}" + +usage() { + echo "Usage: $0 [-s ws|admin|all] [-w ws_url] [-a admin_base_url] [-c admin_session_cookie] [-r artifact_root]" >&2 + exit 2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -s) SCENARIO="$2"; shift 2;; + -w) WS_URL="$2"; shift 2;; + -a) ADMIN_BASE_URL="$2"; shift 2;; + -c) ADMIN_SESSION_COOKIE="$2"; shift 2;; + -r) ARTIFACT_ROOT="$2"; shift 2;; + -h|--help) usage;; + *) echo "Unknown option: $1" >&2; usage;; + esac +done + +case "$SCENARIO" in ws|admin|all) ;; *) echo "Scenario must be ws|admin|all" >&2; exit 2;; esac + +if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]] && ! command -v npx >/dev/null 2>&1; then + echo "npx is required for ws scenario but was not found in PATH." >&2; exit 1 +fi +if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]] && ! command -v k6 >/dev/null 2>&1; then + echo "k6 is required for admin scenario but was not found in PATH." >&2; exit 1 +fi + +if [[ "$ARTIFACT_ROOT" = /* ]]; then artifact_base="$ARTIFACT_ROOT"; else artifact_base="$repo_root/$ARTIFACT_ROOT"; fi +artifact_dir="$artifact_base/$(date +%Y-%m-%d_%H%M%S)" +mkdir -p "$artifact_dir" + +if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]]; then + [[ -n "$WS_URL" ]] || { echo "WS_URL is required for ws scenario. Pass -w or set \$WS_URL." >&2; exit 1; } + ws_out="$artifact_dir/ws-chat-report.json" + ws_sum="$artifact_dir/ws-chat-summary.txt" + echo "== Running artillery ws scenario ==" + WS_URL="$WS_URL" npx artillery run "$script_dir/ws-chat.yml" --output "$ws_out" + "$script_dir/summarize-artillery.sh" "$ws_out" | tee "$ws_sum" +fi + +if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]]; then + [[ -n "$ADMIN_BASE_URL" ]] || { echo "ADMIN_BASE_URL is required for admin scenario. Pass -a or set \$ADMIN_BASE_URL." >&2; exit 1; } + [[ -n "$ADMIN_SESSION_COOKIE" ]] || { echo "ADMIN_SESSION_COOKIE is required for admin scenario. Pass -c or set \$ADMIN_SESSION_COOKIE." >&2; exit 1; } + admin_sum="$artifact_dir/admin-api-summary.json" + admin_out="$artifact_dir/admin-api-output.txt" + admin_ep_sum="$artifact_dir/admin-api-endpoint-summary.txt" + echo "== Running k6 admin scenario ==" + ADMIN_BASE_URL="$ADMIN_BASE_URL" ADMIN_SESSION_COOKIE="$ADMIN_SESSION_COOKIE" \ + k6 run --summary-export "$admin_sum" "$script_dir/admin-api.js" | tee "$admin_out" + "$script_dir/summarize-k6.sh" "$admin_sum" | tee "$admin_ep_sum" +fi + +echo "Artifacts written to $artifact_dir" diff --git a/scripts/loadtest/summarize-artillery.ps1 b/scripts/loadtest/summarize-artillery.ps1 index 6422a7d..3c02066 100644 --- a/scripts/loadtest/summarize-artillery.ps1 +++ b/scripts/loadtest/summarize-artillery.ps1 @@ -1,115 +1,115 @@ -#!/usr/bin/env pwsh -param( - [Parameter(Mandatory = $true)] - [string]$ReportFile -) - -$ErrorActionPreference = 'Stop' - -if (-not (Test-Path $ReportFile)) { - throw "Report file not found: $ReportFile" -} - -$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100 -$aggregate = $report.aggregate - -if (-not $aggregate) { - throw 'Artillery report is missing aggregate data.' -} - -function Get-PropertyValue { - param( - [Parameter(Mandatory = $true)] - [object]$Object, - [Parameter(Mandatory = $true)] - [string]$Name - ) - - if (-not $Object) { - return $null - } - - $property = $Object.PSObject.Properties[$Name] - if ($property) { - return $property.Value - } - - return $null -} - -function Format-StatLine { - param( - [string]$Prefix, - [object]$Stats - ) - - if (-not $Stats) { - return $null - } - - $parts = @() - foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) { - $value = Get-PropertyValue -Object $Stats -Name $name - if ($null -ne $value) { - $parts += "$name=$value" - } - } - - if ($parts.Count -eq 0) { - return $null - } - - return "$Prefix: $($parts -join ', ')" -} - -$counters = Get-PropertyValue -Object $aggregate -Name 'counters' -$rates = Get-PropertyValue -Object $aggregate -Name 'rates' -$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries' - -$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0) -$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0) -$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0) -$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 } - -Write-Output '=== Artillery WebSocket Summary ===' -Write-Output "source: $ReportFile" - -$timestamp = Get-PropertyValue -Object $report -Name 'timestamp' -if ($timestamp) { - Write-Output "timestamp: $timestamp" -} - -if ($null -ne $created) { - Write-Output "vusers.created: $created" -} -if ($null -ne $completed) { - Write-Output "vusers.completed: $completed" -} -if ($null -ne $failed) { - Write-Output "vusers.failed: $failed" -} -Write-Output "vusers.failure_rate_percent: $errorRate" - -if ($counters) { - foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) { - $value = Get-PropertyValue -Object $counters -Name $metric - if ($null -ne $value) { - Write-Output "$metric: $value" - } - } -} - -if ($rates) { - foreach ($property in $rates.PSObject.Properties) { - Write-Output "rate.$($property.Name): $($property.Value)" - } -} - -if ($summaries) { - foreach ($property in $summaries.PSObject.Properties) { - $line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value - if ($line) { - Write-Output $line - } - } -} +#!/usr/bin/env pwsh +param( + [Parameter(Mandatory = $true)] + [string]$ReportFile +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $ReportFile)) { + throw "Report file not found: $ReportFile" +} + +$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100 +$aggregate = $report.aggregate + +if (-not $aggregate) { + throw 'Artillery report is missing aggregate data.' +} + +function Get-PropertyValue { + param( + [Parameter(Mandatory = $true)] + [object]$Object, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if (-not $Object) { + return $null + } + + $property = $Object.PSObject.Properties[$Name] + if ($property) { + return $property.Value + } + + return $null +} + +function Format-StatLine { + param( + [string]$Prefix, + [object]$Stats + ) + + if (-not $Stats) { + return $null + } + + $parts = @() + foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) { + $value = Get-PropertyValue -Object $Stats -Name $name + if ($null -ne $value) { + $parts += "$name=$value" + } + } + + if ($parts.Count -eq 0) { + return $null + } + + return "$Prefix: $($parts -join ', ')" +} + +$counters = Get-PropertyValue -Object $aggregate -Name 'counters' +$rates = Get-PropertyValue -Object $aggregate -Name 'rates' +$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries' + +$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0) +$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0) +$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0) +$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 } + +Write-Output '=== Artillery WebSocket Summary ===' +Write-Output "source: $ReportFile" + +$timestamp = Get-PropertyValue -Object $report -Name 'timestamp' +if ($timestamp) { + Write-Output "timestamp: $timestamp" +} + +if ($null -ne $created) { + Write-Output "vusers.created: $created" +} +if ($null -ne $completed) { + Write-Output "vusers.completed: $completed" +} +if ($null -ne $failed) { + Write-Output "vusers.failed: $failed" +} +Write-Output "vusers.failure_rate_percent: $errorRate" + +if ($counters) { + foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) { + $value = Get-PropertyValue -Object $counters -Name $metric + if ($null -ne $value) { + Write-Output "$metric: $value" + } + } +} + +if ($rates) { + foreach ($property in $rates.PSObject.Properties) { + Write-Output "rate.$($property.Name): $($property.Value)" + } +} + +if ($summaries) { + foreach ($property in $summaries.PSObject.Properties) { + $line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value + if ($line) { + Write-Output $line + } + } +} diff --git a/scripts/loadtest/summarize-artillery.sh b/scripts/loadtest/summarize-artillery.sh new file mode 100644 index 0000000..ea006ff --- /dev/null +++ b/scripts/loadtest/summarize-artillery.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# summarize-artillery.sh — Extract counters, rates and percentile stats from an +# Artillery JSON report. Mirrors scripts/loadtest/summarize-artillery.ps1. +# Usage: ./scripts/loadtest/summarize-artillery.sh +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +report_file="$1" +[[ -f "$report_file" ]] || { echo "Report file not found: $report_file" >&2; exit 1; } + +REPORT_FILE="$report_file" node <<'EOF' +const fs = require('fs'); +const report = JSON.parse(fs.readFileSync(process.env.REPORT_FILE, 'utf8')); +const aggregate = report.aggregate; +if (!aggregate) throw new Error('Artillery report is missing aggregate data.'); + +const get = (obj, name) => (obj && obj[name] !== undefined) ? obj[name] : undefined; +const counters = aggregate.counters || {}; +const rates = aggregate.rates || {}; +const summaries = aggregate.summaries || {}; + +const created = Number(get(counters, 'vusers.created') || 0); +const failed = Number(get(counters, 'vusers.failed') || 0); +const completed = Number(get(counters, 'vusers.completed') || 0); +const errorRate = created > 0 ? +((failed / created) * 100).toFixed(2) : 0; + +console.log('=== Artillery WebSocket Summary ==='); +console.log(`source: ${process.env.REPORT_FILE}`); +if (report.timestamp) console.log(`timestamp: ${report.timestamp}`); +console.log(`vusers.created: ${created}`); +console.log(`vusers.completed: ${completed}`); +console.log(`vusers.failed: ${failed}`); +console.log(`vusers.failure_rate_percent: ${errorRate}`); + +for (const metric of ['ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time']) { + const v = get(counters, metric); + if (v !== undefined) console.log(`${metric}: ${v}`); +} +for (const [k, v] of Object.entries(rates || {})) console.log(`rate.${k}: ${v}`); + +const fmt = (prefix, stats) => { + if (!stats) return; + const parts = []; + for (const name of ['count', 'min', 'max', 'mean', 'median', 'p95', 'p99']) { + const v = get(stats, name); + if (v !== undefined) parts.push(`${name}=${v}`); + } + if (parts.length) console.log(`${prefix}: ${parts.join(', ')}`); +}; +for (const [k, v] of Object.entries(summaries)) fmt(`summary.${k}`, v); +EOF diff --git a/scripts/loadtest/summarize-k6.ps1 b/scripts/loadtest/summarize-k6.ps1 index 726233c..8a21df0 100644 --- a/scripts/loadtest/summarize-k6.ps1 +++ b/scripts/loadtest/summarize-k6.ps1 @@ -1,68 +1,68 @@ -#!/usr/bin/env pwsh -param( - [Parameter(Mandatory = $true)] - [string]$SummaryFile -) - -$ErrorActionPreference = 'Stop' - -if (-not (Test-Path $SummaryFile)) { - throw "Summary file not found: $SummaryFile" -} - -$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json -$metrics = $summary.metrics - -if (-not $metrics) { - throw 'Invalid k6 summary file: missing metrics object.' -} - -$endpoints = @('overview', 'analytics', 'chat_log', 'users') - -function Get-MetricValue { - param( - [object]$Metric, - [string]$Key - ) - - if (-not $Metric) { - return $null - } - if (-not $Metric.values) { - return $null - } - - $value = $Metric.values.$Key - if ($null -ne $value) { - return [double]$value - } - return $null -} - -$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)' -$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate' - -Write-Output "k6 Summary: $SummaryFile" -if ($null -ne $globalDuration) { - Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration) -} -if ($null -ne $globalFailRate) { - Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate) -} -Write-Output '' - -$rows = foreach ($endpoint in $endpoints) { - $durationMetricName = "http_req_duration{endpoint:$endpoint}" - $failMetricName = "http_req_failed{endpoint:$endpoint}" - - $durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)' - $failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate' - - [PSCustomObject]@{ - Endpoint = $endpoint - P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null } - FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null } - } -} - -$rows | Format-Table -AutoSize | Out-String | Write-Output +#!/usr/bin/env pwsh +param( + [Parameter(Mandatory = $true)] + [string]$SummaryFile +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $SummaryFile)) { + throw "Summary file not found: $SummaryFile" +} + +$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json +$metrics = $summary.metrics + +if (-not $metrics) { + throw 'Invalid k6 summary file: missing metrics object.' +} + +$endpoints = @('overview', 'analytics', 'chat_log', 'users') + +function Get-MetricValue { + param( + [object]$Metric, + [string]$Key + ) + + if (-not $Metric) { + return $null + } + if (-not $Metric.values) { + return $null + } + + $value = $Metric.values.$Key + if ($null -ne $value) { + return [double]$value + } + return $null +} + +$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)' +$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate' + +Write-Output "k6 Summary: $SummaryFile" +if ($null -ne $globalDuration) { + Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration) +} +if ($null -ne $globalFailRate) { + Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate) +} +Write-Output '' + +$rows = foreach ($endpoint in $endpoints) { + $durationMetricName = "http_req_duration{endpoint:$endpoint}" + $failMetricName = "http_req_failed{endpoint:$endpoint}" + + $durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)' + $failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate' + + [PSCustomObject]@{ + Endpoint = $endpoint + P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null } + FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null } + } +} + +$rows | Format-Table -AutoSize | Out-String | Write-Output diff --git a/scripts/loadtest/summarize-k6.sh b/scripts/loadtest/summarize-k6.sh new file mode 100644 index 0000000..0375831 --- /dev/null +++ b/scripts/loadtest/summarize-k6.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# summarize-k6.sh — Extract per-endpoint p95 and failure rates from a k6 JSON +# summary export. Mirrors scripts/loadtest/summarize-k6.ps1. +# Usage: ./scripts/loadtest/summarize-k6.sh +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +summary_file="$1" +[[ -f "$summary_file" ]] || { echo "Summary file not found: $summary_file" >&2; exit 1; } + +SUMMARY_FILE="$summary_file" node <<'EOF' +const fs = require('fs'); +const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, 'utf8')); +const metrics = summary.metrics; +if (!metrics) throw new Error('Invalid k6 summary file: missing metrics object.'); + +const val = (m, key) => (m && m.values && m.values[key] !== undefined) ? Number(m.values[key]) : undefined; +const globalDuration = val(metrics.http_req_duration, 'p(95)'); +const globalFailRate = val(metrics.http_req_failed, 'rate'); + +console.log(`k6 Summary: ${process.env.SUMMARY_FILE}`); +if (globalDuration !== undefined) console.log(`Global p95 duration (ms): ${globalDuration.toFixed(2)}`); +if (globalFailRate !== undefined) console.log(`Global failure rate: ${(globalFailRate * 100).toFixed(2)}%`); +console.log(''); + +const endpoints = ['overview', 'analytics', 'chat_log', 'users']; +const rows = endpoints.map((ep) => { + const dP95 = val(metrics[`http_req_duration{endpoint:${ep}}`], 'p(95)'); + const fRate = val(metrics[`http_req_failed{endpoint:${ep}}`], 'rate'); + return { + Endpoint: ep, + P95Ms: dP95 !== undefined ? dP95.toFixed(2) : 'n/a', + FailurePct: fRate !== undefined ? (fRate * 100).toFixed(4) : 'n/a', + }; +}); + +const pad = (s, n) => String(s).padEnd(n); +console.log(pad('ENDPOINT', 12) + pad('P95_MS', 10) + 'FAILURE_PCT'); +for (const r of rows) console.log(pad(r.Endpoint, 12) + pad(r.P95Ms, 10) + r.FailurePct); +EOF diff --git a/scripts/loadtest/ws-chat.yml b/scripts/loadtest/ws-chat.yml index 8044df8..e1657d9 100644 --- a/scripts/loadtest/ws-chat.yml +++ b/scripts/loadtest/ws-chat.yml @@ -1,28 +1,28 @@ -config: - target: "{{ $processEnvironment.WS_URL }}" - phases: - - duration: 60 - arrivalRate: 10 - name: warmup-10 - - duration: 120 - arrivalRate: 100 - name: sustain-100 - - duration: 180 - arrivalRate: 500 - name: sustain-500 - - duration: 240 - arrivalRate: 1000 - name: sustain-1000 - engines: - ws: {} -scenarios: - - engine: ws - name: chat-presence-and-send - flow: - - think: 1 - - send: - text: '{"type":"ping","text":"loadtest"}' - - think: 1 - - send: - text: '{"type":"ping","text":"loadtest-2"}' - - think: 1 +config: + target: "{{ $processEnvironment.WS_URL }}" + phases: + - duration: 60 + arrivalRate: 10 + name: warmup-10 + - duration: 120 + arrivalRate: 100 + name: sustain-100 + - duration: 180 + arrivalRate: 500 + name: sustain-500 + - duration: 240 + arrivalRate: 1000 + name: sustain-1000 + engines: + ws: {} +scenarios: + - engine: ws + name: chat-presence-and-send + flow: + - think: 1 + - send: + text: '{"type":"ping","text":"loadtest"}' + - think: 1 + - send: + text: '{"type":"ping","text":"loadtest-2"}' + - think: 1