import { createServer as createHttpServer, type Server as HttpServer, type IncomingMessage, type ServerResponse, } from "node:http"; import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; import type { WebSocketServer } from "ws"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { extractHookToken, getHookChannelError, type HookMessageChannel, type HooksConfigResolved, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; type SubsystemLogger = ReturnType; type HookDispatchers = { dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void; dispatchAgentHook: (value: { message: string; name: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; channel: HookMessageChannel; to?: string; model?: string; thinking?: string; timeoutSeconds?: number; }) => string; }; function sendJson(res: ServerResponse, status: number, body: unknown) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(body)); } export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; export function createHooksRequestHandler( opts: { getHooksConfig: () => HooksConfigResolved | null; bindHost: string; port: number; logHooks: SubsystemLogger; } & HookDispatchers, ): HooksRequestHandler { const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts; return async (req, res) => { const hooksConfig = getHooksConfig(); if (!hooksConfig) return false; const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`); const basePath = hooksConfig.basePath; if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) { return false; } const token = extractHookToken(req, url); if (!token || token !== hooksConfig.token) { res.statusCode = 401; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Unauthorized"); return true; } if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Method Not Allowed"); return true; } const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); if (!subPath) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); return true; } const body = await readJsonBody(req, hooksConfig.maxBodyBytes); if (!body.ok) { const status = body.error === "payload too large" ? 413 : 400; sendJson(res, status, { ok: false, error: body.error }); return true; } const payload = typeof body.value === "object" && body.value !== null ? body.value : {}; const headers = normalizeHookHeaders(req); if (subPath === "wake") { const normalized = normalizeWakePayload(payload as Record); if (!normalized.ok) { sendJson(res, 400, { ok: false, error: normalized.error }); return true; } dispatchWakeHook(normalized.value); sendJson(res, 200, { ok: true, mode: normalized.value.mode }); return true; } if (subPath === "agent") { const normalized = normalizeAgentPayload(payload as Record); if (!normalized.ok) { sendJson(res, 400, { ok: false, error: normalized.error }); return true; } const runId = dispatchAgentHook(normalized.value); sendJson(res, 202, { ok: true, runId }); return true; } if (hooksConfig.mappings.length > 0) { try { const mapped = await applyHookMappings(hooksConfig.mappings, { payload: payload as Record, headers, url, path: subPath, }); if (mapped) { if (!mapped.ok) { sendJson(res, 400, { ok: false, error: mapped.error }); return true; } if (mapped.action === null) { res.statusCode = 204; res.end(); return true; } if (mapped.action.kind === "wake") { dispatchWakeHook({ text: mapped.action.text, mode: mapped.action.mode, }); sendJson(res, 200, { ok: true, mode: mapped.action.mode }); return true; } const channel = resolveHookChannel(mapped.action.channel); if (!channel) { sendJson(res, 400, { ok: false, error: getHookChannelError() }); return true; } const runId = dispatchAgentHook({ message: mapped.action.message, name: mapped.action.name ?? "Hook", wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", deliver: resolveHookDeliver(mapped.action.deliver), channel, to: mapped.action.to, model: mapped.action.model, thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, }); sendJson(res, 202, { ok: true, runId }); return true; } } catch (err) { logHooks.warn(`hook mapping failed: ${String(err)}`); sendJson(res, 500, { ok: false, error: "hook mapping failed" }); return true; } } res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); return true; }; } export function createGatewayHttpServer(opts: { canvasHost: CanvasHostHandler | null; controlUiEnabled: boolean; controlUiBasePath: string; openAiChatCompletionsEnabled: boolean; openResponsesEnabled: boolean; openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; handleHooksRequest: HooksRequestHandler; handlePluginRequest?: HooksRequestHandler; resolvedAuth: import("./auth.js").ResolvedGatewayAuth; tlsOptions?: TlsOptions; }): HttpServer { const { canvasHost, controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, handleHooksRequest, handlePluginRequest, resolvedAuth, } = opts; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { void handleRequest(req, res); }) : createHttpServer((req, res) => { void handleRequest(req, res); }); async function handleRequest(req: IncomingMessage, res: ServerResponse) { // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; try { if (await handleHooksRequest(req, res)) return; if (await handleSlackHttpRequest(req, res)) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return; if (openResponsesEnabled) { if ( await handleOpenResponsesHttpRequest(req, res, { auth: resolvedAuth, config: openResponsesConfig, }) ) return; } if (openAiChatCompletionsEnabled) { if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; } if (canvasHost) { if (await handleA2uiHttpRequest(req, res)) return; if (await canvasHost.handleHttpRequest(req, res)) return; } if (controlUiEnabled) { if ( handleControlUiHttpRequest(req, res, { basePath: controlUiBasePath, }) ) return; } res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); } catch (err) { res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end(String(err)); } } return httpServer; } export function attachGatewayUpgradeHandler(opts: { httpServer: HttpServer; wss: WebSocketServer; canvasHost: CanvasHostHandler | null; }) { const { httpServer, wss, canvasHost } = opts; httpServer.on("upgrade", (req, socket, head) => { if (canvasHost?.handleUpgrade(req, socket, head)) return; wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); }); }