246 lines
8.2 KiB
JavaScript
246 lines
8.2 KiB
JavaScript
import fs from "fs";
|
|
import {performance} from "perf_hooks";
|
|
import express from "express";
|
|
import session from "express-session";
|
|
import FileStoreFactory from "session-file-store";
|
|
import {createConfig} from "./config.js";
|
|
import {createDatabase} from "./db/index.js";
|
|
import {createRepositories} from "./db/repositories.js";
|
|
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
|
|
import {createModelService} from "./services/modelService.js";
|
|
import {createChatService} from "./services/chatService.js";
|
|
import {createAuthRouter} from "./routes/auth.js";
|
|
import {createChatsRouter} from "./routes/chats.js";
|
|
import {createPagesRouter} from "./routes/pages.js";
|
|
import {createPersonasRouter} from "./routes/personas.js";
|
|
import {createSettingsRouter} from "./routes/settings.js";
|
|
import {createSystemRouter} from "./routes/system.js";
|
|
|
|
export function createCloseHandler(resources) {
|
|
let closed = false;
|
|
return async () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
|
|
const errors = [];
|
|
for (const close of resources) {
|
|
try {
|
|
await close();
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
|
|
if (errors.length) {
|
|
throw new AggregateError(errors, "Failed to close one or more resources");
|
|
}
|
|
};
|
|
}
|
|
|
|
function createLifecycleMonitors(app, config) {
|
|
const resources = [];
|
|
|
|
if (config.eventLoopLagMonitorEnabled) {
|
|
let last = performance.now();
|
|
const lagMonitor = setInterval(() => {
|
|
const now = performance.now();
|
|
const drift = now - last - 1000;
|
|
last = now;
|
|
if (drift > config.eventLoopLagWarnMs) {
|
|
console.warn("EVENT LOOP LAG", Math.round(drift), "ms", new Date().toISOString());
|
|
}
|
|
}, 1000);
|
|
lagMonitor.unref();
|
|
resources.push(() => clearInterval(lagMonitor));
|
|
}
|
|
|
|
if (config.slowRequestLoggingEnabled) {
|
|
app.use((req, res, next) => {
|
|
const start = Date.now();
|
|
res.on("finish", () => {
|
|
const ms = Date.now() - start;
|
|
if (ms > config.slowRequestWarnMs) {
|
|
console.warn("SLOW REQ", `${ms}ms`, req.method, req.url);
|
|
}
|
|
});
|
|
next();
|
|
});
|
|
}
|
|
|
|
return resources;
|
|
}
|
|
|
|
function wantsJson(req) {
|
|
const accept = req.get("Accept") || "";
|
|
const contentType = req.get("Content-Type") || "";
|
|
return req.xhr
|
|
|| req.path.startsWith("/api/")
|
|
|| accept.includes("application/json")
|
|
|| contentType.includes("application/json");
|
|
}
|
|
|
|
function notFoundHandler(req, res) {
|
|
if (wantsJson(req)) {
|
|
return res.status(404).json({error: "Not found"});
|
|
}
|
|
return res.status(404).type("text").send("Not found");
|
|
}
|
|
|
|
function errorHandler(err, req, res, next) {
|
|
if (res.headersSent) {
|
|
return next(err);
|
|
}
|
|
|
|
if (err?.type === "entity.parse.failed") {
|
|
return res.status(400).json({error: "Invalid JSON"});
|
|
}
|
|
|
|
const status = Number.isInteger(err.status) ? err.status : Number.isInteger(err.statusCode) ? err.statusCode : 500;
|
|
const message = status >= 500 ? "Internal server error" : err.message;
|
|
if (status >= 500) {
|
|
console.warn("REQUEST ERROR", status, req.method, req.url, err);
|
|
} else {
|
|
console.warn("REQUEST ERROR", status, req.method, req.url, message);
|
|
}
|
|
if (wantsJson(req)) {
|
|
return res.status(status).json({error: message});
|
|
}
|
|
return res.status(status).type("text").send(message);
|
|
}
|
|
|
|
function sameOriginProtection(config) {
|
|
return (req, res, next) => {
|
|
if (!config.sameOriginProtectionEnabled || !["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
|
|
return next();
|
|
}
|
|
|
|
const secFetchSite = req.get("Sec-Fetch-Site");
|
|
if (secFetchSite && !["same-origin", "none"].includes(secFetchSite)) {
|
|
return res.status(403).json({error: "Cross-origin request blocked"});
|
|
}
|
|
|
|
const origin = req.get("Origin");
|
|
if (!origin) return next();
|
|
|
|
let parsedOrigin;
|
|
try {
|
|
parsedOrigin = new URL(origin);
|
|
} catch {
|
|
return res.status(403).json({error: "Cross-origin request blocked"});
|
|
}
|
|
|
|
if (parsedOrigin.protocol !== `${req.protocol}:` || parsedOrigin.host !== req.get("Host")) {
|
|
return res.status(403).json({error: "Cross-origin request blocked"});
|
|
}
|
|
|
|
return next();
|
|
};
|
|
}
|
|
|
|
export function createApp(options = {}) {
|
|
const config = createConfig(options.config);
|
|
const app = express();
|
|
app.set("trust proxy", config.trustProxy);
|
|
|
|
if (config.dbPath !== ":memory:") {
|
|
fs.mkdirSync(config.dataDir, {recursive: true});
|
|
fs.mkdirSync(config.sessionsDir, {recursive: true});
|
|
}
|
|
|
|
const closeHandlers = createLifecycleMonitors(app, config);
|
|
|
|
const db = createDatabase(config);
|
|
const repositories = createRepositories(db, config);
|
|
const modelService = options.modelService || createModelService(config, options.modelOverrides);
|
|
const chatService = createChatService(repositories, modelService, config);
|
|
const FileStore = FileStoreFactory(session);
|
|
const authRateLimiters = createAuthRateLimiters(config);
|
|
const chatRateLimiters = createChatRateLimiters(config);
|
|
closeHandlers.push(
|
|
...authRateLimiters.map((limiter) => limiter.close),
|
|
...chatRateLimiters.map((limiter) => limiter.close),
|
|
() => db.close()
|
|
);
|
|
|
|
app.use(express.json({limit: "1mb"}));
|
|
app.use(express.static(config.publicDir, {
|
|
maxAge: config.staticMaxAge,
|
|
etag: config.staticEtag,
|
|
fallthrough: config.staticFallthrough
|
|
}));
|
|
app.use(session({
|
|
store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})),
|
|
secret: config.sessionSecret,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: config.cookieSecure,
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
maxAge: config.sessionMaxAgeMs
|
|
}
|
|
}));
|
|
app.use(sameOriginProtection(config));
|
|
|
|
app.locals.config = config;
|
|
app.locals.db = db;
|
|
app.locals.repositories = repositories;
|
|
app.locals.modelService = modelService;
|
|
app.locals.close = createCloseHandler(closeHandlers);
|
|
|
|
app.use(createAuthRouter({repositories, authRateLimiters}));
|
|
app.use(createSettingsRouter({repositories}));
|
|
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
|
|
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
|
app.use(createSystemRouter({modelService, config}));
|
|
app.use(createPagesRouter(config));
|
|
app.use(notFoundHandler);
|
|
app.use(errorHandler);
|
|
|
|
return app;
|
|
}
|
|
|
|
export function startServer(options = {}) {
|
|
const app = createApp(options);
|
|
const {port} = app.locals.config;
|
|
const server = app.listen(port, () => {
|
|
console.log(`Server running at http://localhost:${port}`);
|
|
});
|
|
const close = async () => {
|
|
const errors = [];
|
|
try {
|
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
try {
|
|
await app.locals.close();
|
|
} catch (error) {
|
|
if (error instanceof AggregateError) {
|
|
errors.push(...error.errors);
|
|
} else {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
if (errors.length) {
|
|
throw new AggregateError(errors, "Failed to close server cleanly");
|
|
}
|
|
};
|
|
if (options.handleSignals !== false) {
|
|
let shuttingDown = false;
|
|
const shutdown = (signal) => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
close()
|
|
.then(() => process.exit(0))
|
|
.catch((error) => {
|
|
console.error(`Failed to shut down after ${signal}`, error);
|
|
process.exit(1);
|
|
});
|
|
};
|
|
process.once("SIGINT", shutdown);
|
|
process.once("SIGTERM", shutdown);
|
|
}
|
|
return {app, server, close};
|
|
}
|