Spaces:
Paused
Paused
| 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 { agentRoutes } from "./routes/agents.js"; | |
| import { projectRoutes } from "./routes/projects.js"; | |
| import { issueRoutes } from "./routes/issues.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 { 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({ | |
| 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)); | |
| app.get("/health", (_req, res) => { | |
| res.status(200).json({ status: "ok" }); | |
| }); | |
| app.get("/api-docs", (_req, res) => { | |
| res.status(200).json({ | |
| endpoints: [ | |
| { | |
| path: "/api/health", | |
| method: "GET", | |
| purpose: "Returns API health status", | |
| request: null, | |
| response: { status: "ok" } | |
| }, | |
| { | |
| path: "/api/companies", | |
| method: "GET", | |
| purpose: "Lists all companies", | |
| request: null, | |
| response: [{ id: "string", name: "string" }] | |
| }, | |
| { | |
| path: "/api/companies", | |
| method: "POST", | |
| purpose: "Creates a new company", | |
| request: { name: "string", goal: "string" }, | |
| response: { id: "string", name: "string" } | |
| } | |
| ] | |
| }); | |
| }); | |
| // Mount API routes | |
| 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)); | |
| api.use(agentRoutes(db)); | |
| api.use(assetRoutes(db, opts.storageService)); | |
| api.use(projectRoutes(db)); | |
| api.use(issueRoutes(db, opts.storageService)); | |
| 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)); | |
| 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") { | |
| // Try published location first (server/ui-dist/), then monorepo dev location (../../ui/dist) | |
| 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; | |
| } | |