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 { ColineUiNode } from "../tree/tree"; /** * `@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 { site, getSession, signIn } from "@colineapp/sdk/site"; * * const entries = await site.collection("entries").query({ limit: 20 }); * await site.tools.invoke("guestbook.sign", { text: "hi" }); * ``` */ export type SiteVisitor = Omit & { avatarUrl: string | null }; export interface SiteSession { visitor: SiteVisitor; site: { name: string; url: string; signInEnabled: boolean }; } export interface SiteToolResult { output: TOutput; card: ColineUiNode | null; } /** 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").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); } }); } /** The site's data and tools, as the current visitor. */ export const site: Pick & { collection(name: string): ColineCollection; 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"), }, 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: (name: string, input?: unknown) => request>(`/tools/${encodeURIComponent(name)}`, { method: "POST", body: JSON.stringify({ input: input ?? {} }), }), }, }; /** * 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 }); } /** The current visitor and site. Creates a guest session on first call. */ export function getSession(): Promise { return ensureSession(); } /** * Send the visitor to Sign in with Coline, then back to `returnTo` (the * current page by default). Works only when the site turns sign-in on. */ export function signIn(options: { returnTo?: string } = {}): void { const returnTo = options.returnTo ?? `${location.pathname}${location.search}`; location.assign(`${BASE}/auth/start?return=${encodeURIComponent(returnTo)}`); } /** * 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; }