The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 6 | * |
| 7 | * Usage: |
| 8 | * import { createDevServer } from "@colineapp/sdk/dev"; |
| 9 | * createDevServer({ app, secret: "dev-secret" }).listen(4200); |
| 10 | */ |
| 11 |
| 12 | import type { ColineApp } from "./app"; |
| 13 | import { createHandler } from "./handler"; |
| 14 | import { createSignedAppRequestHeaders } from "./signing"; |
| 15 |
| 16 | // ── Types ─────────────────────────────────────────────────────────────────── |
| 17 |
| 18 | export interface DevServerOptions { |
| 19 | app: ColineApp; |
| 20 | secret: string; |
| 21 | port?: number; |
| 22 | /** Mock workspace context */ |
| 23 | workspace?: { |
| 24 | id?: string; |
| 25 | slug?: string; |
| 26 | name?: string; |
| 27 | }; |
| 28 | /** Mock user context */ |
| 29 | actor?: { |
| 30 | userId?: string; |
| 31 | email?: string; |
| 32 | displayName?: string; |
| 33 | }; |
| 34 | } |
| 35 |
| 36 | // ── Mock Data ─────────────────────────────────────────────────────────────── |
| 37 |
| 38 | function buildMockBody( |
| 39 | options: DevServerOptions, |
| 40 | renderType: "home" | "file", |
| 41 | fileId?: string, |
| 42 | ) { |
| 43 | const workspace = { |
| 44 | id: options.workspace?.id ?? "ws_dev_000", |
| 45 | slug: options.workspace?.slug ?? "dev-workspace", |
| 46 | name: options.workspace?.name ?? "Dev Workspace", |
| 47 | }; |
| 48 |
| 49 | const actor = { |
| 50 | userId: options.actor?.userId ?? "user_dev_000", |
| 51 | email: options.actor?.email ?? "dev@example.com", |
| 52 | displayName: options.actor?.displayName ?? "Developer", |
| 53 | }; |
| 54 |
| 55 | const app = { |
| 56 | appInstallId: "install_dev_000", |
| 57 | appId: "app_dev_000", |
| 58 | appKey: options.app.getDefinition().manifest.key, |
| 59 | name: options.app.getDefinition().manifest.name, |
| 60 | }; |
| 61 |
| 62 | const base = { workspace, actor, app }; |
| 63 |
| 64 | if (renderType === "home") { |
| 65 | return base; |
| 66 | } |
| 67 |
| 68 | return { |
| 69 | ...base, |
| 70 | file: { |
| 71 | id: fileId ?? "file_dev_000", |
| 72 | typeKey: options.app.getDefinition().fileTypes[0]?.typeKey ?? "unknown", |
| 73 | title: "Sample File", |
| 74 | }, |
| 75 | document: { _dev: true }, |
| 76 | version: 1, |
| 77 | }; |
| 78 | } |
| 79 |
| 80 | // ── UI Renderer ───────────────────────────────────────────────────────────── |
| 81 |
| 82 | function renderUiNode(node: Record<string, unknown>): string { |
| 83 | const type = node.type as string; |
| 84 |
| 85 | switch (type) { |
| 86 | case "stack": { |
| 87 | const children = (node.children as Record<string, unknown>[]) ?? []; |
| 88 | const dir = node.direction === "horizontal" ? "flex-row" : "flex-col"; |
| 89 | const gap = node.gap ?? 3; |
| 90 | return `<div class="flex ${dir}" style="gap: ${Number(gap) * 4}px">${children.map(renderUiNode).join("")}</div>`; |
| 91 | } |
| 92 | case "heading": { |
| 93 | const level = node.level ?? 2; |
| 94 | const cls = level === 1 ? "text-2xl font-bold" : level === 2 ? "text-xl font-semibold" : "text-lg font-medium"; |
| 95 | return `<h${level} class="${cls}">${escapeHtml(String(node.value ?? ""))}</h${level}>`; |
| 96 | } |
| 97 | case "text": |
| 98 | return `<p class="text-sm text-gray-600 dark:text-gray-400">${escapeHtml(String(node.value ?? ""))}</p>`; |
| 99 | case "badge": { |
| 100 | const tone = node.tone ?? "default"; |
| 101 | const colors: Record<string, string> = { |
| 102 | default: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", |
| 103 | muted: "bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-500", |
| 104 | positive: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", |
| 105 | warning: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", |
| 106 | danger: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", |
| 107 | }; |
| 108 | return `<span class="inline-flex px-2 py-0.5 rounded text-xs font-medium ${colors[String(tone)] ?? colors.default}">${escapeHtml(String(node.value ?? ""))}</span>`; |
| 109 | } |
| 110 | case "button": |
| 111 | return `<button class="px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">${escapeHtml(String(node.label ?? ""))}</button>`; |
| 112 | case "divider": |
| 113 | return `<hr class="border-gray-200 dark:border-gray-800 my-2" />`; |
| 114 | case "empty_state": |
| 115 | return `<div class="text-center py-8"><p class="text-gray-400 text-sm">${escapeHtml(String(node.title ?? ""))}</p>${node.description ? `<p class="text-gray-400 text-xs mt-1">${escapeHtml(String(node.description))}</p>` : ""}</div>`; |
| 116 | case "table": { |
| 117 | const columns = (node.columns as Array<{ key: string; header: string }>) ?? []; |
| 118 | const rows = (node.rows as Array<Record<string, unknown>>) ?? []; |
| 119 | return `<table class="w-full text-sm"><thead><tr>${columns.map((c) => `<th class="text-left py-2 px-3 border-b font-medium text-gray-500">${escapeHtml(c.header)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${columns.map((c) => `<td class="py-2 px-3 border-b border-gray-100 dark:border-gray-800">${escapeHtml(String(row[c.key] ?? ""))}</td>`).join("")}</tr>`).join("")}</tbody></table>`; |
| 120 | } |
| 121 | case "code_block": |
| 122 | return `<pre class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 text-xs overflow-x-auto"><code>${escapeHtml(String(node.code ?? ""))}</code></pre>`; |
| 123 | case "image": |
| 124 | return `<img src="${escapeHtml(String(node.src ?? ""))}" alt="${escapeHtml(String(node.alt ?? ""))}" class="max-w-full rounded-lg" />`; |
| 125 | case "link": |
| 126 | return `<a href="${escapeHtml(String(node.href ?? "#"))}" class="text-blue-600 hover:underline text-sm">${escapeHtml(String(node.label ?? ""))}</a>`; |
| 127 | case "file_card": |
| 128 | return `<div class="flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"><div class="text-gray-400">📄</div><div><p class="text-sm font-medium">${escapeHtml(String(node.title ?? ""))}</p>${node.subtitle ? `<p class="text-xs text-gray-500">${escapeHtml(String(node.subtitle))}</p>` : ""}</div></div>`; |
| 129 | case "user_chip": |
| 130 | return `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-xs">👤 ${escapeHtml(String(node.label ?? ""))}</span>`; |
| 131 | case "input": |
| 132 | return `<div class="space-y-1">${node.label ? `<label class="text-sm font-medium">${escapeHtml(String(node.label))}</label>` : ""}<input type="${node.inputType ?? "text"}" name="${escapeHtml(String(node.name ?? ""))}" placeholder="${escapeHtml(String(node.placeholder ?? ""))}" class="w-full px-3 py-2 rounded-lg border text-sm" /></div>`; |
| 133 | case "select": |
| 134 | return `<div class="space-y-1">${node.label ? `<label class="text-sm font-medium">${escapeHtml(String(node.label))}</label>` : ""}<select name="${escapeHtml(String(node.name ?? ""))}" class="w-full px-3 py-2 rounded-lg border text-sm">${((node.options as Array<{ label: string; value: string }>) ?? []).map((o) => `<option value="${escapeHtml(o.value)}">${escapeHtml(o.label)}</option>`).join("")}</select></div>`; |
| 135 | default: |
| 136 | return `<div class="text-xs text-gray-400">[Unknown: ${escapeHtml(type)}]</div>`; |
| 137 | } |
| 138 | } |
| 139 |
| 140 | function escapeHtml(str: string): string { |
| 141 | return str |
| 142 | .replace(/&/g, "&") |
| 143 | .replace(/</g, "<") |
| 144 | .replace(/>/g, ">") |
| 145 | .replace(/"/g, """); |
| 146 | } |
| 147 |
| 148 | // ── Dev Server HTML Shell ─────────────────────────────────────────────────── |
| 149 |
| 150 | function devShell(appName: string, renderedContent: string, tab: "home" | "file"): string { |
| 151 | return `<!DOCTYPE html> |
| 152 | <html lang="en" class="h-full"> |
| 153 | <head> |
| 154 | <meta charset="utf-8" /> |
| 155 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 156 | <title>${escapeHtml(appName)} — Coline Dev Preview</title> |
| 157 | <script src="https://cdn.tailwindcss.com"></script> |
| 158 | <style> |
| 159 | body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } |
| 160 | </style> |
| 161 | </head> |
| 162 | <body class="h-full bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100"> |
| 163 | <div class="flex h-full"> |
| 164 | <!-- Sidebar --> |
| 165 | <aside class="w-64 shrink-0 border-r bg-white dark:bg-gray-900 p-4 flex flex-col gap-4"> |
| 166 | <div class="flex items-center gap-2"> |
| 167 | <div class="h-8 w-8 rounded-lg bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 text-sm font-bold">C</div> |
| 168 | <span class="text-sm font-semibold">Coline Dev Preview</span> |
| 169 | </div> |
| 170 | <div class="flex-1 space-y-1"> |
| 171 | <a href="/dev" |
| 172 | class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${tab === "home" ? "bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium" : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"}"> |
| 173 | 🏠 Home |
| 174 | </a> |
| 175 | <a href="/dev/file" |
| 176 | class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${tab === "file" ? "bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium" : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"}"> |
| 177 | 📄 File Preview |
| 178 | </a> |
| 179 | </div> |
| 180 | <div class="text-xs text-gray-400 space-y-1"> |
| 181 | <p>Manifest: <a href="/coline/manifest" class="text-blue-500 hover:underline">/coline/manifest</a></p> |
| 182 | <p class="text-[10px]">This is a local development preview.</p> |
| 183 | </div> |
| 184 | </aside> |
| 185 |
| 186 | <!-- Content --> |
| 187 | <main class="flex-1 overflow-y-auto p-8"> |
| 188 | <div class="max-w-2xl mx-auto space-y-6"> |
| 189 | <div class="flex items-center justify-between"> |
| 190 | <h1 class="text-lg font-semibold">${escapeHtml(appName)}</h1> |
| 191 | <span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300">Dev Mode</span> |
| 192 | </div> |
| 193 | <div class="bg-white dark:bg-gray-900 rounded-xl border p-6 space-y-4"> |
| 194 | ${renderedContent} |
| 195 | </div> |
| 196 | </div> |
| 197 | </main> |
| 198 | </div> |
| 199 | </body> |
| 200 | </html>`; |
| 201 | } |
| 202 |
| 203 | // ── Dev Server Factory ────────────────────────────────────────────────────── |
| 204 |
| 205 | export function createDevServer(options: DevServerOptions) { |
| 206 | const handler = createHandler({ |
| 207 | app: options.app, |
| 208 | secret: options.secret, |
| 209 | }); |
| 210 |
| 211 | async function callRender(renderType: "home" | "file", fileId?: string): Promise<string> { |
| 212 | const body = JSON.stringify(buildMockBody(options, renderType, fileId)); |
| 213 | const path = renderType === "home" ? "/coline/render/home" : "/coline/render/file"; |
| 214 | const headers = await createSignedAppRequestHeaders({ |
| 215 | secret: options.secret, |
| 216 | body, |
| 217 | deliveryId: `dev_${Date.now()}`, |
| 218 | }); |
| 219 |
| 220 | const request = new Request(`http://localhost${path}`, { |
| 221 | method: "POST", |
| 222 | headers: { |
| 223 | "Content-Type": "application/json", |
| 224 | ...headers, |
| 225 | }, |
| 226 | body, |
| 227 | }); |
| 228 |
| 229 | const response = await handler(request); |
| 230 | if (!response.ok) { |
| 231 | const text = await response.text(); |
| 232 | return `<div class="text-red-500 text-sm">Render error (${response.status}): ${escapeHtml(text)}</div>`; |
| 233 | } |
| 234 |
| 235 | const json = (await response.json()) as { ui?: Record<string, unknown>[] | Record<string, unknown> }; |
| 236 | const uiNodes = Array.isArray(json.ui) ? json.ui : json.ui ? [json.ui] : []; |
| 237 | return uiNodes.map((node) => renderUiNode(node as Record<string, unknown>)).join(""); |
| 238 | } |
| 239 |
| 240 | async function devHandler(request: Request): Promise<Response> { |
| 241 | const url = new URL(request.url); |
| 242 |
| 243 | // Pass through /coline/* routes to the real handler |
| 244 | if (url.pathname.startsWith("/coline/")) { |
| 245 | return handler(request); |
| 246 | } |
| 247 |
| 248 | const appName = options.app.getDefinition().manifest.name; |
| 249 |
| 250 | // Dev preview routes |
| 251 | if (url.pathname === "/dev" || url.pathname === "/dev/home" || url.pathname === "/") { |
| 252 | const content = await callRender("home"); |
| 253 | return new Response(devShell(appName, content, "home"), { |
| 254 | headers: { "Content-Type": "text/html; charset=utf-8" }, |
| 255 | }); |
| 256 | } |
| 257 |
| 258 | if (url.pathname === "/dev/file") { |
| 259 | const content = await callRender("file", url.searchParams.get("id") ?? undefined); |
| 260 | return new Response(devShell(appName, content, "file"), { |
| 261 | headers: { "Content-Type": "text/html; charset=utf-8" }, |
| 262 | }); |
| 263 | } |
| 264 |
| 265 | // Redirect root to dev preview |
| 266 | return new Response(null, { |
| 267 | status: 302, |
| 268 | headers: { Location: "/dev" }, |
| 269 | }); |
| 270 | } |
| 271 |
| 272 | return { |
| 273 | handler: devHandler, |
| 274 | listen(port?: number) { |
| 275 | const p = port ?? options.port ?? 4200; |
| 276 | // Use a dynamic import to avoid bundling node:http in non-Node environments |
| 277 | // Dynamic import — only works in Node.js environments |
| 278 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 279 | void (import("node:http" as string) as Promise<any>).then((http: { createServer: Function }) => { |
| 280 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 281 | http.createServer(async (req: any, res: any) => { |
| 282 | const url = new URL(req.url ?? "/", `http://localhost:${p}`); |
| 283 | const bodyChunks: string[] = []; |
| 284 | req.on("data", (chunk: { toString(): string }) => bodyChunks.push(chunk.toString())); |
| 285 | req.on("end", async () => { |
| 286 | const body = bodyChunks.join(""); |
| 287 | const request = new Request(url, { |
| 288 | method: req.method, |
| 289 | headers: Object.fromEntries( |
| 290 | Object.entries(req.headers).filter( |
| 291 | (entry): entry is [string, string] => |
| 292 | typeof entry[1] === "string", |
| 293 | ), |
| 294 | ), |
| 295 | ...(body && req.method !== "GET" && req.method !== "HEAD" |
| 296 | ? { body } |
| 297 | : {}), |
| 298 | }); |
| 299 |
| 300 | const response = await devHandler(request); |
| 301 | const responseBody = await response.text(); |
| 302 | res.writeHead( |
| 303 | response.status, |
| 304 | Object.fromEntries(response.headers), |
| 305 | ); |
| 306 | res.end(responseBody); |
| 307 | }); |
| 308 | }) |
| 309 | .listen(p, () => { |
| 310 | console.log(`\n 🎨 Dev preview: http://localhost:${p}/dev`); |
| 311 | console.log(` 📋 Manifest: http://localhost:${p}/coline/manifest\n`); |
| 312 | }); |
| 313 | }); |
| 314 | }, |
| 315 | }; |
| 316 | } |
| 317 |