Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const jsonHeaders = {

export const MAX_REQUEST_BODY_SIZE_ENV = "HASNA_SESSIONS_MAX_REQUEST_BODY_SIZE";
export const SELF_HOSTED_DEFAULT_MAX_REQUEST_BODY_SIZE = 512 * 1024 * 1024;
export const SERVER_IDLE_TIMEOUT_ENV = "HASNA_SESSIONS_IDLE_TIMEOUT_SECONDS";
export const DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS = 60;

function json(payload: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(payload, null, 2), { status, headers: jsonHeaders });
Expand Down Expand Up @@ -117,6 +119,21 @@ export function resolveMaxRequestBodySize(env: NodeJS.ProcessEnv = process.env):
return isCloudMode(env) ? SELF_HOSTED_DEFAULT_MAX_REQUEST_BODY_SIZE : undefined;
}

export function resolveServerIdleTimeoutSeconds(
env: NodeJS.ProcessEnv = process.env,
): number {
const configured = env[SERVER_IDLE_TIMEOUT_ENV]?.trim();
if (!configured) return DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS;

const seconds = Number(configured);
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 255) {
throw new Error(
`${SERVER_IDLE_TIMEOUT_ENV} must be an integer from 0 through 255 seconds.`,
);
}
return seconds;
}

/** Serve mode string for the health/version contract. */
function serveMode(): "cloud" | "local" {
return isCloudMode() ? "cloud" : "local";
Expand Down Expand Up @@ -373,17 +390,20 @@ export function createSessionsServer(options: {
hostname?: string;
enableMcp?: boolean;
maxRequestBodySize?: number;
idleTimeout?: number;
} = {}) {
const pkg = getPackageInfo();
const hostname = options.hostname ?? process.env.HOST ?? "127.0.0.1";
const port = Number.isFinite(options.port)
? options.port
: Number.parseInt(process.env.PORT || "3456", 10);
const maxRequestBodySize = options.maxRequestBodySize ?? resolveMaxRequestBodySize();
const idleTimeout = options.idleTimeout ?? resolveServerIdleTimeoutSeconds();

return Bun.serve({
hostname,
port: Number.isFinite(port) ? port : 3456,
idleTimeout,
...(maxRequestBodySize === undefined ? {} : { maxRequestBodySize }),
async fetch(request) {
if (options.enableMcp) {
Expand Down
1 change: 1 addition & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Environment:
HASNA_SESSIONS_DATABASE_URL cloud Postgres DSN (cloud mode)
HASNA_SESSIONS_API_SIGNING_KEY HMAC signing key for /v1 API-key auth
HASNA_SESSIONS_MAX_REQUEST_BODY_SIZE max request body bytes/units (cloud default: 512MiB)
HASNA_SESSIONS_IDLE_TIMEOUT_SECONDS in-flight HTTP idle timeout (default: 60, max: 255)

Endpoints:
GET /health liveness -> { status, version, mode }
Expand Down
31 changes: 30 additions & 1 deletion test/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,47 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createSessionsServer,
DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS,
MAX_REQUEST_BODY_SIZE_ENV,
resolveServerIdleTimeoutSeconds,
resolveMaxRequestBodySize,
SERVER_IDLE_TIMEOUT_ENV,
SELF_HOSTED_DEFAULT_MAX_REQUEST_BODY_SIZE,
} from "../src/server/app";
import { getPackageInfo } from "../src/lib/package";
import { getDatabase, resetDatabase, closeDatabase } from "../src/db/database";
import { saveParsedSession } from "../src/db/sessions";

describe("createSessionsServer", () => {
it("keeps slow in-flight requests alive past Bun's 10-second default", () => {
let serveOptions: Record<string, unknown> | undefined;
const serveSpy = spyOn(Bun, "serve").mockImplementation((options: Record<string, unknown>) => {
serveOptions = options;
return { port: 0, stop() {} } as never;
});

try {
createSessionsServer({ hostname: "127.0.0.1", port: 0 });
expect(serveOptions?.idleTimeout).toBe(DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS);
} finally {
serveSpy.mockRestore();
}
});

it("accepts explicit idle-timeout overrides and rejects values Bun cannot serve", () => {
expect(resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "0" })).toBe(0);
expect(resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "255" })).toBe(255);
expect(() =>
resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "256" }),
).toThrow(SERVER_IDLE_TIMEOUT_ENV);
expect(() =>
resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "10.5" }),
).toThrow(SERVER_IDLE_TIMEOUT_ENV);
});

it("preserves Bun's default body limit in local mode unless configured", () => {
expect(resolveMaxRequestBodySize({ HASNA_SESSIONS_STORAGE_MODE: "local" })).toBeUndefined();
});
Expand Down
Loading