add overall more stability
This commit is contained in:
@@ -20,8 +20,12 @@ KrisHD_AI is an Express + SQLite app for account-based chat, persona management,
|
|||||||
- `PORT`: HTTP port for the Express server.
|
- `PORT`: HTTP port for the Express server.
|
||||||
- `DB_PATH`: SQLite database path. Use `:memory:` for isolated tests.
|
- `DB_PATH`: SQLite database path. Use `:memory:` for isolated tests.
|
||||||
- `SESSION_SECRET`: session signing secret. Required outside local throwaway environments.
|
- `SESSION_SECRET`: session signing secret. Required outside local throwaway environments.
|
||||||
- `COOKIE_SECURE`: set to `true` when serving over HTTPS.
|
- `COOKIE_SECURE`: set to `true` when serving over HTTPS. Required in production unless `ALLOW_INSECURE_COOKIES=true` is set.
|
||||||
|
- `ALLOW_INSECURE_COOKIES`: explicit production override for non-HTTPS deployments.
|
||||||
|
- `TRUST_PROXY`: Express trust proxy setting for reverse proxy deployments.
|
||||||
- `SESSION_MAX_AGE_MS`: cookie lifetime.
|
- `SESSION_MAX_AGE_MS`: cookie lifetime.
|
||||||
|
- `EVENT_LOOP_LAG_*`: enable and tune event-loop lag warnings.
|
||||||
|
- `SLOW_REQUEST_*`: enable and tune slow request warnings.
|
||||||
- `CHAT_LIMIT`: per-account maximum chat sessions.
|
- `CHAT_LIMIT`: per-account maximum chat sessions.
|
||||||
- `CHAT_HISTORY_LIMIT`: number of recent messages included in model prompts.
|
- `CHAT_HISTORY_LIMIT`: number of recent messages included in model prompts.
|
||||||
- `MODEL_API_BASE_URL`: base URL of the remote model backend. The server expects `/chat` and `/tags`.
|
- `MODEL_API_BASE_URL`: base URL of the remote model backend. The server expects `/chat` and `/tags`.
|
||||||
@@ -31,6 +35,7 @@ KrisHD_AI is an Express + SQLite app for account-based chat, persona management,
|
|||||||
- `MODELS_CACHE_TTL_MS`: model list cache TTL.
|
- `MODELS_CACHE_TTL_MS`: model list cache TTL.
|
||||||
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
|
- `AUTH_RATE_LIMIT_*`: rate-limit window and thresholds for registration/login.
|
||||||
- `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints.
|
- `CHAT_RATE_LIMIT_*`: rate-limit window and thresholds for chat and retry endpoints.
|
||||||
|
- `RATE_LIMIT_CLEANUP_INTERVAL_MS`: interval for pruning stale in-memory rate-limit buckets.
|
||||||
- `DROP_LEGACY_CHATS`: when `true`, migrate any legacy `chats` table into `chat_sessions` / `chat_messages` and remove the old table.
|
- `DROP_LEGACY_CHATS`: when `true`, migrate any legacy `chats` table into `chat_sessions` / `chat_messages` and remove the old table.
|
||||||
|
|
||||||
## Model Backend Expectations
|
## Model Backend Expectations
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const files = [
|
|||||||
"src/db/schema.js",
|
"src/db/schema.js",
|
||||||
"src/middleware/auth.js",
|
"src/middleware/auth.js",
|
||||||
"src/middleware/rateLimit.js",
|
"src/middleware/rateLimit.js",
|
||||||
|
"src/middleware/rateLimiters.js",
|
||||||
"src/middleware/validate.js",
|
"src/middleware/validate.js",
|
||||||
"src/routes/auth.js",
|
"src/routes/auth.js",
|
||||||
"src/routes/chats.js",
|
"src/routes/chats.js",
|
||||||
|
|||||||
+93
-54
@@ -6,7 +6,7 @@ import FileStoreFactory from "session-file-store";
|
|||||||
import {createConfig} from "./config.js";
|
import {createConfig} from "./config.js";
|
||||||
import {createDatabase} from "./db/index.js";
|
import {createDatabase} from "./db/index.js";
|
||||||
import {createRepositories} from "./db/repositories.js";
|
import {createRepositories} from "./db/repositories.js";
|
||||||
import {createRateLimiter} from "./middleware/rateLimit.js";
|
import {createAuthRateLimiters, createChatRateLimiters} from "./middleware/rateLimiters.js";
|
||||||
import {createModelService} from "./services/modelService.js";
|
import {createModelService} from "./services/modelService.js";
|
||||||
import {createChatService} from "./services/chatService.js";
|
import {createChatService} from "./services/chatService.js";
|
||||||
import {createAuthRouter} from "./routes/auth.js";
|
import {createAuthRouter} from "./routes/auth.js";
|
||||||
@@ -16,72 +16,104 @@ import {createPersonasRouter} from "./routes/personas.js";
|
|||||||
import {createSettingsRouter} from "./routes/settings.js";
|
import {createSettingsRouter} from "./routes/settings.js";
|
||||||
import {createSystemRouter} from "./routes/system.js";
|
import {createSystemRouter} from "./routes/system.js";
|
||||||
|
|
||||||
|
function createCloseHandler(resources) {
|
||||||
|
let closed = false;
|
||||||
|
return async () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
|
||||||
|
for (const close of resources) {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = Number.isInteger(err.status) ? err.status : 500;
|
||||||
|
const message = status >= 500 ? "Internal server error" : err.message;
|
||||||
|
console.warn("REQUEST ERROR", status, req.method, req.url, err);
|
||||||
|
if (wantsJson(req)) {
|
||||||
|
return res.status(status).json({error: message});
|
||||||
|
}
|
||||||
|
return res.status(status).type("text").send(message);
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp(options = {}) {
|
export function createApp(options = {}) {
|
||||||
const config = createConfig(options.config);
|
const config = createConfig(options.config);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", config.trustProxy);
|
||||||
|
|
||||||
if (config.dbPath !== ":memory:") {
|
if (config.dbPath !== ":memory:") {
|
||||||
fs.mkdirSync(config.sessionsDir, {recursive: true});
|
fs.mkdirSync(config.sessionsDir, {recursive: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
let last = performance.now();
|
const closeHandlers = createLifecycleMonitors(app, config);
|
||||||
const lagMonitor = setInterval(() => {
|
|
||||||
const now = performance.now();
|
|
||||||
const drift = now - last - 1000;
|
|
||||||
last = now;
|
|
||||||
if (drift > 50) {
|
|
||||||
console.log("EVENT LOOP LAG", Math.round(drift), "ms", new Date().toISOString());
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
lagMonitor.unref();
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const start = Date.now();
|
|
||||||
res.on("finish", () => {
|
|
||||||
const ms = Date.now() - start;
|
|
||||||
if (ms > 50) console.log("SLOW REQ", `${ms}ms`, req.method, req.url);
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = createDatabase(config);
|
const db = createDatabase(config);
|
||||||
const repositories = createRepositories(db, config);
|
const repositories = createRepositories(db, config);
|
||||||
const modelService = options.modelService || createModelService(config, options.modelOverrides);
|
const modelService = options.modelService || createModelService(config, options.modelOverrides);
|
||||||
const chatService = createChatService(repositories, modelService, config);
|
const chatService = createChatService(repositories, modelService, config);
|
||||||
const FileStore = FileStoreFactory(session);
|
const FileStore = FileStoreFactory(session);
|
||||||
|
const authRateLimiters = createAuthRateLimiters(config);
|
||||||
const authRateLimiters = [
|
const chatRateLimiters = createChatRateLimiters(config);
|
||||||
createRateLimiter({
|
closeHandlers.push(
|
||||||
windowMs: config.authRateLimitWindowMs,
|
...authRateLimiters.map((limiter) => limiter.close),
|
||||||
max: config.authRateLimitMaxPerIp,
|
...chatRateLimiters.map((limiter) => limiter.close),
|
||||||
keyGenerator: (req) => `auth:ip:${req.ip}`,
|
() => db.close()
|
||||||
message: "Too many authentication attempts from this IP. Please try again later."
|
);
|
||||||
}),
|
|
||||||
createRateLimiter({
|
|
||||||
windowMs: config.authRateLimitWindowMs,
|
|
||||||
max: config.authRateLimitMaxPerAccount,
|
|
||||||
keyGenerator: (req) => {
|
|
||||||
const username = String(req.body?.username || "").trim().toLowerCase();
|
|
||||||
return username ? `auth:user:${username}` : null;
|
|
||||||
},
|
|
||||||
message: "Too many authentication attempts for this account. Please try again later."
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
const chatRateLimiters = [
|
|
||||||
createRateLimiter({
|
|
||||||
windowMs: config.chatRateLimitWindowMs,
|
|
||||||
max: config.chatRateLimitMaxPerIp,
|
|
||||||
keyGenerator: (req) => `chat:ip:${req.ip}`,
|
|
||||||
message: "Too many chat requests from this IP. Please slow down."
|
|
||||||
}),
|
|
||||||
createRateLimiter({
|
|
||||||
windowMs: config.chatRateLimitWindowMs,
|
|
||||||
max: config.chatRateLimitMaxPerAccount,
|
|
||||||
keyGenerator: (req) => req.session.user ? `chat:user:${req.session.user}` : null,
|
|
||||||
message: "Too many chat requests for this account. Please slow down."
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
app.use(express.json({limit: "1mb"}));
|
app.use(express.json({limit: "1mb"}));
|
||||||
app.use(express.static(config.publicDir));
|
app.use(express.static(config.publicDir));
|
||||||
@@ -102,6 +134,7 @@ export function createApp(options = {}) {
|
|||||||
app.locals.db = db;
|
app.locals.db = db;
|
||||||
app.locals.repositories = repositories;
|
app.locals.repositories = repositories;
|
||||||
app.locals.modelService = modelService;
|
app.locals.modelService = modelService;
|
||||||
|
app.locals.close = createCloseHandler(closeHandlers);
|
||||||
|
|
||||||
app.use(createAuthRouter({repositories, authRateLimiters}));
|
app.use(createAuthRouter({repositories, authRateLimiters}));
|
||||||
app.use(createSettingsRouter({repositories}));
|
app.use(createSettingsRouter({repositories}));
|
||||||
@@ -109,6 +142,8 @@ export function createApp(options = {}) {
|
|||||||
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
||||||
app.use(createSystemRouter({modelService}));
|
app.use(createSystemRouter({modelService}));
|
||||||
app.use(createPagesRouter(config));
|
app.use(createPagesRouter(config));
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -119,5 +154,9 @@ export function startServer(options = {}) {
|
|||||||
const server = app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
console.log(`Server running at http://localhost:${port}`);
|
console.log(`Server running at http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
return {app, server};
|
const close = async () => {
|
||||||
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
|
await app.locals.close();
|
||||||
|
};
|
||||||
|
return {app, server, close};
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-2
@@ -10,10 +10,20 @@ const int = (value, fallback) => {
|
|||||||
return Number.isInteger(parsed) ? parsed : fallback;
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trustProxy = (value, fallback = false) => {
|
||||||
|
if (value == null || value === "") return fallback;
|
||||||
|
const normalized = String(value).toLowerCase();
|
||||||
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||||
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : value;
|
||||||
|
};
|
||||||
|
|
||||||
export function createConfig(overrides = {}) {
|
export function createConfig(overrides = {}) {
|
||||||
const rootDir = overrides.rootDir || process.cwd();
|
const rootDir = overrides.rootDir || process.cwd();
|
||||||
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
|
const dataDir = overrides.dataDir || path.resolve(rootDir, "data");
|
||||||
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
|
const sessionsDir = overrides.sessionsDir || path.resolve(rootDir, "sessions");
|
||||||
|
const testMode = bool(process.env.TEST_MODE, process.env.NODE_ENV === "test");
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
rootDir,
|
rootDir,
|
||||||
@@ -24,7 +34,13 @@ export function createConfig(overrides = {}) {
|
|||||||
dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"),
|
dbPath: process.env.DB_PATH || path.resolve(dataDir, "app.db"),
|
||||||
sessionSecret: process.env.SESSION_SECRET || "change-me-session-secret",
|
sessionSecret: process.env.SESSION_SECRET || "change-me-session-secret",
|
||||||
cookieSecure: bool(process.env.COOKIE_SECURE, false),
|
cookieSecure: bool(process.env.COOKIE_SECURE, false),
|
||||||
|
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
|
||||||
|
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
|
||||||
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
||||||
|
eventLoopLagMonitorEnabled: bool(process.env.EVENT_LOOP_LAG_MONITOR_ENABLED, !testMode),
|
||||||
|
eventLoopLagWarnMs: int(process.env.EVENT_LOOP_LAG_WARN_MS, 50),
|
||||||
|
slowRequestLoggingEnabled: bool(process.env.SLOW_REQUEST_LOGGING_ENABLED, !testMode),
|
||||||
|
slowRequestWarnMs: int(process.env.SLOW_REQUEST_WARN_MS, 50),
|
||||||
chatLimit: int(process.env.CHAT_LIMIT, 10),
|
chatLimit: int(process.env.CHAT_LIMIT, 10),
|
||||||
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20),
|
chatHistoryLimit: int(process.env.CHAT_HISTORY_LIMIT, 20),
|
||||||
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
|
modelApiBaseUrl: process.env.MODEL_API_BASE_URL || "https://ai.krishd.ch/api",
|
||||||
@@ -38,9 +54,20 @@ export function createConfig(overrides = {}) {
|
|||||||
chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
|
chatRateLimitWindowMs: int(process.env.CHAT_RATE_LIMIT_WINDOW_MS, 60 * 1000),
|
||||||
chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
|
chatRateLimitMaxPerIp: int(process.env.CHAT_RATE_LIMIT_MAX_PER_IP, 60),
|
||||||
chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
|
chatRateLimitMaxPerAccount: int(process.env.CHAT_RATE_LIMIT_MAX_PER_ACCOUNT, 30),
|
||||||
testMode: bool(process.env.TEST_MODE, process.env.NODE_ENV === "test"),
|
rateLimitCleanupIntervalMs: int(process.env.RATE_LIMIT_CLEANUP_INTERVAL_MS, 60 * 1000),
|
||||||
|
testMode,
|
||||||
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true)
|
dropLegacyChats: bool(process.env.DROP_LEGACY_CHATS, true)
|
||||||
};
|
};
|
||||||
|
|
||||||
return {...config, ...overrides};
|
const resolved = {...config, ...overrides};
|
||||||
|
if (process.env.NODE_ENV === "production" && !resolved.testMode) {
|
||||||
|
if (resolved.sessionSecret === "change-me-session-secret") {
|
||||||
|
throw new Error("SESSION_SECRET must be set in production.");
|
||||||
|
}
|
||||||
|
if (!resolved.cookieSecure && !resolved.allowInsecureCookies) {
|
||||||
|
throw new Error("COOKIE_SECURE must be true in production unless ALLOW_INSECURE_COOKIES=true is set.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,26 @@ function pruneBucket(bucket, windowMs, now) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRateLimiter({windowMs, max, keyGenerator, message}) {
|
export function createRateLimiter({windowMs, max, keyGenerator, message, cleanupIntervalMs = 60 * 1000}) {
|
||||||
const hits = new Map();
|
const hits = new Map();
|
||||||
|
let cleanupTimer = null;
|
||||||
|
|
||||||
return (req, res, next) => {
|
const cleanup = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, bucket] of hits) {
|
||||||
|
pruneBucket(bucket, windowMs, now);
|
||||||
|
if (!bucket.length) {
|
||||||
|
hits.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cleanupIntervalMs > 0) {
|
||||||
|
cleanupTimer = setInterval(cleanup, cleanupIntervalMs);
|
||||||
|
cleanupTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
const limiter = (req, res, next) => {
|
||||||
const key = keyGenerator(req);
|
const key = keyGenerator(req);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return next();
|
return next();
|
||||||
@@ -17,6 +33,9 @@ export function createRateLimiter({windowMs, max, keyGenerator, message}) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const bucket = hits.get(key) || [];
|
const bucket = hits.get(key) || [];
|
||||||
pruneBucket(bucket, windowMs, now);
|
pruneBucket(bucket, windowMs, now);
|
||||||
|
if (!bucket.length) {
|
||||||
|
hits.delete(key);
|
||||||
|
}
|
||||||
if (bucket.length >= max) {
|
if (bucket.length >= max) {
|
||||||
return res.status(429).json({error: message});
|
return res.status(429).json({error: message});
|
||||||
}
|
}
|
||||||
@@ -25,4 +44,14 @@ export function createRateLimiter({windowMs, max, keyGenerator, message}) {
|
|||||||
hits.set(key, bucket);
|
hits.set(key, bucket);
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
limiter.close = () => {
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
|
hits.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return limiter;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async function startTestServer(overrides = {}) {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
async close() {
|
async close() {
|
||||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
|
await app.locals.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user