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.
| 4 | ColineCapabilities, |
| 5 | ColineCollection, |
| 6 | ColineRecordQuery, |
| 7 | ColineVisitorRef, |
| 8 | } from "../capabilities/types"; |
| 9 | import type { SiteSignInProvider, SiteVisitorAccess } from "../manifest/surfaces"; |
| 10 | import type { ColineUiNode } from "../tree/tree"; |
| 11 | import type { SiteAppCollectionName, SiteAppRecord, SiteAppToolInput, SiteAppToolName, SiteAppToolOutput } from "./types"; |
| 12 |
| 13 | export type { |
| 14 | SiteAppCollectionName, |
| 15 | SiteAppRecord, |
| 16 | SiteAppToolInput, |
| 17 | SiteAppToolName, |
| 18 | SiteAppToolOutput, |
| 19 | } from "./types"; |
| 20 |
| 21 | /** |
| 22 | * `@colineapp/sdk/site`: the browser client for a Coline site (SDK 2.1). |
| 23 | * |
| 24 | * A site page runs on its own origin and talks only to `/_coline/*` on |
| 25 | * that origin. Every call runs as the visitor: collections answer under |
| 26 | * the rules the manifest declares, and logic runs only through the tools |
| 27 | * listed in `surfaces.site.tools`. |
| 28 | * |
| 29 | * ```ts |
| 30 | * import { createSiteClient, signIn } from "@colineapp/sdk/site"; |
| 31 | * import type app from "../app.config"; |
| 32 | * |
| 33 | * const site = createSiteClient<typeof app>(); |
| 34 | * const entries = await site.collection("entries").query({ limit: 20 }); // typed records |
| 35 | * await site.tools.invoke("guestbook.sign", { name: "Ada", message: "hi" }); // typed input |
| 36 | * ``` |
| 37 | */ |
| 38 |
| 39 | export type SiteVisitor = Omit<ColineVisitorRef, "kind"> & { |
| 40 | avatarUrl: string | null; |
| 41 | /** How they signed in; null for guests. */ |
| 42 | provider: SiteSignInProvider | null; |
| 43 | /** Whether the site's access mode lets them use its data and tools. */ |
| 44 | admitted: boolean; |
| 45 | }; |
| 46 |
| 47 | export interface SiteSession { |
| 48 | visitor: SiteVisitor; |
| 49 | site: { |
| 50 | name: string; |
| 51 | url: string; |
| 52 | signInEnabled: boolean; |
| 53 | /** Sign-in options this site offers right now, in order. */ |
| 54 | signInProviders: SiteSignInProvider[]; |
| 55 | access: SiteVisitorAccess; |
| 56 | /** What uploads the site accepts, or null when it accepts none. */ |
| 57 | uploads: { who: "visitor" | "signedIn"; maxFileBytes: number; types: string[] } | null; |
| 58 | /** Tools registered with in-browser agents, when the site turns that on. */ |
| 59 | agentTools: SiteAgentTool[]; |
| 60 | }; |
| 61 | } |
| 62 |
| 63 | export interface SiteAgentTool { |
| 64 | name: string; |
| 65 | description: string; |
| 66 | inputSchema: Record<string, unknown>; |
| 67 | readOnly: boolean; |
| 68 | } |
| 69 |
| 70 | export interface SiteToolResult<TOutput = unknown> { |
| 71 | output: TOutput; |
| 72 | card: ColineUiNode | null; |
| 73 | } |
| 74 |
| 75 | /** A file stored on the site. `url` is a path on the site; public files work in `<img src>`. */ |
| 76 | export interface SiteFile { |
| 77 | id: string; |
| 78 | name: string; |
| 79 | size: number; |
| 80 | contentType: string; |
| 81 | public: boolean; |
| 82 | url: string; |
| 83 | createdAt: string; |
| 84 | } |
| 85 |
| 86 | /** A failed site call. `code` is stable; `message` is safe to show. */ |
| 87 | export class SiteError extends Error { |
| 88 | readonly code: string; |
| 89 | readonly status: number; |
| 90 |
| 91 | constructor(code: string, message: string, status: number) { |
| 92 | super(message); |
| 93 | this.name = "SiteError"; |
| 94 | this.code = code; |
| 95 | this.status = status; |
| 96 | } |
| 97 | } |
| 98 |
| 99 | const BASE = "/_coline"; |
| 100 |
| 101 | // The first call without a cookie creates a guest. Calls made in parallel |
| 102 | // at startup would each create one, so every call waits for one session. |
| 103 | let session: Promise<SiteSession> | null = null; |
| 104 |
| 105 | /** |
| 106 | * Fill fields a site served by an older Coline doesn't send yet, so a page |
| 107 | * built with a newer SDK still loads there. Never invents a sign-in. |
| 108 | */ |
| 109 | function normalizeSession(raw: SiteSession): SiteSession { |
| 110 | const site: Partial<SiteSession["site"]> = raw.site ?? {}; |
| 111 | const visitor: Partial<SiteVisitor> = raw.visitor ?? {}; |
| 112 | const access = site.access ?? "open"; |
| 113 | return { |
| 114 | visitor: { |
| 115 | ...(visitor as SiteVisitor), |
| 116 | provider: visitor.provider ?? (visitor.signedIn ? "coline" : null), |
| 117 | admitted: visitor.admitted ?? (access === "open" || Boolean(visitor.signedIn)), |
| 118 | }, |
| 119 | site: { |
| 120 | ...(site as SiteSession["site"]), |
| 121 | signInProviders: site.signInProviders ?? (site.signInEnabled ? ["coline"] : []), |
| 122 | access, |
| 123 | uploads: site.uploads ?? null, |
| 124 | agentTools: site.agentTools ?? [], |
| 125 | }, |
| 126 | }; |
| 127 | } |
| 128 |
| 129 | function ensureSession(): Promise<SiteSession> { |
| 130 | session ??= send<SiteSession>("/session") |
| 131 | .then((raw) => { |
| 132 | const value = normalizeSession(raw); |
| 133 | registerAgentTools(value); |
| 134 | return value; |
| 135 | }) |
| 136 | .catch((error: unknown) => { |
| 137 | session = null; |
| 138 | throw error; |
| 139 | }); |
| 140 | return session; |
| 141 | } |
| 142 |
| 143 | async function request<T>(path: string, init?: RequestInit): Promise<T> { |
| 144 | await ensureSession(); |
| 145 | return send<T>(path, init); |
| 146 | } |
| 147 |
| 148 | async function send<T>(path: string, init?: RequestInit): Promise<T> { |
| 149 | let response: Response; |
| 150 | try { |
| 151 | response = await fetch(`${BASE}${path}`, { |
| 152 | credentials: "same-origin", |
| 153 | ...init, |
| 154 | headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, |
| 155 | }); |
| 156 | } catch { |
| 157 | throw new SiteError("network_error", "Couldn't reach the site. Check your connection.", 0); |
| 158 | } |
| 159 | const body = (await response.json().catch(() => null)) as |
| 160 | | { data?: T; error?: { code?: string; message?: string } } |
| 161 | | null; |
| 162 | if (!response.ok || !body || body.error) { |
| 163 | throw new SiteError( |
| 164 | body?.error?.code ?? "site_error", |
| 165 | body?.error?.message ?? "Something went wrong.", |
| 166 | response.status, |
| 167 | ); |
| 168 | } |
| 169 | return body.data as T; |
| 170 | } |
| 171 |
| 172 | function call<R>(operation: string, args: unknown[]): Promise<R> { |
| 173 | return request<R>("/capability", { method: "POST", body: JSON.stringify({ operation, args }) }); |
| 174 | } |
| 175 |
| 176 | function unavailable(operation: string): never { |
| 177 | throw new SiteError( |
| 178 | "unavailable_to_visitors", |
| 179 | `"${operation}" is not available on a site. Run it in a site tool instead.`, |
| 180 | 403, |
| 181 | ); |
| 182 | } |
| 183 |
| 184 | type Handler = (message: { event: string; data: unknown }) => void; |
| 185 | const handlers = new Map<string, Set<Handler>>(); |
| 186 | let socket: WebSocket | null = null; |
| 187 | let connecting: Promise<void> | null = null; |
| 188 | let reconnect: ReturnType<typeof setTimeout> | null = null; |
| 189 |
| 190 | function connect(): Promise<void> { |
| 191 | if (socket || typeof WebSocket === "undefined") return Promise.resolve(); |
| 192 | connecting ??= open().finally(() => { |
| 193 | connecting = null; |
| 194 | }); |
| 195 | return connecting; |
| 196 | } |
| 197 |
| 198 | async function open() { |
| 199 | let token: { url: string }; |
| 200 | try { |
| 201 | token = await request<{ url: string }>("/realtime"); |
| 202 | } catch { |
| 203 | // Live updates are an optimization: live collections keep polling. |
| 204 | return; |
| 205 | } |
| 206 | // Everyone unsubscribed while the token was on its way. |
| 207 | if (handlers.size === 0) return; |
| 208 | const url = new URL(token.url, location.href); |
| 209 | url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; |
| 210 | const next = new WebSocket(url); |
| 211 | socket = next; |
| 212 | next.addEventListener("message", (event) => { |
| 213 | let message: { event?: string; data?: unknown }; |
| 214 | try { |
| 215 | message = JSON.parse(String(event.data)); |
| 216 | } catch { |
| 217 | return; |
| 218 | } |
| 219 | if (typeof message.event !== "string") return; |
| 220 | for (const handler of handlers.get(message.event) ?? []) handler({ event: message.event, data: message.data ?? null }); |
| 221 | }); |
| 222 | next.addEventListener("close", () => { |
| 223 | if (socket === next) socket = null; |
| 224 | if (handlers.size > 0 && !reconnect) { |
| 225 | reconnect = setTimeout(() => { |
| 226 | reconnect = null; |
| 227 | void connect(); |
| 228 | }, 2_000); |
| 229 | } |
| 230 | }); |
| 231 | } |
| 232 |
| 233 | function invokeTool<TOutput>(name: string, input?: unknown): Promise<SiteToolResult<TOutput>> { |
| 234 | return request<SiteToolResult<TOutput>>(`/tools/${encodeURIComponent(name)}`, { |
| 235 | method: "POST", |
| 236 | body: JSON.stringify({ input: input ?? {} }), |
| 237 | }); |
| 238 | } |
| 239 |
| 240 | /** Files on the site: visitors upload when `surfaces.site.uploads` allows it. */ |
| 241 | export interface SiteFiles { |
| 242 | /** Upload a file. Private files are readable only by you and the team. */ |
| 243 | upload(file: Blob, options?: { name?: string; public?: boolean }): Promise<SiteFile>; |
| 244 | /** Your uploads on this site, newest first. */ |
| 245 | list(): Promise<SiteFile[]>; |
| 246 | /** Delete one of your uploads. */ |
| 247 | delete(id: string): Promise<void>; |
| 248 | /** The site path that serves a file. */ |
| 249 | url(id: string): string; |
| 250 | } |
| 251 |
| 252 | const files: SiteFiles = { |
| 253 | async upload(file, options = {}) { |
| 254 | await ensureSession(); |
| 255 | const name = options.name ?? (typeof File !== "undefined" && file instanceof File ? file.name : "file"); |
| 256 | return send<SiteFile>(`/files${options.public ? "?public=1" : ""}`, { |
| 257 | method: "POST", |
| 258 | body: file, |
| 259 | headers: { |
| 260 | "content-type": file.type || "application/octet-stream", |
| 261 | "x-coline-file-name": encodeURIComponent(name), |
| 262 | }, |
| 263 | }); |
| 264 | }, |
| 265 | async list() { |
| 266 | return (await request<{ files: SiteFile[] }>("/files")).files; |
| 267 | }, |
| 268 | async delete(id) { |
| 269 | await request<null>(`/files/${encodeURIComponent(id)}`, { method: "DELETE" }); |
| 270 | }, |
| 271 | url: (id) => `${BASE}/files/${encodeURIComponent(id)}`, |
| 272 | }; |
| 273 |
| 274 | /** The site's data, files and tools, as the current visitor. */ |
| 275 | export const site: Pick<ColineCapabilities, "storage" | "realtime"> & { |
| 276 | collection<T>(name: string): ColineCollection<T>; |
| 277 | files: SiteFiles; |
| 278 | tools: { invoke<TOutput = unknown>(name: string, input?: unknown): Promise<SiteToolResult<TOutput>> }; |
| 279 | } = { |
| 280 | collection: <T>(name: string) => createCollectionClient<T>(name, call), |
| 281 | storage: { |
| 282 | collection: <T>(name: string) => createCollectionClient<T>(name, call), |
| 283 | kv: { |
| 284 | get: () => unavailable("storage.kv.get"), |
| 285 | set: () => unavailable("storage.kv.set"), |
| 286 | delete: () => unavailable("storage.kv.delete"), |
| 287 | list: () => unavailable("storage.kv.list"), |
| 288 | }, |
| 289 | batch: () => unavailable("storage.batch"), |
| 290 | }, |
| 291 | files, |
| 292 | realtime: { |
| 293 | async subscribe(event, handler) { |
| 294 | const set = handlers.get(event) ?? new Set(); |
| 295 | set.add(handler); |
| 296 | handlers.set(event, set); |
| 297 | void connect(); |
| 298 | return { |
| 299 | unsubscribe() { |
| 300 | set.delete(handler); |
| 301 | if (set.size === 0) handlers.delete(event); |
| 302 | if (handlers.size === 0) { |
| 303 | socket?.close(); |
| 304 | socket = null; |
| 305 | } |
| 306 | }, |
| 307 | }; |
| 308 | }, |
| 309 | publish: () => unavailable("realtime.publish"), |
| 310 | }, |
| 311 | tools: { invoke: invokeTool }, |
| 312 | }; |
| 313 |
| 314 | /** |
| 315 | * A live view of a collection for this visitor: refreshes when the site's |
| 316 | * records change, and every few seconds as a fallback. |
| 317 | */ |
| 318 | export function liveCollection<T extends Record<string, unknown>>( |
| 319 | name: string, |
| 320 | query: ColineRecordQuery<T> = {}, |
| 321 | options: LiveCollectionOptions = {}, |
| 322 | ) { |
| 323 | // Visitors can't pick a record's id or access, so optimistic inserts go |
| 324 | // without them; the next sync swaps in the server's record. |
| 325 | const visitorSite: Pick<ColineCapabilities, "storage" | "realtime"> = { |
| 326 | ...site, |
| 327 | storage: { |
| 328 | ...site.storage, |
| 329 | collection: <R>(collectionName: string) => { |
| 330 | const collection = site.collection<R>(collectionName); |
| 331 | return { ...collection, insert: (data: R) => collection.insert(data) }; |
| 332 | }, |
| 333 | }, |
| 334 | }; |
| 335 | return createLiveCollection<T>(visitorSite, name, query, { pollIntervalMs: 5_000, ...options }); |
| 336 | } |
| 337 |
| 338 | export type LiveSiteCollection<T extends Record<string, unknown>> = ReturnType<typeof liveCollection<T>>; |
| 339 |
| 340 | /** |
| 341 | * The site client typed from the app itself: collection names and record |
| 342 | * shapes from each collection's `schema`, tool names, inputs and outputs |
| 343 | * from `defineTool`. Import the app as a type only, so none of its server |
| 344 | * code reaches the page: |
| 345 | * |
| 346 | * ```ts |
| 347 | * import type app from "../app.config"; |
| 348 | * const site = createSiteClient<typeof app>(); |
| 349 | * ``` |
| 350 | */ |
| 351 | export function createSiteClient<App>(): TypedSite<App> { |
| 352 | return { |
| 353 | collection: (name) => site.collection(name), |
| 354 | liveCollection: (name, query, options) => liveCollection(name, query as never, options), |
| 355 | files, |
| 356 | tools: { invoke: (name, input) => invokeTool(name, input) }, |
| 357 | } as TypedSite<App>; |
| 358 | } |
| 359 |
| 360 | export interface TypedSite<App> { |
| 361 | collection<K extends SiteAppCollectionName<App>>(name: K): ColineCollection<SiteAppRecord<App, K>>; |
| 362 | liveCollection<K extends SiteAppCollectionName<App>>( |
| 363 | name: K, |
| 364 | query?: ColineRecordQuery<SiteAppRecord<App, K>>, |
| 365 | options?: LiveCollectionOptions, |
| 366 | ): LiveSiteCollection<SiteAppRecord<App, K>>; |
| 367 | files: SiteFiles; |
| 368 | tools: { |
| 369 | invoke<N extends SiteAppToolName<App>>( |
| 370 | name: N, |
| 371 | input: SiteAppToolInput<App, N>, |
| 372 | ): Promise<SiteToolResult<SiteAppToolOutput<App, N>>>; |
| 373 | }; |
| 374 | } |
| 375 |
| 376 | /** The current visitor and site. Creates a guest session on first call. */ |
| 377 | export function getSession(): Promise<SiteSession> { |
| 378 | return ensureSession(); |
| 379 | } |
| 380 |
| 381 | /** |
| 382 | * Send the visitor to sign in, then back to `returnTo` (the current page by |
| 383 | * default). `provider` picks Google or a Coline account; without it, the |
| 384 | * site's first option. Works only when the site turns sign-in on. |
| 385 | */ |
| 386 | export function signIn(options: { provider?: SiteSignInProvider; returnTo?: string } = {}): void { |
| 387 | const returnTo = options.returnTo ?? `${location.pathname}${location.search}`; |
| 388 | const params = new URLSearchParams({ return: returnTo }); |
| 389 | if (options.provider) params.set("provider", options.provider); |
| 390 | location.assign(`${BASE}/auth/start?${params.toString()}`); |
| 391 | } |
| 392 |
| 393 | /** |
| 394 | * Sign out. The visitor becomes a new guest, and the page reloads so no |
| 395 | * view keeps records only the signed-in person could see (sign-in reloads |
| 396 | * too). Pass `{ reload: false }` only if you clear every view yourself. |
| 397 | */ |
| 398 | export async function signOut(options: { reload?: boolean } = {}): Promise<void> { |
| 399 | await request<null>("/auth/sign-out", { method: "POST", body: "{}" }); |
| 400 | session = null; |
| 401 | if (options.reload !== false && typeof location !== "undefined") location.reload(); |
| 402 | } |
| 403 |
| 404 | /** The current path matched against the site's routes, with `:param` values. */ |
| 405 | export function matchRoute( |
| 406 | pattern: string, |
| 407 | pathname: string = typeof location === "undefined" ? "/" : location.pathname, |
| 408 | ): Record<string, string> | null { |
| 409 | const patternParts = pattern.split("/").filter(Boolean); |
| 410 | const pathParts = pathname.split("/").filter(Boolean); |
| 411 | const params: Record<string, string> = {}; |
| 412 | for (let index = 0; index < patternParts.length; index += 1) { |
| 413 | const part = patternParts[index]!; |
| 414 | if (part === "*") return params; |
| 415 | const value = pathParts[index]; |
| 416 | if (value === undefined) return null; |
| 417 | if (part.startsWith(":")) { |
| 418 | try { |
| 419 | params[part.slice(1)] = decodeURIComponent(value); |
| 420 | } catch { |
| 421 | return null; |
| 422 | } |
| 423 | } else if (part !== value) return null; |
| 424 | } |
| 425 | return pathParts.length === patternParts.length ? params : null; |
| 426 | } |
| 427 |
| 428 | // ── In-browser agents (WebMCP) ───────────────────────────────────────── |
| 429 |
| 430 | interface ModelContextTool { |
| 431 | name: string; |
| 432 | description: string; |
| 433 | inputSchema: Record<string, unknown>; |
| 434 | annotations?: { readOnlyHint?: boolean }; |
| 435 | execute(input: unknown): Promise<{ content: Array<{ type: "text"; text: string }> }>; |
| 436 | } |
| 437 |
| 438 | interface ModelContext { |
| 439 | registerTool?(tool: ModelContextTool): unknown; |
| 440 | provideContext?(context: { tools: ModelContextTool[] }): unknown; |
| 441 | } |
| 442 |
| 443 | let agentToolsRegistered = false; |
| 444 |
| 445 | /** |
| 446 | * With `surfaces.site.agentTools: true`, the site's listed tools are |
| 447 | * offered to an agent in the visitor's tab through WebMCP |
| 448 | * (`navigator.modelContext`). The agent calls them as the visitor, under |
| 449 | * the same rules as the page. Browsers without WebMCP ignore this. |
| 450 | */ |
| 451 | function registerAgentTools(value: SiteSession): void { |
| 452 | if (agentToolsRegistered || value.site.agentTools.length === 0 || typeof navigator === "undefined") return; |
| 453 | const context = (navigator as Navigator & { modelContext?: ModelContext }).modelContext; |
| 454 | if (!context?.registerTool && !context?.provideContext) return; |
| 455 | agentToolsRegistered = true; |
| 456 | const tools: ModelContextTool[] = value.site.agentTools.map((tool) => ({ |
| 457 | name: tool.name, |
| 458 | description: tool.description, |
| 459 | inputSchema: tool.inputSchema, |
| 460 | annotations: { readOnlyHint: tool.readOnly }, |
| 461 | async execute(input) { |
| 462 | const result = await invokeTool(tool.name, input); |
| 463 | return { content: [{ type: "text", text: JSON.stringify(result.output ?? null) }] }; |
| 464 | }, |
| 465 | })); |
| 466 | if (context.registerTool) { |
| 467 | for (const tool of tools) context.registerTool(tool); |
| 468 | } else { |
| 469 | context.provideContext!({ tools }); |
| 470 | } |
| 471 | } |
| 472 |
| 473 | // Register as soon as an agent-capable browser loads the page. |
| 474 | if (typeof navigator !== "undefined" && (navigator as Navigator & { modelContext?: ModelContext }).modelContext) { |
| 475 | void ensureSession().catch(() => undefined); |
| 476 | } |
| 477 |