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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
Generated
+207
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user