/** * Local development server for Coline apps. * * Renders a browser-based preview of your app's home and file views * by calling your handler with signed mock requests — no Coline account needed. * * Usage: * import { createDevServer } from "@colineapp/sdk/dev"; * createDevServer({ app, secret: "dev-secret" }).listen(4200); */ import type { ColineApp } from "./app"; import { createHandler } from "./handler"; import { createSignedAppRequestHeaders } from "./signing"; // ── Types ─────────────────────────────────────────────────────────────────── export interface DevServerOptions { app: ColineApp; secret: string; port?: number; /** Mock workspace context */ workspace?: { id?: string; slug?: string; name?: string; }; /** Mock user context */ actor?: { userId?: string; email?: string; displayName?: string; }; } // ── Mock Data ─────────────────────────────────────────────────────────────── function buildMockBody( options: DevServerOptions, renderType: "home" | "file", fileId?: string, ) { const workspace = { id: options.workspace?.id ?? "ws_dev_000", slug: options.workspace?.slug ?? "dev-workspace", name: options.workspace?.name ?? "Dev Workspace", }; const actor = { userId: options.actor?.userId ?? "user_dev_000", email: options.actor?.email ?? "dev@example.com", displayName: options.actor?.displayName ?? "Developer", }; const app = { appInstallId: "install_dev_000", appId: "app_dev_000", appKey: options.app.getDefinition().manifest.key, name: options.app.getDefinition().manifest.name, }; const base = { workspace, actor, app }; if (renderType === "home") { return base; } return { ...base, file: { id: fileId ?? "file_dev_000", typeKey: options.app.getDefinition().fileTypes[0]?.typeKey ?? "unknown", title: "Sample File", }, document: { _dev: true }, version: 1, }; } // ── UI Renderer ───────────────────────────────────────────────────────────── function renderUiNode(node: Record): string { const type = node.type as string; switch (type) { case "stack": { const children = (node.children as Record[]) ?? []; const dir = node.direction === "horizontal" ? "flex-row" : "flex-col"; const gap = node.gap ?? 3; return `
${children.map(renderUiNode).join("")}
`; } case "heading": { const level = node.level ?? 2; const cls = level === 1 ? "text-2xl font-bold" : level === 2 ? "text-xl font-semibold" : "text-lg font-medium"; return `${escapeHtml(String(node.value ?? ""))}`; } case "text": return `

${escapeHtml(String(node.value ?? ""))}

`; case "badge": { const tone = node.tone ?? "default"; const colors: Record = { default: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", muted: "bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-500", positive: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", warning: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", danger: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", }; return `${escapeHtml(String(node.value ?? ""))}`; } case "button": return ``; case "divider": return `
`; case "empty_state": return `

${escapeHtml(String(node.title ?? ""))}

${node.description ? `

${escapeHtml(String(node.description))}

` : ""}
`; case "table": { const columns = (node.columns as Array<{ key: string; header: string }>) ?? []; const rows = (node.rows as Array>) ?? []; return `${columns.map((c) => ``).join("")}${rows.map((row) => `${columns.map((c) => ``).join("")}`).join("")}
${escapeHtml(c.header)}
${escapeHtml(String(row[c.key] ?? ""))}
`; } case "code_block": return `
${escapeHtml(String(node.code ?? ""))}
`; case "image": return `${escapeHtml(String(node.alt ?? `; case "link": return `${escapeHtml(String(node.label ?? ""))}`; case "file_card": return `
📄

${escapeHtml(String(node.title ?? ""))}

${node.subtitle ? `

${escapeHtml(String(node.subtitle))}

` : ""}
`; case "user_chip": return `👤 ${escapeHtml(String(node.label ?? ""))}`; case "input": return `
${node.label ? `` : ""}
`; case "select": return `
${node.label ? `` : ""}
`; default: return `
[Unknown: ${escapeHtml(type)}]
`; } } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } // ── Dev Server HTML Shell ─────────────────────────────────────────────────── function devShell(appName: string, renderedContent: string, tab: "home" | "file"): string { return ` ${escapeHtml(appName)} — Coline Dev Preview

${escapeHtml(appName)}

Dev Mode
${renderedContent}
`; } // ── Dev Server Factory ────────────────────────────────────────────────────── export function createDevServer(options: DevServerOptions) { const handler = createHandler({ app: options.app, secret: options.secret, }); async function callRender(renderType: "home" | "file", fileId?: string): Promise { const body = JSON.stringify(buildMockBody(options, renderType, fileId)); const path = renderType === "home" ? "/coline/render/home" : "/coline/render/file"; const headers = await createSignedAppRequestHeaders({ secret: options.secret, body, deliveryId: `dev_${Date.now()}`, }); const request = new Request(`http://localhost${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body, }); const response = await handler(request); if (!response.ok) { const text = await response.text(); return `
Render error (${response.status}): ${escapeHtml(text)}
`; } const json = (await response.json()) as { ui?: Record[] | Record }; const uiNodes = Array.isArray(json.ui) ? json.ui : json.ui ? [json.ui] : []; return uiNodes.map((node) => renderUiNode(node as Record)).join(""); } async function devHandler(request: Request): Promise { const url = new URL(request.url); // Pass through /coline/* routes to the real handler if (url.pathname.startsWith("/coline/")) { return handler(request); } const appName = options.app.getDefinition().manifest.name; // Dev preview routes if (url.pathname === "/dev" || url.pathname === "/dev/home" || url.pathname === "/") { const content = await callRender("home"); return new Response(devShell(appName, content, "home"), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } if (url.pathname === "/dev/file") { const content = await callRender("file", url.searchParams.get("id") ?? undefined); return new Response(devShell(appName, content, "file"), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // Redirect root to dev preview return new Response(null, { status: 302, headers: { Location: "/dev" }, }); } return { handler: devHandler, listen(port?: number) { const p = port ?? options.port ?? 4200; // Use a dynamic import to avoid bundling node:http in non-Node environments // Dynamic import — only works in Node.js environments // eslint-disable-next-line @typescript-eslint/no-explicit-any void (import("node:http" as string) as Promise).then((http: { createServer: Function }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any http.createServer(async (req: any, res: any) => { const url = new URL(req.url ?? "/", `http://localhost:${p}`); const bodyChunks: string[] = []; req.on("data", (chunk: { toString(): string }) => bodyChunks.push(chunk.toString())); req.on("end", async () => { const body = bodyChunks.join(""); const request = new Request(url, { method: req.method, headers: Object.fromEntries( Object.entries(req.headers).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ), ...(body && req.method !== "GET" && req.method !== "HEAD" ? { body } : {}), }); const response = await devHandler(request); const responseBody = await response.text(); res.writeHead( response.status, Object.fromEntries(response.headers), ); res.end(responseBody); }); }) .listen(p, () => { console.log(`\n 🎨 Dev preview: http://localhost:${p}/dev`); console.log(` 📋 Manifest: http://localhost:${p}/coline/manifest\n`); }); }); }, }; }