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.
This commit is contained in:
Darkraise
2026-08-12 14:05:14 +07:00
parent 3672022ea0
commit 74aa0cf067
17 changed files with 1011 additions and 150 deletions
+12 -93
View File
@@ -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"
+2
View File
@@ -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"
+72
View File
@@ -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);
}
@@ -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<Response>;
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);
});
});
+35
View File
@@ -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(),
});
}
@@ -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 = '<!doctype html><title>Flashpoint Web</title><div id="root"></div>';
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"');
});
});
});
+28 -56
View File
@@ -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(
() => {
+41
View File
@@ -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');
});
});
+39
View File
@@ -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';
}
}
+3 -1
View File
@@ -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:
+13
View File
@@ -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 (
<ErrorBoundary>
<ServerStartingScreen />
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>
@@ -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: () => <div data-testid="particles" />,
}));
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<typeof vi.fn>;
let reload: ReturnType<typeof vi.fn>;
/** Advance the poll timer and let the awaited fetch chain settle. */
async function advanceOnePoll(): Promise<void> {
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(<ServerStartingScreen />);
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(<ServerStartingScreen />);
await advanceOnePoll();
await advanceOnePoll();
expect(screen.getByText(/Loading game service configuration/)).toBeInTheDocument();
});
it('reloads once the server reports ready', async () => {
fetchMock.mockResolvedValue(readyResponse());
render(<ServerStartingScreen />);
await advanceOnePoll();
expect(reload).toHaveBeenCalled();
});
it('warns after repeated unreachable polls', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
render(<ServerStartingScreen />);
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(<ServerStartingScreen />);
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(<ServerStartingScreen />);
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(<ServerStartingScreen />);
await advanceOnePoll();
const callsBeforeUnmount = fetchMock.mock.calls.length;
unmount();
await advanceOnePoll();
expect(fetchMock.mock.calls.length).toBe(callsBeforeUnmount);
});
});
@@ -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<HealthBody | null> {
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<ReturnType<typeof setInterval> | 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 (
<PublicLayout background={<ParticleNetworkBackground />}>
<div
className="w-full max-w-md text-center animate-fade-in-up"
role="status"
aria-live="polite"
>
{isUnreachable ? (
<>
<AlertTriangle className="mx-auto h-10 w-10 text-destructive" aria-hidden="true" />
<h1 className="mt-6 text-2xl font-semibold text-foreground">
Server is not responding
</h1>
<p className="mt-3 text-muted-foreground">
It stopped answering while starting up. Check the server logs — this page will recover
on its own if the server comes back.
</p>
</>
) : (
<>
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" aria-hidden="true" />
<h1 className="mt-6 text-2xl font-semibold text-foreground">Server is starting</h1>
<p className="mt-3 text-muted-foreground">
{phase ?? 'Getting things ready'}. This can take a few minutes on a network share.
</p>
<p className="mt-4 text-sm text-muted-foreground">
The page reloads automatically when the server is ready.
</p>
</>
)}
</div>
</PublicLayout>
);
}
+10
View File
@@ -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' });
}
+18
View File
@@ -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<ServerStatusState>((set) => ({
isStarting: false,
phase: null,
setStarting: (phase) => set({ isStarting: true, phase }),
}));
+7
View File
@@ -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,
+207
View File
@@ -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",