paperclip/server/src/app.ts
Dotta 6e6d67372c Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master:
  Drop lockfile from watcher change
  Tighten plugin dev file watching
  Fix plugin smoke example typecheck
  Fix plugin dev watcher and migration snapshot
  Clarify plugin authoring and external dev workflow
  Expand kitchen sink plugin demos
  fix: set AGENT_HOME env var for agent processes
  Add kitchen sink plugin example
  Simplify plugin runtime and cleanup lifecycle
  Add plugin framework and settings UI

# Conflicts:
#	packages/db/src/migrations/meta/0029_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
2026-03-14 13:56:09 -05:00

310 lines
11 KiB
TypeScript

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 { 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 { 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));
// 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));
const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager();
const pluginRegistry = pluginRegistryService(db);
const eventBus = createPluginEventBus();
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: "spa",
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;
}