| import express, { Router, type Request as ExpressRequest } from "express"; |
| import path from "node:path"; |
| import fs from "node:fs"; |
| import { fileURLToPath } from "node:url"; |
| import type { Db } from "@paperclipai/db"; |
| import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; |
| import type { StorageService } from "./storage/types.js"; |
| import { httpLogger, errorHandler } from "./middleware/index.js"; |
| import { actorMiddleware } from "./middleware/auth.js"; |
| import { boardMutationGuard } from "./middleware/board-mutation-guard.js"; |
| import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js"; |
| import { healthRoutes } from "./routes/health.js"; |
| import { companyRoutes } from "./routes/companies.js"; |
| import { companySkillRoutes } from "./routes/company-skills.js"; |
| import { agentRoutes } from "./routes/agents.js"; |
| import { projectRoutes } from "./routes/projects.js"; |
| import { issueRoutes } from "./routes/issues.js"; |
| import { routineRoutes } from "./routes/routines.js"; |
| import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; |
| import { goalRoutes } from "./routes/goals.js"; |
| import { approvalRoutes } from "./routes/approvals.js"; |
| import { secretRoutes } from "./routes/secrets.js"; |
| import { costRoutes } from "./routes/costs.js"; |
| import { activityRoutes } from "./routes/activity.js"; |
| import { dashboardRoutes } from "./routes/dashboard.js"; |
| import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; |
| import { instanceSettingsRoutes } from "./routes/instance-settings.js"; |
| import { llmRoutes } from "./routes/llms.js"; |
| import { assetRoutes } from "./routes/assets.js"; |
| import { accessRoutes } from "./routes/access.js"; |
| import { pluginRoutes } from "./routes/plugins.js"; |
| import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; |
| import { agentMemoryRoutes } from "./routes/agent-memories.js"; |
| import { mcpServerRoutes } from "./routes/mcp-servers.js"; |
| import { agentKpiRoutes } from "./routes/agent-kpis.js"; |
| import { agentExperimentRoutes } from "./routes/agent-experiments.js"; |
| import { skillChangeRoutes } from "./routes/skill-changes.js"; |
| import { applyUiBranding } from "./ui-branding.js"; |
| import { logger } from "./middleware/logger.js"; |
| import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; |
| import { createPluginWorkerManager } from "./services/plugin-worker-manager.js"; |
| import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js"; |
| import { pluginJobStore } from "./services/plugin-job-store.js"; |
| import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js"; |
| import { pluginLifecycleManager } from "./services/plugin-lifecycle.js"; |
| import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js"; |
| import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js"; |
| import { createPluginEventBus } from "./services/plugin-event-bus.js"; |
| import { setPluginEventBus } from "./services/activity-log.js"; |
| import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js"; |
| import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js"; |
| import { pluginRegistryService } from "./services/plugin-registry.js"; |
| import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; |
| import type { BetterAuthSessionResult } from "./auth/better-auth.js"; |
|
|
| type UiMode = "none" | "static" | "vite-dev"; |
|
|
| export function resolveViteHmrPort(serverPort: number): number { |
| if (serverPort <= 55_535) { |
| return serverPort + 10_000; |
| } |
| return Math.max(1_024, serverPort - 10_000); |
| } |
|
|
| export async function createApp( |
| db: Db, |
| opts: { |
| uiMode: UiMode; |
| serverPort: number; |
| storageService: StorageService; |
| deploymentMode: DeploymentMode; |
| deploymentExposure: DeploymentExposure; |
| allowedHostnames: string[]; |
| bindHost: string; |
| authReady: boolean; |
| companyDeletionEnabled: boolean; |
| instanceId?: string; |
| hostVersion?: string; |
| localPluginDir?: string; |
| betterAuthHandler?: express.RequestHandler; |
| resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>; |
| }, |
| ) { |
| const app = express(); |
|
|
| app.use(express.json({ |
| |
| limit: "10mb", |
| verify: (req, _res, buf) => { |
| (req as unknown as { rawBody: Buffer }).rawBody = buf; |
| }, |
| })); |
| app.use(httpLogger); |
| const privateHostnameGateEnabled = |
| opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; |
| const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ |
| allowedHostnames: opts.allowedHostnames, |
| bindHost: opts.bindHost, |
| }); |
| app.use( |
| privateHostnameGuard({ |
| enabled: privateHostnameGateEnabled, |
| allowedHostnames: opts.allowedHostnames, |
| bindHost: opts.bindHost, |
| }), |
| ); |
| app.use( |
| actorMiddleware(db, { |
| deploymentMode: opts.deploymentMode, |
| resolveSession: opts.resolveSession, |
| }), |
| ); |
| app.get("/api/auth/get-session", (req, res) => { |
| if (req.actor.type !== "board" || !req.actor.userId) { |
| res.status(401).json({ error: "Unauthorized" }); |
| return; |
| } |
| res.json({ |
| session: { |
| id: `paperclip:${req.actor.source}:${req.actor.userId}`, |
| userId: req.actor.userId, |
| }, |
| user: { |
| id: req.actor.userId, |
| email: null, |
| name: req.actor.source === "local_implicit" ? "Local Board" : null, |
| }, |
| }); |
| }); |
| if (opts.betterAuthHandler) { |
| app.all("/api/auth/*authPath", opts.betterAuthHandler); |
| } |
| app.use(llmRoutes(db)); |
|
|
| |
| const api = Router(); |
| api.use(boardMutationGuard()); |
| api.use( |
| "/health", |
| healthRoutes(db, { |
| deploymentMode: opts.deploymentMode, |
| deploymentExposure: opts.deploymentExposure, |
| authReady: opts.authReady, |
| companyDeletionEnabled: opts.companyDeletionEnabled, |
| }), |
| ); |
| api.use("/companies", companyRoutes(db, opts.storageService)); |
| api.use(companySkillRoutes(db)); |
| api.use(agentRoutes(db)); |
| api.use(assetRoutes(db, opts.storageService)); |
| api.use(projectRoutes(db)); |
| api.use(issueRoutes(db, opts.storageService)); |
| api.use(routineRoutes(db)); |
| api.use(executionWorkspaceRoutes(db)); |
| api.use(goalRoutes(db)); |
| api.use(approvalRoutes(db)); |
| api.use(secretRoutes(db)); |
| api.use(costRoutes(db)); |
| api.use(activityRoutes(db)); |
| api.use(dashboardRoutes(db)); |
| api.use(sidebarBadgeRoutes(db)); |
| api.use(instanceSettingsRoutes(db)); |
| api.use(agentMemoryRoutes(db)); |
| api.use(mcpServerRoutes(db)); |
| api.use(agentKpiRoutes(db)); |
| api.use(agentExperimentRoutes(db)); |
| api.use(skillChangeRoutes(db)); |
| const hostServicesDisposers = new Map<string, () => void>(); |
| const workerManager = createPluginWorkerManager(); |
| const pluginRegistry = pluginRegistryService(db); |
| const eventBus = createPluginEventBus(); |
| setPluginEventBus(eventBus); |
| const jobStore = pluginJobStore(db); |
| const lifecycle = pluginLifecycleManager(db, { workerManager }); |
| const scheduler = createPluginJobScheduler({ |
| db, |
| jobStore, |
| workerManager, |
| }); |
| const toolDispatcher = createPluginToolDispatcher({ |
| workerManager, |
| lifecycleManager: lifecycle, |
| db, |
| }); |
| const jobCoordinator = createPluginJobCoordinator({ |
| db, |
| lifecycle, |
| scheduler, |
| jobStore, |
| }); |
| const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); |
| const loader = pluginLoader( |
| db, |
| { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR }, |
| { |
| workerManager, |
| eventBus, |
| jobScheduler: scheduler, |
| jobStore, |
| toolDispatcher, |
| lifecycleManager: lifecycle, |
| instanceInfo: { |
| instanceId: opts.instanceId ?? "default", |
| hostVersion: opts.hostVersion ?? "0.0.0", |
| }, |
| buildHostHandlers: (pluginId, manifest) => { |
| const notifyWorker = (method: string, params: unknown) => { |
| const handle = workerManager.getWorker(pluginId); |
| if (handle) handle.notify(method, params); |
| }; |
| const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker); |
| hostServicesDisposers.set(pluginId, () => services.dispose()); |
| return createHostClientHandlers({ |
| pluginId, |
| capabilities: manifest.capabilities, |
| services, |
| }); |
| }, |
| }, |
| ); |
| api.use( |
| pluginRoutes( |
| db, |
| loader, |
| { scheduler, jobStore }, |
| { workerManager }, |
| { toolDispatcher }, |
| { workerManager }, |
| ), |
| ); |
| api.use( |
| accessRoutes(db, { |
| deploymentMode: opts.deploymentMode, |
| deploymentExposure: opts.deploymentExposure, |
| bindHost: opts.bindHost, |
| allowedHostnames: opts.allowedHostnames, |
| }), |
| ); |
| app.use("/api", api); |
| app.use("/api", (_req, res) => { |
| res.status(404).json({ error: "API route not found" }); |
| }); |
| app.use(pluginUiStaticRoutes(db, { |
| localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, |
| })); |
|
|
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| if (opts.uiMode === "static") { |
| |
| const candidates = [ |
| path.resolve(__dirname, "../ui-dist"), |
| path.resolve(__dirname, "../../ui/dist"), |
| ]; |
| const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); |
| if (uiDist) { |
| const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); |
| app.use(express.static(uiDist)); |
| app.get(/.*/, (_req, res) => { |
| res.status(200).set("Content-Type", "text/html").end(indexHtml); |
| }); |
| } else { |
| console.warn("[paperclip] UI dist not found; running in API-only mode"); |
| } |
| } |
|
|
| if (opts.uiMode === "vite-dev") { |
| const uiRoot = path.resolve(__dirname, "../../ui"); |
| const hmrPort = resolveViteHmrPort(opts.serverPort); |
| const { createServer: createViteServer } = await import("vite"); |
| const vite = await createViteServer({ |
| root: uiRoot, |
| appType: "custom", |
| server: { |
| middlewareMode: true, |
| hmr: { |
| host: opts.bindHost, |
| port: hmrPort, |
| clientPort: hmrPort, |
| }, |
| allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined, |
| }, |
| }); |
|
|
| app.use(vite.middlewares); |
| app.get(/.*/, async (req, res, next) => { |
| try { |
| const templatePath = path.resolve(uiRoot, "index.html"); |
| const template = fs.readFileSync(templatePath, "utf-8"); |
| const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template)); |
| res.status(200).set({ "Content-Type": "text/html" }).end(html); |
| } catch (err) { |
| next(err); |
| } |
| }); |
| } |
|
|
| app.use(errorHandler); |
|
|
| jobCoordinator.start(); |
| scheduler.start(); |
| void toolDispatcher.initialize().catch((err) => { |
| logger.error({ err }, "Failed to initialize plugin tool dispatcher"); |
| }); |
| const devWatcher = opts.uiMode === "vite-dev" |
| ? createPluginDevWatcher( |
| lifecycle, |
| async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, |
| ) |
| : null; |
| void loader.loadAll().then((result) => { |
| if (!result) return; |
| for (const loaded of result.results) { |
| if (devWatcher && loaded.success && loaded.plugin.packagePath) { |
| devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath); |
| } |
| } |
| }).catch((err) => { |
| logger.error({ err }, "Failed to load ready plugins on startup"); |
| }); |
| process.once("exit", () => { |
| devWatcher?.close(); |
| hostServiceCleanup.disposeAll(); |
| hostServiceCleanup.teardown(); |
| }); |
| process.once("beforeExit", () => { |
| void flushPluginLogBuffer(); |
| }); |
|
|
| return app; |
| } |
|
|