import { createCollectionClient } from "../capabilities/collection-client"; import { createLiveCollection, type LiveCollectionOptions } from "../capabilities/live-collection"; import type { ColineCapabilities, ColineCollection, ColineRecordQuery, ColineVisitorRef, } from "../capabilities/types"; import type { SiteSignInProvider, SiteVisitorAccess } from "../manifest/surfaces"; import type { ColineUiNode } from "../tree/tree"; import type { SiteAppCollectionName, SiteAppRecord, SiteAppToolInput, SiteAppToolName, SiteAppToolOutput } from "./types"; export type { SiteAppCollectionName, SiteAppRecord, SiteAppToolInput, SiteAppToolName, SiteAppToolOutput, } from "./types"; /** * `@colineapp/sdk/site`: the browser client for a Coline site (SDK 2.1). * * A site page runs on its own origin and talks only to `/_coline/*` on * that origin. Every call runs as the visitor: collections answer under * the rules the manifest declares, and logic runs only through the tools * listed in `surfaces.site.tools`. * * ```ts * import { createSiteClient, signIn } from "@colineapp/sdk/site"; * import type app from "../app.config"; * * const site = createSiteClient(); * const entries = await site.collection("entries").query({ limit: 20 }); // typed records * await site.tools.invoke("guestbook.sign", { name: "Ada", message: "hi" }); // typed input * ``` */ export type SiteVisitor = Omit & { avatarUrl: string | null; /** How they signed in; null for guests. */ provider: SiteSignInProvider | null; /** Whether the site's access mode lets them use its data and tools. */ admitted: boolean; }; export interface SiteSession { visitor: SiteVisitor; site: { name: string; url: string; signInEnabled: boolean; /** Sign-in options this site offers right now, in order. */ signInProviders: SiteSignInProvider[]; access: SiteVisitorAccess; /** What uploads the site accepts, or null when it accepts none. */ uploads: { who: "visitor" | "signedIn"; maxFileBytes: number; types: string[] } | null; /** Tools registered with in-browser agents, when the site turns that on. */ agentTools: SiteAgentTool[]; }; } export interface SiteAgentTool { name: string; description: string; inputSchema: Record; readOnly: boolean; } export interface SiteToolResult { output: TOutput; card: ColineUiNode | null; } /** A file stored on the site. `url` is a path on the site; public files work in ``. */ export interface SiteFile { id: string; name: string; size: number; contentType: string; public: boolean; url: string; createdAt: string; } /** A failed site call. `code` is stable; `message` is safe to show. */ export class SiteError extends Error { readonly code: string; readonly status: number; constructor(code: string, message: string, status: number) { super(message); this.name = "SiteError"; this.code = code; this.status = status; } } const BASE = "/_coline"; // The first call without a cookie creates a guest. Calls made in parallel // at startup would each create one, so every call waits for one session. let session: Promise | null = null; function ensureSession(): Promise { session ??= send("/session") .then((value) => { registerAgentTools(value); return value; }) .catch((error: unknown) => { session = null; throw error; }); return session; } async function request(path: string, init?: RequestInit): Promise { await ensureSession(); return send(path, init); } async function send(path: string, init?: RequestInit): Promise { let response: Response; try { response = await fetch(`${BASE}${path}`, { credentials: "same-origin", ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, }); } catch { throw new SiteError("network_error", "Couldn't reach the site. Check your connection.", 0); } const body = (await response.json().catch(() => null)) as | { data?: T; error?: { code?: string; message?: string } } | null; if (!response.ok || !body || body.error) { throw new SiteError( body?.error?.code ?? "site_error", body?.error?.message ?? "Something went wrong.", response.status, ); } return body.data as T; } function call(operation: string, args: unknown[]): Promise { return request("/capability", { method: "POST", body: JSON.stringify({ operation, args }) }); } function unavailable(operation: string): never { throw new SiteError( "unavailable_to_visitors", `"${operation}" is not available on a site. Run it in a site tool instead.`, 403, ); } type Handler = (message: { event: string; data: unknown }) => void; const handlers = new Map>(); let socket: WebSocket | null = null; let connecting: Promise | null = null; let reconnect: ReturnType | null = null; function connect(): Promise { if (socket || typeof WebSocket === "undefined") return Promise.resolve(); connecting ??= open().finally(() => { connecting = null; }); return connecting; } async function open() { let token: { url: string }; try { token = await request<{ url: string }>("/realtime"); } catch { // Live updates are an optimization: live collections keep polling. return; } // Everyone unsubscribed while the token was on its way. if (handlers.size === 0) return; const url = new URL(token.url, location.href); url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; const next = new WebSocket(url); socket = next; next.addEventListener("message", (event) => { let message: { event?: string; data?: unknown }; try { message = JSON.parse(String(event.data)); } catch { return; } if (typeof message.event !== "string") return; for (const handler of handlers.get(message.event) ?? []) handler({ event: message.event, data: message.data ?? null }); }); next.addEventListener("close", () => { if (socket === next) socket = null; if (handlers.size > 0 && !reconnect) { reconnect = setTimeout(() => { reconnect = null; void connect(); }, 2_000); } }); } function invokeTool(name: string, input?: unknown): Promise> { return request>(`/tools/${encodeURIComponent(name)}`, { method: "POST", body: JSON.stringify({ input: input ?? {} }), }); } /** Files on the site: visitors upload when `surfaces.site.uploads` allows it. */ export interface SiteFiles { /** Upload a file. Private files are readable only by you and the team. */ upload(file: Blob, options?: { name?: string; public?: boolean }): Promise; /** Your uploads on this site, newest first. */ list(): Promise; /** Delete one of your uploads. */ delete(id: string): Promise; /** The site path that serves a file. */ url(id: string): string; } const files: SiteFiles = { async upload(file, options = {}) { await ensureSession(); const name = options.name ?? (typeof File !== "undefined" && file instanceof File ? file.name : "file"); return send(`/files${options.public ? "?public=1" : ""}`, { method: "POST", body: file, headers: { "content-type": file.type || "application/octet-stream", "x-coline-file-name": encodeURIComponent(name), }, }); }, async list() { return (await request<{ files: SiteFile[] }>("/files")).files; }, async delete(id) { await request(`/files/${encodeURIComponent(id)}`, { method: "DELETE" }); }, url: (id) => `${BASE}/files/${encodeURIComponent(id)}`, }; /** The site's data, files and tools, as the current visitor. */ export const site: Pick & { collection(name: string): ColineCollection; files: SiteFiles; tools: { invoke(name: string, input?: unknown): Promise> }; } = { collection: (name: string) => createCollectionClient(name, call), storage: { collection: (name: string) => createCollectionClient(name, call), kv: { get: () => unavailable("storage.kv.get"), set: () => unavailable("storage.kv.set"), delete: () => unavailable("storage.kv.delete"), list: () => unavailable("storage.kv.list"), }, batch: () => unavailable("storage.batch"), }, files, realtime: { async subscribe(event, handler) { const set = handlers.get(event) ?? new Set(); set.add(handler); handlers.set(event, set); void connect(); return { unsubscribe() { set.delete(handler); if (set.size === 0) handlers.delete(event); if (handlers.size === 0) { socket?.close(); socket = null; } }, }; }, publish: () => unavailable("realtime.publish"), }, tools: { invoke: invokeTool }, }; /** * A live view of a collection for this visitor: refreshes when the site's * records change, and every few seconds as a fallback. */ export function liveCollection>( name: string, query: ColineRecordQuery = {}, options: LiveCollectionOptions = {}, ) { // Visitors can't pick a record's id or access, so optimistic inserts go // without them; the next sync swaps in the server's record. const visitorSite: Pick = { ...site, storage: { ...site.storage, collection: (collectionName: string) => { const collection = site.collection(collectionName); return { ...collection, insert: (data: R) => collection.insert(data) }; }, }, }; return createLiveCollection(visitorSite, name, query, { pollIntervalMs: 5_000, ...options }); } export type LiveSiteCollection> = ReturnType>; /** * The site client typed from the app itself: collection names and record * shapes from each collection's `schema`, tool names, inputs and outputs * from `defineTool`. Import the app as a type only, so none of its server * code reaches the page: * * ```ts * import type app from "../app.config"; * const site = createSiteClient(); * ``` */ export function createSiteClient(): TypedSite { return { collection: (name) => site.collection(name), liveCollection: (name, query, options) => liveCollection(name, query as never, options), files, tools: { invoke: (name, input) => invokeTool(name, input) }, } as TypedSite; } export interface TypedSite { collection>(name: K): ColineCollection>; liveCollection>( name: K, query?: ColineRecordQuery>, options?: LiveCollectionOptions, ): LiveSiteCollection>; files: SiteFiles; tools: { invoke>( name: N, input: SiteAppToolInput, ): Promise>>; }; } /** The current visitor and site. Creates a guest session on first call. */ export function getSession(): Promise { return ensureSession(); } /** * Send the visitor to sign in, then back to `returnTo` (the current page by * default). `provider` picks Google or a Coline account; without it, the * site's first option. Works only when the site turns sign-in on. */ export function signIn(options: { provider?: SiteSignInProvider; returnTo?: string } = {}): void { const returnTo = options.returnTo ?? `${location.pathname}${location.search}`; const params = new URLSearchParams({ return: returnTo }); if (options.provider) params.set("provider", options.provider); location.assign(`${BASE}/auth/start?${params.toString()}`); } /** * Sign out. The visitor becomes a new guest, and the page reloads so no * view keeps records only the signed-in person could see (sign-in reloads * too). Pass `{ reload: false }` only if you clear every view yourself. */ export async function signOut(options: { reload?: boolean } = {}): Promise { await request("/auth/sign-out", { method: "POST", body: "{}" }); session = null; if (options.reload !== false && typeof location !== "undefined") location.reload(); } /** The current path matched against the site's routes, with `:param` values. */ export function matchRoute( pattern: string, pathname: string = typeof location === "undefined" ? "/" : location.pathname, ): Record | null { const patternParts = pattern.split("/").filter(Boolean); const pathParts = pathname.split("/").filter(Boolean); const params: Record = {}; for (let index = 0; index < patternParts.length; index += 1) { const part = patternParts[index]!; if (part === "*") return params; const value = pathParts[index]; if (value === undefined) return null; if (part.startsWith(":")) { try { params[part.slice(1)] = decodeURIComponent(value); } catch { return null; } } else if (part !== value) return null; } return pathParts.length === patternParts.length ? params : null; } // ── In-browser agents (WebMCP) ───────────────────────────────────────── interface ModelContextTool { name: string; description: string; inputSchema: Record; annotations?: { readOnlyHint?: boolean }; execute(input: unknown): Promise<{ content: Array<{ type: "text"; text: string }> }>; } interface ModelContext { registerTool?(tool: ModelContextTool): unknown; provideContext?(context: { tools: ModelContextTool[] }): unknown; } let agentToolsRegistered = false; /** * With `surfaces.site.agentTools: true`, the site's listed tools are * offered to an agent in the visitor's tab through WebMCP * (`navigator.modelContext`). The agent calls them as the visitor, under * the same rules as the page. Browsers without WebMCP ignore this. */ function registerAgentTools(value: SiteSession): void { if (agentToolsRegistered || value.site.agentTools.length === 0 || typeof navigator === "undefined") return; const context = (navigator as Navigator & { modelContext?: ModelContext }).modelContext; if (!context?.registerTool && !context?.provideContext) return; agentToolsRegistered = true; const tools: ModelContextTool[] = value.site.agentTools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, annotations: { readOnlyHint: tool.readOnly }, async execute(input) { const result = await invokeTool(tool.name, input); return { content: [{ type: "text", text: JSON.stringify(result.output ?? null) }] }; }, })); if (context.registerTool) { for (const tool of tools) context.registerTool(tool); } else { context.provideContext!({ tools }); } } // Register as soon as an agent-capable browser loads the page. if (typeof navigator !== "undefined" && (navigator as Navigator & { modelContext?: ModelContext }).modelContext) { void ensureSession().catch(() => undefined); }