159 lines
4.6 KiB
JavaScript
159 lines
4.6 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
import express from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import { loggerMiddleware } from './middleware/logger.js';
|
|
import env from '../config/env.js';
|
|
import { authMiddleware } from './middleware/auth.js';
|
|
import { ensureConnection } from '../db/store.js';
|
|
import { closeAll as closeAllSse } from './sse.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
async function loadRoutes(app, basePath = '') {
|
|
const routesDir = path.join(__dirname, 'routes');
|
|
|
|
async function walk(dir) {
|
|
for (const file of fs.readdirSync(dir)) {
|
|
const fullPath = path.join(dir, file);
|
|
const stat = fs.statSync(fullPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
await walk(fullPath);
|
|
} else if (file.endsWith('.js')) {
|
|
const routeModule = await import(pathToFileURL(fullPath).href);
|
|
if (routeModule.register) {
|
|
routeModule.register(app, basePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await walk(routesDir);
|
|
}
|
|
|
|
let server;
|
|
let starting;
|
|
let startedAt;
|
|
|
|
function corsMiddleware(req, res, next) {
|
|
const origin = req.headers.origin;
|
|
|
|
if (origin && env.corsOrigins.includes(origin)) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
}
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
return res.sendStatus(204);
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
async function createApi() {
|
|
const app = express();
|
|
|
|
app.use((req, res, next) => {
|
|
// Do not attach no-store to SSE endpoint
|
|
if (req.path !== '/api/events') {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
}
|
|
next();
|
|
});
|
|
app.use(corsMiddleware);
|
|
app.use(express.json({ limit: env.bodyLimit }));
|
|
app.use(cookieParser());
|
|
app.use(loggerMiddleware);
|
|
app.use(authMiddleware);
|
|
|
|
if (startedAt) {
|
|
app.use((req, res, next) => {
|
|
res.setHeader('X-Server-Started-At', startedAt);
|
|
next();
|
|
});
|
|
}
|
|
|
|
await loadRoutes(app, '/api');
|
|
|
|
app.use((err, _req, res, next) => {
|
|
if (err.type === 'entity.too.large') {
|
|
return res.status(413).json({ error: 'Payload too large' });
|
|
}
|
|
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
|
return res.status(400).json({ error: 'Invalid JSON body' });
|
|
}
|
|
next(err);
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
export async function startApi() {
|
|
if (server) {
|
|
return server;
|
|
}
|
|
if (starting) {
|
|
return starting;
|
|
}
|
|
|
|
starting = (async () => {
|
|
await ensureConnection();
|
|
|
|
startedAt = new Date().toISOString();
|
|
const app = await createApi();
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const instance = app.listen(env.port);
|
|
|
|
instance.once('listening', () => {
|
|
server = instance;
|
|
console.log(`API listening on port ${env.port}`);
|
|
resolve();
|
|
});
|
|
|
|
instance.once('error', (err) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
let detail = '';
|
|
try {
|
|
const pids = execSync(`lsof -ti :${env.port}`, { encoding: 'utf8' }).trim();
|
|
if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`;
|
|
} catch {
|
|
// lsof unavailable or no match
|
|
}
|
|
console.error(`Port ${env.port} is already in use${detail}. Check with: lsof -i :${env.port}`);
|
|
} else {
|
|
console.error('Server error:', err.message);
|
|
}
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
return server;
|
|
})();
|
|
|
|
try {
|
|
return await starting;
|
|
} finally {
|
|
starting = undefined;
|
|
}
|
|
}
|
|
|
|
export async function stopApi() {
|
|
if (!server) return;
|
|
// Long-lived SSE (and keep-alive) sockets otherwise block server.close()
|
|
// forever — node --watch hangs on "Waiting for graceful termination...".
|
|
closeAllSse();
|
|
await new Promise((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
server.closeAllConnections();
|
|
});
|
|
server = undefined;
|
|
}
|