From 74aa0cf06771cab51226a6329e2700404ddfed1c Mon Sep 17 00:00:00 2001 From: Darkraise Date: Wed, 12 Aug 2026 14:05:14 +0700 Subject: [PATCH] feat(startup): serve the app while the server warms up On a network share the database copy and cache pre-warm run for minutes, and the listener only opened after all of it, so anyone who loaded the page during that window got a connection error. Open the port immediately after registering the static frontend, and hold every route that needs the database behind a gate that answers 503 with a machine-readable marker and the current startup phase. The SPA shell needs nothing the gate blocks, so the frontend renders a "server is starting" screen on the login page's own background, polls /health, and reloads once the server reports ready. Move the database copy out of the entrypoint: it does the same freshness check the application does, and doing it before node starts kept the port closed for the longest part of startup. Distinguish a dead server from a slow one by requiring the backend's own marker, since a proxy in front of a stopped backend still answers. --- backend/docker-entrypoint.sh | 105 +-------- backend/package.json | 2 + backend/src/middleware/frontendServing.ts | 72 ++++++ backend/src/middleware/startupGate.test.ts | 90 ++++++++ backend/src/middleware/startupGate.ts | 35 +++ .../src/middleware/startupOrdering.test.ts | 124 +++++++++++ backend/src/server.ts | 84 +++---- backend/src/services/StartupState.test.ts | 41 ++++ backend/src/services/StartupState.ts | 39 ++++ docker-compose.yml | 4 +- frontend/src/App.tsx | 13 ++ .../common/ServerStartingScreen.test.tsx | 155 +++++++++++++ .../common/ServerStartingScreen.tsx | 155 +++++++++++++ frontend/src/lib/api/client.ts | 10 + frontend/src/store/serverStatus.ts | 18 ++ frontend/vite.config.ts | 7 + package-lock.json | 207 ++++++++++++++++++ 17 files changed, 1011 insertions(+), 150 deletions(-) create mode 100644 backend/src/middleware/frontendServing.ts create mode 100644 backend/src/middleware/startupGate.test.ts create mode 100644 backend/src/middleware/startupGate.ts create mode 100644 backend/src/middleware/startupOrdering.test.ts create mode 100644 backend/src/services/StartupState.test.ts create mode 100644 backend/src/services/StartupState.ts create mode 100644 frontend/src/components/common/ServerStartingScreen.test.tsx create mode 100644 frontend/src/components/common/ServerStartingScreen.tsx create mode 100644 frontend/src/store/serverStatus.ts diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index f18dd56..12f3471 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -6,9 +6,13 @@ set -e # ============================================================================= # This script handles: # 1. Dynamic UID/GID setup (via PUID/PGID environment variables) -# 2. Initial database copy from network storage to local (if enabled) -# 3. Database sync check on startup -# 4. Application startup with dropped privileges +# 2. Ownership of the mounted data and log directories +# 3. Application startup with dropped privileges +# +# The local database copy is deliberately NOT done here: it runs inside the +# application so the HTTP port is already open while it happens, which on a +# network share is the difference between a "server is starting" page and a +# connection error. # ============================================================================= echo "🚀 Flashpoint Web Backend - Starting..." @@ -86,87 +90,6 @@ FLASHPOINT_PATH="/data/flashpoint" SOURCE_DB_PATH="${FLASHPOINT_PATH}/Data/flashpoint.sqlite" LOCAL_DB_PATH="/app/data/flashpoint.sqlite" -# Function to copy database from source to local storage -copy_database() { - local source="$1" - local dest="$2" - local dest_dir - dest_dir=$(dirname "$dest") - - echo "đŸ“Ļ Copying database from source to local storage..." - echo " Source: $source" - echo " Destination: $dest" - - # Create destination directory if it doesn't exist - if [ ! -d "$dest_dir" ]; then - mkdir -p "$dest_dir" - echo " Created directory: $dest_dir" - fi - - # Copy to temp file first for atomic operation - local temp_file="${dest}.tmp" - - # Measure copy time - local start_time - start_time=$(date +%s) - - cp "$source" "$temp_file" - mv "$temp_file" "$dest" - - local end_time - end_time=$(date +%s) - local duration=$((end_time - start_time)) - - # Get file size in MB - local size_bytes - local size_mb - size_bytes=$(stat -c%s "$dest" 2>/dev/null || stat -f%z "$dest" 2>/dev/null || echo "0") - size_mb=$((size_bytes / 1024 / 1024)) - - echo "✅ Database copied successfully (${size_mb}MB in ${duration}s)" -} - -# Function to check if local copy needs update -needs_update() { - local source="$1" - local dest="$2" - - # If destination doesn't exist, needs update - if [ ! -f "$dest" ]; then - echo " Local copy does not exist" - return 0 - fi - - # Get modification times (portable between Linux and Alpine) - local source_mtime - local dest_mtime - source_mtime=$(stat -c%Y "$source" 2>/dev/null || stat -f%m "$source" 2>/dev/null || echo "0") - dest_mtime=$(stat -c%Y "$dest" 2>/dev/null || stat -f%m "$dest" 2>/dev/null || echo "0") - - # If source is newer, needs update - if [ "$source_mtime" -gt "$dest_mtime" ]; then - echo " Source is newer than local copy" - echo " Source mtime: $source_mtime" - echo " Local mtime: $dest_mtime" - return 0 - fi - - # Get file sizes - local source_size - local dest_size - source_size=$(stat -c%s "$source" 2>/dev/null || stat -f%z "$source" 2>/dev/null || echo "0") - dest_size=$(stat -c%s "$dest" 2>/dev/null || stat -f%z "$dest" 2>/dev/null || echo "0") - - # If sizes differ, needs update - if [ "$source_size" != "$dest_size" ]; then - echo " File sizes differ (source: ${source_size}, local: ${dest_size})" - return 0 - fi - - # No update needed - return 1 -} - # ============================================================================= # Main Entrypoint Logic # ============================================================================= @@ -180,16 +103,12 @@ fi echo "✅ Source database found: $SOURCE_DB_PATH" -# Handle local database copy if enabled +# The copy itself is left to the application: it performs the same freshness +# check, and doing it here keeps the port closed for the whole copy, which is +# minutes on a network share — exactly when a user is most likely to open the +# page and get a connection error instead of the "server is starting" screen. if [ "$ENABLE_LOCAL_DB_COPY" = "true" ]; then - echo "🔄 Local database copy is ENABLED" - echo " Checking if local copy needs update..." - - if needs_update "$SOURCE_DB_PATH" "$LOCAL_DB_PATH"; then - copy_database "$SOURCE_DB_PATH" "$LOCAL_DB_PATH" - else - echo "✅ Local database copy is up to date" - fi + echo "🔄 Local database copy is ENABLED (performed by the application at startup)" else echo "â„šī¸ Local database copy is DISABLED" echo " Database will be accessed directly from: $SOURCE_DB_PATH" diff --git a/backend/package.json b/backend/package.json index baa8a3f..c3e140d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -65,9 +65,11 @@ "@types/mime-types": "^3.0.1", "@types/node": "^20.11.25", "@types/node-cron": "^3.0.11", + "@types/supertest": "^7.2.1", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "eslint": "^8.57.0", + "supertest": "^7.2.2", "tsx": "^4.7.1", "typescript": "^5.4.2", "vitest": "^1.3.1" diff --git a/backend/src/middleware/frontendServing.ts b/backend/src/middleware/frontendServing.ts new file mode 100644 index 0000000..3aca241 --- /dev/null +++ b/backend/src/middleware/frontendServing.ts @@ -0,0 +1,72 @@ +import express, { Express } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { logger } from '../utils/logger'; +import { config } from '../config'; +import { startupGate } from './startupGate'; + +/** + * Single-image deployment: the backend serves the built frontend and its SPA + * fallback. + * + * Registered before auth/maintenance so the app shell always loads (the SPA then + * reflects login/maintenance state from API responses), and before the API + * catch-all 404 in setupRoutes so client routes reach index.html. + * + * It must also stay ahead of the startup gate: nothing here needs the database, + * and serving the shell during startup is what lets the SPA render its "server + * is starting" screen instead of the browser showing a connection error. + */ +export function registerFrontendServing(app: Express, distPath = config.frontendDistPath): void { + const indexHtml = path.join(distPath, 'index.html'); + + if (!fs.existsSync(indexHtml)) { + logger.warn( + `âš ī¸ SERVE_FRONTEND is enabled but no build was found at ${indexHtml} — skipping static serving` + ); + return; + } + + app.use( + express.static(distPath, { + index: false, + maxAge: '1y', + immutable: true, + setHeaders: (res, filePath) => { + // Hashed assets are immutable; index.html must always be revalidated. + if (filePath.endsWith('index.html')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + } + }, + }) + ); + + const backendPrefixes = ['/api/', '/game-proxy/', '/game-zip/', '/proxy/']; + app.get('*', (req, res, next) => { + if (req.path === '/health' || backendPrefixes.some((prefix) => req.path.startsWith(prefix))) { + next(); + return; + } + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.sendFile(indexHtml); + }); + + logger.info(`đŸ–Ĩī¸ Serving frontend from ${distPath}`); +} + +/** + * Registers everything that must be in place before the listener opens, in the + * one order that works: the SPA shell first so it loads during startup, then the + * gate that holds back every route needing the database. + * + * Exported as a unit so the ordering is exercised by tests rather than restated + * in them — a test that mirrors this order proves its own wiring, not the + * server's. + */ +export function registerPreListenStack(app: Express, distPath = config.frontendDistPath): void { + if (config.serveFrontend) { + registerFrontendServing(app, distPath); + } + + app.use(startupGate); +} diff --git a/backend/src/middleware/startupGate.test.ts b/backend/src/middleware/startupGate.test.ts new file mode 100644 index 0000000..265d2bf --- /dev/null +++ b/backend/src/middleware/startupGate.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +import { startupGate } from './startupGate'; +import { StartupState } from '../services/StartupState'; + +let res: Partial; +let next: NextFunction; +let statusCode: number | null; +let body: unknown; + +function requestFor(path: string): Request { + return { path } as Request; +} + +beforeEach(() => { + StartupState.reset(); + statusCode = null; + body = null; + res = { + status: vi.fn((code: number) => { + statusCode = code; + return res as Response; + }), + json: vi.fn((payload: unknown) => { + body = payload; + return res as Response; + }), + }; + next = vi.fn() as unknown as NextFunction; +}); + +describe('startupGate', () => { + it('answers API requests with a machine-readable starting marker', () => { + StartupState.setPhase('Connecting to the Flashpoint database'); + const request = requestFor('/api/games'); + + startupGate(request, res as Response, next); + + expect(next).not.toHaveBeenCalled(); + expect(statusCode).toBe(503); + expect(body).toMatchObject({ + success: false, + starting: true, + phase: 'Connecting to the Flashpoint database', + }); + }); + + it('reports starting on the health endpoint', () => { + const request = requestFor('/health'); + + startupGate(request, res as Response, next); + + expect(next).not.toHaveBeenCalled(); + expect(statusCode).toBe(503); + expect(body).toMatchObject({ status: 'starting', phase: 'Starting up' }); + }); + + it('gates game content requests too', () => { + const request = requestFor('/some-game/index.html'); + + startupGate(request, res as Response, next); + + expect(statusCode).toBe(503); + expect(body).toMatchObject({ starting: true }); + }); + + it('passes everything through once startup finishes', () => { + const request = requestFor('/api/games'); + StartupState.markReady(); + + startupGate(request, res as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('passes the health check through once ready', () => { + const request = requestFor('/health'); + StartupState.markReady(); + + startupGate(request, res as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/middleware/startupGate.ts b/backend/src/middleware/startupGate.ts new file mode 100644 index 0000000..3c89975 --- /dev/null +++ b/backend/src/middleware/startupGate.ts @@ -0,0 +1,35 @@ +import { Request, Response, NextFunction } from 'express'; +import { StartupState } from '../services/StartupState'; + +/** + * Answers requests that arrive before startup finishes. + * + * The HTTP listener opens before the database copy and cache pre-warm complete — + * on a network share those take minutes, and a closed port shows the user a + * connection error instead of a page. Static assets and the SPA shell are served + * without this gate, so the frontend can load and render its "server is + * starting" screen; everything behind it needs the database and would otherwise + * reach middleware that throws. + */ +export function startupGate(req: Request, res: Response, next: NextFunction): void { + if (StartupState.isReady()) { + next(); + return; + } + + if (req.path === '/health') { + res.status(503).json({ + status: 'starting', + phase: StartupState.getPhase(), + timestamp: new Date().toISOString(), + }); + return; + } + + res.status(503).json({ + success: false, + error: 'Server is starting', + starting: true, + phase: StartupState.getPhase(), + }); +} diff --git a/backend/src/middleware/startupOrdering.test.ts b/backend/src/middleware/startupOrdering.test.ts new file mode 100644 index 0000000..171b74e --- /dev/null +++ b/backend/src/middleware/startupOrdering.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express, { Express } from 'express'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +vi.mock('../config', () => ({ + config: { + serveFrontend: true, + frontendDistPath: '/unused-in-tests', + }, +})); + +import { registerPreListenStack } from './frontendServing'; +import { StartupState } from '../services/StartupState'; + +const INDEX_HTML = 'Flashpoint Web
'; +const ASSET_JS = 'console.log("bundle");'; + +let distDir: string; +let app: Express; + +/** + * Exercises the same registration server.ts uses, so a reordering there is + * caught here. Only the API routes that would normally come from setupRoutes are + * stubbed — the shell-before-gate ordering under test is the real one. + */ +beforeEach(() => { + StartupState.reset(); + + distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-dist-')); + fs.mkdirSync(path.join(distDir, 'assets')); + fs.writeFileSync(path.join(distDir, 'index.html'), INDEX_HTML); + fs.writeFileSync(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS); + + app = express(); + registerPreListenStack(app, distDir); + app.get('/api/games', (_req, res) => { + res.json({ success: true, data: [] }); + }); + app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); + }); +}); + +afterEach(() => { + fs.rmSync(distDir, { recursive: true, force: true }); +}); + +describe('startup request ordering', () => { + describe('while the server is still starting', () => { + it('serves the app shell', async () => { + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('id="root"'); + }); + + it('serves hashed asset bundles', async () => { + const response = await request(app).get('/assets/app-abc123.js'); + + expect(response.status).toBe(200); + expect(response.text).toBe(ASSET_JS); + }); + + it('serves the shell for client-side routes', async () => { + const response = await request(app).get('/games/some-game-id'); + + expect(response.status).toBe(200); + expect(response.text).toContain('id="root"'); + }); + + it('holds off API requests with the starting marker', async () => { + StartupState.setPhase('Connecting to the Flashpoint database'); + + const response = await request(app).get('/api/games'); + + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + starting: true, + phase: 'Connecting to the Flashpoint database', + }); + }); + + it('reports starting on the health endpoint', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ status: 'starting' }); + }); + }); + + describe('once startup finishes', () => { + beforeEach(() => { + StartupState.markReady(); + }); + + it('lets API requests reach their route', async () => { + const response = await request(app).get('/api/games'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ success: true }); + }); + + it('lets the health check reach its route', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ status: 'ok' }); + }); + + it('still serves the app shell', async () => { + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('id="root"'); + }); + }); +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index 01f699e..4c2e2b0 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -35,6 +35,9 @@ import { CachedSystemSettingsService } from './services/CachedSystemSettingsServ import { PermissionCache } from './services/PermissionCache'; import { PerformanceMetrics } from './services/PerformanceMetrics'; import { RuffleService } from './services/RuffleService'; +import { StartupState } from './services/StartupState'; +import { startupGate } from './middleware/startupGate'; +import { registerPreListenStack } from './middleware/frontendServing'; import { DownloadManager } from './services/DownloadManager'; import { ConfigManager } from './game/config'; import { zipManager } from './game/zip-manager'; @@ -106,8 +109,8 @@ async function startServer() { } next(); }; - app.use('/game-proxy', gameCors, gameProxyRouter); - app.use('/game-zip', gameCors, gameZipRouter); + app.use('/game-proxy', gameCors, startupGate, gameProxyRouter); + app.use('/game-zip', gameCors, startupGate, gameZipRouter); // When the backend also serves the SPA, allow the app's own resources by // hash/keyword rather than the blanket 'unsafe-inline'/'unsafe-eval': @@ -212,49 +215,23 @@ async function startServer() { app.use(cookieParser()); app.use(compression()); - // Single-image deployment: the backend serves the built frontend and its SPA - // fallback. Registered before auth/maintenance so the app shell always loads - // (the SPA then reflects login/maintenance state from API responses), and - // before the API catch-all 404 in setupRoutes so client routes reach index.html. - if (config.serveFrontend) { - const distPath = config.frontendDistPath; - const indexHtml = path.join(distPath, 'index.html'); + registerPreListenStack(app); - if (fs.existsSync(indexHtml)) { - app.use( - express.static(distPath, { - index: false, - maxAge: '1y', - immutable: true, - setHeaders: (res, filePath) => { - // Hashed assets are immutable; index.html must always be revalidated. - if (filePath.endsWith('index.html')) { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - } - }, - }) - ); + // Listen before the slow work: on a network mount the database copy and cache + // pre-warm can run for minutes, and a closed port shows the user a connection + // error instead of a page that explains itself. + const server = app.listen(config.port, config.host, () => { + logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`); + logger.info(`📁 Flashpoint path: ${config.flashpointPath}`); + logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`); + logger.info(`🌍 Environment: ${config.nodeEnv}`); + }); - const backendPrefixes = ['/api/', '/game-proxy/', '/game-zip/', '/proxy/']; - app.get('*', (req, res, next) => { - if ( - req.path === '/health' || - backendPrefixes.some((prefix) => req.path.startsWith(prefix)) - ) { - next(); - return; - } - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.sendFile(indexHtml); - }); - - logger.info(`đŸ–Ĩī¸ Serving frontend from ${distPath}`); - } else { - logger.warn( - `âš ī¸ SERVE_FRONTEND is enabled but no build was found at ${indexHtml} — skipping static serving` - ); - } - } + // Prevent connection exhaustion - critical for handling game file requests + server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout + server.headersTimeout = 66000; // Must be > keepAliveTimeout + server.timeout = 120000; // 2 min max for any request (including game file streaming) + server.maxConnections = 500; // Defense-in-depth against connection exhaustion app.use(requestTimeout(TimeoutConfig.DEFAULT)); @@ -278,6 +255,7 @@ async function startServer() { app.use(maintenanceMode); try { + StartupState.setPhase('Connecting to the Flashpoint database'); await DatabaseService.initialize(); logger.info('Database connection established'); DownloadedReconciler.reconcile().catch((error: unknown) => @@ -289,6 +267,7 @@ async function startServer() { } try { + StartupState.setPhase('Preparing application data'); await UserDatabaseService.initialize(); logger.info('User database connection established'); } catch (error) { @@ -301,6 +280,7 @@ async function startServer() { logger.info(`[Edition] Flashpoint version: ${config.flashpointVersionString}`); } + StartupState.setPhase('Warming up game search'); GameSearchCache.prewarmCache().catch((error) => { logger.warn('Failed to pre-warm game search cache:', error); }); @@ -374,6 +354,7 @@ async function startServer() { } try { + StartupState.setPhase('Loading game service configuration'); await ConfigManager.loadConfig(config.flashpointPath); logger.info('🎮 Game service configuration loaded (integrated)'); } catch (error) { @@ -401,6 +382,10 @@ async function startServer() { setupRoutes(app); app.use(errorHandler); + // Routes are live from here on, so stop short-circuiting requests. + StartupState.markReady(); + logger.info('✅ Server ready to accept requests'); + PermissionCache.startCleanup(); logger.info('✅ Permission cache initialized'); @@ -418,19 +403,6 @@ async function startServer() { logger.warn(`File logging unavailable: ${loggingStatus.fileError}`); } - const server = app.listen(config.port, config.host, () => { - logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`); - logger.info(`📁 Flashpoint path: ${config.flashpointPath}`); - logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`); - logger.info(`🌍 Environment: ${config.nodeEnv}`); - }); - - // Prevent connection exhaustion - critical for handling game file requests - server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout - server.headersTimeout = 66000; // Must be > keepAliveTimeout - server.timeout = 120000; // 2 min max for any request (including game file streaming) - server.maxConnections = 500; // Defense-in-depth against connection exhaustion - const playTrackingService = new PlayTrackingService(); const playSessionCleanupInterval = setInterval( () => { diff --git a/backend/src/services/StartupState.test.ts b/backend/src/services/StartupState.test.ts new file mode 100644 index 0000000..24ace1b --- /dev/null +++ b/backend/src/services/StartupState.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +import { StartupState } from './StartupState'; + +beforeEach(() => { + StartupState.reset(); +}); + +describe('StartupState', () => { + it('starts not ready with an initial phase', () => { + expect(StartupState.isReady()).toBe(false); + expect(StartupState.getPhase()).toBe('Starting up'); + }); + + it('reports the current phase while starting', () => { + StartupState.setPhase('Connecting to the Flashpoint database'); + + expect(StartupState.getPhase()).toBe('Connecting to the Flashpoint database'); + expect(StartupState.isReady()).toBe(false); + }); + + it('reports ready once startup completes', () => { + StartupState.setPhase('Warming up game search'); + StartupState.markReady(); + + expect(StartupState.isReady()).toBe(true); + expect(StartupState.getPhase()).toBe('Ready'); + }); + + it('ignores late phase updates from background work', () => { + StartupState.markReady(); + StartupState.setPhase('Installing Ruffle'); + + expect(StartupState.isReady()).toBe(true); + expect(StartupState.getPhase()).toBe('Ready'); + }); +}); diff --git a/backend/src/services/StartupState.ts b/backend/src/services/StartupState.ts new file mode 100644 index 0000000..30c3a09 --- /dev/null +++ b/backend/src/services/StartupState.ts @@ -0,0 +1,39 @@ +import { logger } from '../utils/logger'; + +/** + * Tracks how far startup has progressed so the HTTP listener can open before the + * slow work finishes. On a network mount the database copy and cache pre-warm + * take long enough that a browser hitting the port meanwhile would otherwise get + * a connection error instead of a page. + */ +export class StartupState { + private static ready = false; + private static phase = 'Starting up'; + + static isReady(): boolean { + return this.ready; + } + + static getPhase(): string { + return this.phase; + } + + static setPhase(phase: string): void { + if (this.ready) { + return; + } + this.phase = phase; + logger.info(`[Startup] ${phase}`); + } + + static markReady(): void { + this.ready = true; + this.phase = 'Ready'; + } + + /** Test seam: startup state is process-wide and survives between suites. */ + static reset(): void { + this.ready = false; + this.phase = 'Starting up'; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 0ec7a33..de77508 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,7 +68,9 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 40s + # The port opens early and /health reports "starting" until the database + # copy and cache pre-warm finish, which is minutes on a network share. + start_period: 300s logging: driver: json-file options: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbe7643..2bd4f86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,9 @@ import { ErrorBoundary } from './components/common/ErrorBoundary'; import { NetworkStatusIndicator } from './components/common/NetworkStatusIndicator'; import { MobileWarningDialog } from './components/common/MobileWarningDialog'; import { RouteLoadingFallback } from './components/common/RouteLoadingFallback'; +import { ServerStartingScreen } from './components/common/ServerStartingScreen'; import { useAuthStore } from './store/auth'; +import { useServerStatusStore } from './store/serverStatus'; import { authSettingsApi, authApi } from './lib/api'; import { usePublicSettings } from './hooks/usePublicSettings'; import { Toaster } from '@/components/ui/sonner'; @@ -92,6 +94,7 @@ const ActivitiesView = lazy(() => function App() { const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore(); + const isServerStarting = useServerStatusStore((state) => state.isStarting); const navigate = useNavigate(); const location = useLocation(); const { data: publicSettings } = usePublicSettings(); @@ -177,6 +180,16 @@ function App() { } }, [isGuest, authSettings, location.pathname, location.search, clearAuth, navigate]); + // Takes over the whole app: every route needs an API the server cannot serve + // yet, so routing on would only render broken shells. + if (isServerStarting) { + return ( + + + + ); + } + return ( diff --git a/frontend/src/components/common/ServerStartingScreen.test.tsx b/frontend/src/components/common/ServerStartingScreen.test.tsx new file mode 100644 index 0000000..18b5696 --- /dev/null +++ b/frontend/src/components/common/ServerStartingScreen.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; + +vi.mock('@/components/auth/ParticleNetworkBackground', () => ({ + ParticleNetworkBackground: () =>
, +})); + +import { ServerStartingScreen } from './ServerStartingScreen'; +import { useServerStatusStore } from '@/store/serverStatus'; + +const POLL_INTERVAL_MS = 2000; + +function startingResponse(phase: string): Response { + return { + ok: false, + status: 503, + json: async () => ({ status: 'starting', phase }), + } as unknown as Response; +} + +/** What a proxy in front of a dead backend returns: a reply, but not ours. */ +function proxyErrorResponse(): Response { + return { + ok: false, + status: 502, + json: async () => { + throw new Error('not JSON'); + }, + } as unknown as Response; +} + +function readyResponse(): Response { + return { + ok: true, + status: 200, + json: async () => ({ status: 'ok' }), + } as unknown as Response; +} + +let fetchMock: ReturnType; +let reload: ReturnType; + +/** Advance the poll timer and let the awaited fetch chain settle. */ +async function advanceOnePoll(): Promise { + await act(async () => { + vi.advanceTimersByTime(POLL_INTERVAL_MS); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + useServerStatusStore.setState({ isStarting: true, phase: 'Starting up' }); + + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + reload = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, reload }, + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('ServerStartingScreen', () => { + it('shows the phase the server reports', async () => { + fetchMock.mockResolvedValue(startingResponse('Warming up game search')); + + render(); + await advanceOnePoll(); + + expect(screen.getByText(/Server is starting/i)).toBeInTheDocument(); + expect(screen.getByText(/Warming up game search/)).toBeInTheDocument(); + }); + + it('advances the phase as startup progresses', async () => { + fetchMock + .mockResolvedValueOnce(startingResponse('Connecting to the Flashpoint database')) + .mockResolvedValue(startingResponse('Loading game service configuration')); + + render(); + await advanceOnePoll(); + await advanceOnePoll(); + + expect(screen.getByText(/Loading game service configuration/)).toBeInTheDocument(); + }); + + it('reloads once the server reports ready', async () => { + fetchMock.mockResolvedValue(readyResponse()); + + render(); + await advanceOnePoll(); + + expect(reload).toHaveBeenCalled(); + }); + + it('warns after repeated unreachable polls', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + render(); + for (let i = 0; i < 9; i++) { + await advanceOnePoll(); + } + + expect(screen.getByText(/Server is not responding/i)).toBeInTheDocument(); + }); + + it('treats a proxy error page as unreachable, not as a live server', async () => { + fetchMock.mockResolvedValue(proxyErrorResponse()); + + render(); + for (let i = 0; i < 9; i++) { + await advanceOnePoll(); + } + + expect(screen.getByText(/Server is not responding/i)).toBeInTheDocument(); + expect(reload).not.toHaveBeenCalled(); + }); + + it('recovers the starting message when the server answers again', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + render(); + for (let i = 0; i < 9; i++) { + await advanceOnePoll(); + } + expect(screen.getByText(/Server is not responding/i)).toBeInTheDocument(); + + fetchMock.mockResolvedValue(startingResponse('Preparing application data')); + await advanceOnePoll(); + + expect(screen.getByText(/Server is starting/i)).toBeInTheDocument(); + expect(screen.queryByText(/Server is not responding/i)).not.toBeInTheDocument(); + }); + + it('stops polling after unmount', async () => { + fetchMock.mockResolvedValue(startingResponse('Starting up')); + + const { unmount } = render(); + await advanceOnePoll(); + const callsBeforeUnmount = fetchMock.mock.calls.length; + + unmount(); + await advanceOnePoll(); + + expect(fetchMock.mock.calls.length).toBe(callsBeforeUnmount); + }); +}); diff --git a/frontend/src/components/common/ServerStartingScreen.tsx b/frontend/src/components/common/ServerStartingScreen.tsx new file mode 100644 index 0000000..072158e --- /dev/null +++ b/frontend/src/components/common/ServerStartingScreen.tsx @@ -0,0 +1,155 @@ +import { useEffect, useRef, useState } from 'react'; +import { Loader2, AlertTriangle } from 'lucide-react'; +import { PublicLayout } from '@/components/layout/PublicLayout'; +import { ParticleNetworkBackground } from '@/components/auth/ParticleNetworkBackground'; +import { useServerStatusStore } from '@/store/serverStatus'; + +const POLL_INTERVAL_MS = 2000; +/** A server that accepts the connection but never answers is not a live one. */ +const POLL_TIMEOUT_MS = 5000; +/** Consecutive unreachable polls before we stop calling this a slow start. */ +const UNREACHABLE_POLLS_BEFORE_WARNING = 8; + +interface HealthBody { + status?: string; + phase?: string; +} + +/** + * Read the backend's own startup marker. A reachable proxy in front of a dead + * backend still answers (Vite's dev proxy with 500, nginx with 502), so the + * status code alone cannot tell "starting" from "gone" — only this body can. + */ +async function readStartingMarker(response: Response): Promise { + try { + const body: unknown = await response.json(); + if (typeof body === 'object' && body !== null && 'status' in body) { + const parsed = body as HealthBody; + return parsed.status === 'starting' ? parsed : null; + } + } catch { + // A proxy error page, not our JSON. + } + return null; +} + +/** + * Shown while the backend is up but not yet serving requests — on a network + * mount the database copy and cache pre-warm can run for minutes after the port + * opens. Reloads once the server reports ready so the app starts from a clean + * state rather than resuming half-failed queries. + */ +export function ServerStartingScreen() { + const phase = useServerStatusStore((state) => state.phase); + const setStarting = useServerStatusStore((state) => state.setStarting); + const [isUnreachable, setIsUnreachable] = useState(false); + const unreachablePollsRef = useRef(0); + const pollerRef = useRef | null>(null); + const setStartingRef = useRef(setStarting); + + useEffect(() => { + setStartingRef.current = setStarting; + }, [setStarting]); + + useEffect(() => { + let cancelled = false; + + const markUnreachable = () => { + unreachablePollsRef.current += 1; + if (unreachablePollsRef.current >= UNREACHABLE_POLLS_BEFORE_WARNING) { + setIsUnreachable(true); + } + }; + + const poll = async () => { + const timeoutController = new AbortController(); + const timeout = setTimeout(() => timeoutController.abort(), POLL_TIMEOUT_MS); + + try { + // Plain fetch: the API client's interceptors would re-flag this request + // as a startup failure and fight the polling loop. + const response = await fetch('/health', { + cache: 'no-store', + signal: timeoutController.signal, + }); + if (cancelled) return; + + if (response.ok) { + window.location.reload(); + return; + } + + const marker = await readStartingMarker(response); + if (cancelled) return; + + if (marker === null) { + // Someone answered, but not the backend we are waiting for. + markUnreachable(); + return; + } + + unreachablePollsRef.current = 0; + setIsUnreachable(false); + // Report progress: the phase the interceptor captured is the one from + // the first failed request and would otherwise never advance. + if (marker.phase) { + setStartingRef.current(marker.phase); + } + } catch { + // A thrown fetch is a dead or hung server, not a slow one — startup + // failures exit the process, and promising a reload would be a lie. + if (cancelled) return; + markUnreachable(); + } finally { + clearTimeout(timeout); + } + }; + + pollerRef.current = setInterval(() => { + void poll(); + }, POLL_INTERVAL_MS); + void poll(); + + return () => { + cancelled = true; + if (pollerRef.current !== null) { + clearInterval(pollerRef.current); + pollerRef.current = null; + } + }; + }, []); + + return ( + }> +
+ {isUnreachable ? ( + <> +
+
+ ); +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 80b0bd9..64aa78a 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import { toast } from 'sonner'; import { useAuthStore } from '@/store/auth'; import { useSharedAccessStore } from '@/store/sharedAccess'; +import { useServerStatusStore } from '@/store/serverStatus'; declare module 'axios' { export interface InternalAxiosRequestConfig { @@ -77,6 +78,15 @@ apiClient.interceptors.response.use( const status = error.response.status; + // The backend opens its port before startup finishes, so it can explain + // itself instead of refusing connections. Surface the starting screen rather + // than a generic server-error toast. + if (status === 503 && error.response.data?.starting === true) { + const phase = error.response.data?.phase; + useServerStatusStore.getState().setStarting(typeof phase === 'string' ? phase : null); + return Promise.reject(error); + } + if (status === 404 && !originalRequest._skip404Toast) { toast.error('Resource not found', { id: 'not-found' }); } diff --git a/frontend/src/store/serverStatus.ts b/frontend/src/store/serverStatus.ts new file mode 100644 index 0000000..121f4c8 --- /dev/null +++ b/frontend/src/store/serverStatus.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +interface ServerStatusState { + /** True while the backend answers requests with "still starting". */ + isStarting: boolean; + /** What the backend reported it was doing, e.g. "Warming up game search". */ + phase: string | null; + + /** Cleared only by the reload the starting screen performs once ready. */ + setStarting: (phase: string | null) => void; +} + +export const useServerStatusStore = create((set) => ({ + isStarting: false, + phase: null, + + setStarting: (phase) => set({ isStarting: true, phase }), +})); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 723b328..bbd55a2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -119,6 +119,13 @@ export default defineConfig({ target: 'http://localhost:3100', changeOrigin: true, }, + // Without this the startup poll hits Vite's SPA fallback, which answers + // 200 with index.html and makes the "server is starting" screen reload + // itself every two seconds. + '/health': { + target: 'http://localhost:3100', + changeOrigin: true, + }, '/game-proxy': { target: 'http://localhost:3100', changeOrigin: true, diff --git a/package-lock.json b/package-lock.json index b0c102f..a8079fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,9 +66,11 @@ "@types/mime-types": "^3.0.1", "@types/node": "^20.11.25", "@types/node-cron": "^3.0.11", + "@types/supertest": "^7.2.1", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "eslint": "^8.57.0", + "supertest": "^7.2.2", "tsx": "^4.7.1", "typescript": "^5.4.2", "vitest": "^1.3.1" @@ -5763,6 +5765,19 @@ "node": ">=18" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7182,6 +7197,16 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -10047,6 +10072,13 @@ "@types/express": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -10229,6 +10261,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -10366,6 +10405,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -10707,6 +10770,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -11286,6 +11356,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -11376,6 +11456,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -11758,6 +11845,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -12127,6 +12225,13 @@ "node": ">= 6" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -12242,6 +12347,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -16358,6 +16481,90 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",