feat(FN-2014): merge fusion/fn-2014

This commit is contained in:
Fusion
2026-04-17 14:26:12 -07:00
committed by gsxdsm
parent b4764a1bd4
commit 39e833b91e
37 changed files with 960 additions and 533 deletions

View File

@@ -1,8 +1,10 @@
import express from "express";
import { randomUUID } from "node:crypto";
import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { createSecureServer as createHttp2SecureServer, type Http2SecureServer } from "node:http2";
import type { Server as HttpServer } from "node:http";
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore, CentralCore, MessageStore } from "@fusion/core";
import { AgentStore, ChatStore } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
@@ -201,6 +203,16 @@ export interface ServerOptions {
/** Daemon mode configuration with bearer token authentication.
* When provided, all API requests (except /api/health) require valid bearer token. */
daemon?: { token: string };
/** Optional TLS credentials. When provided, the server is served over HTTP/2
* with HTTP/1.1 fallback (allowHTTP1:true) — this lifts the browser's
* per-origin connection cap so long-lived SSE streams no longer starve
* regular API fetches. WebSocket upgrades continue to work because HTTP/1.1
* clients are still accepted. */
https?: {
cert: string | Buffer;
key: string | Buffer;
ca?: string | Buffer | Array<string | Buffer>;
};
}
type DashboardExpressApp = ReturnType<typeof express> & {
@@ -259,6 +271,49 @@ function shouldScheduleAiSessionCleanup(): boolean {
return process.env.NODE_ENV !== "test";
}
/**
* Resolve TLS credentials from environment variables, if configured.
*
* Reads either inline PEM material (`FUSION_TLS_CERT` / `FUSION_TLS_KEY`) or
* file paths (`FUSION_TLS_CERT_FILE` / `FUSION_TLS_KEY_FILE`). `FUSION_TLS_CA`
* / `FUSION_TLS_CA_FILE` are optional and set the CA bundle.
*
* Returns `undefined` when neither pair is set, which callers should treat as
* "serve plain HTTP/1.1". When a cert is set without a key (or vice versa)
* this throws — that's a config error worth surfacing.
*/
export function loadTlsCredentialsFromEnv(
env: NodeJS.ProcessEnv = process.env,
): { cert: Buffer; key: Buffer; ca?: Buffer } | undefined {
const certInline = env.FUSION_TLS_CERT;
const keyInline = env.FUSION_TLS_KEY;
const certFile = env.FUSION_TLS_CERT_FILE;
const keyFile = env.FUSION_TLS_KEY_FILE;
const hasCert = Boolean(certInline || certFile);
const hasKey = Boolean(keyInline || keyFile);
if (!hasCert && !hasKey) return undefined;
if (hasCert !== hasKey) {
throw new Error(
"FUSION_TLS_* environment is incomplete: set both a cert and a key " +
"(inline via FUSION_TLS_CERT/FUSION_TLS_KEY or paths via *_FILE).",
);
}
const cert = certInline ? Buffer.from(certInline) : readFileSync(certFile!);
const key = keyInline ? Buffer.from(keyInline) : readFileSync(keyFile!);
const caInline = env.FUSION_TLS_CA;
const caFile = env.FUSION_TLS_CA_FILE;
const ca = caInline
? Buffer.from(caInline)
: caFile
? readFileSync(caFile)
: undefined;
return { cert, key, ca };
}
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
// ── Derive defaults from engine when provided (explicit options override) ──
const engine = options?.engine;
@@ -738,9 +793,30 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
dashboardApp.__fnWebSocketsAttached = false;
const originalListen = dashboardApp.listen.bind(dashboardApp);
const httpsCreds = options?.https;
dashboardApp.listen = ((...args: Parameters<typeof dashboardApp.listen>) => {
const normalizedArgs = normalizeListenArgsForTests(args) as Parameters<typeof originalListen>;
const server = originalListen(...normalizedArgs);
let server: HttpServer | Http2SecureServer;
if (httpsCreds) {
// HTTP/2 with HTTP/1.1 fallback. allowHTTP1 is required so that:
// 1. WebSocket upgrades (HTTP/1.1-only) keep working.
// 2. Older clients and curl continue to connect.
// Express 5's request pipeline is compatible with both h1 and h2 req/res.
const h2 = createHttp2SecureServer(
{
cert: httpsCreds.cert,
key: httpsCreds.key,
ca: httpsCreds.ca,
allowHTTP1: true,
},
dashboardApp as unknown as Parameters<typeof createHttp2SecureServer>[1],
);
server = h2;
h2.listen(...(normalizedArgs as Parameters<Http2SecureServer["listen"]>));
} else {
server = originalListen(...normalizedArgs);
}
server.once("close", () => {
clearAiSessionCleanupInterval();
@@ -749,11 +825,11 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
if (!dashboardApp.__fnWebSocketsAttached) {
dashboardApp.__fnWebSocketsAttached = true;
setupTerminalWebSocket(dashboardApp, server, store, options);
setupBadgeWebSocket(dashboardApp, server, store, options);
setupTerminalWebSocket(dashboardApp, server as HttpServer, store, options);
setupBadgeWebSocket(dashboardApp, server as HttpServer, store, options);
}
return server;
return server as HttpServer;
}) as typeof dashboardApp.listen;
return dashboardApp;