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 { ColineUiNode } from "../tree/tree"; |
| 10 |
| 11 | /** |
| 12 | * `@colineapp/sdk/site`: the browser client for a Coline site (SDK 2.1). |
| 13 | * |
| 14 | * A site page runs on its own origin and talks only to `/_coline/*` on |
| 15 | * that origin. Every call runs as the visitor: collections answer under |
| 16 | * the rules the manifest declares, and logic runs only through the tools |
| 17 | * listed in `surfaces.site.tools`. |
| 18 | * |
| 19 | * ```ts |
| 20 | * import { site, getSession, signIn } from "@colineapp/sdk/site"; |
| 21 | * |
| 22 | * const entries = await site.collection<Entry>("entries").query({ limit: 20 }); |
| 23 | * await site.tools.invoke("guestbook.sign", { text: "hi" }); |
| 24 | * ``` |
| 25 | */ |
| 26 |
| 27 | export type SiteVisitor = Omit<ColineVisitorRef, "kind"> & { avatarUrl: string | null }; |
| 28 |
| 29 | export interface SiteSession { |
| 30 | visitor: SiteVisitor; |
| 31 | site: { name: string; url: string; signInEnabled: boolean }; |
| 32 | } |
| 33 |
| 34 | export interface SiteToolResult<TOutput = unknown> { |
| 35 | output: TOutput; |
| 36 | card: ColineUiNode | null; |
| 37 | } |
| 38 |
| 39 | /** A failed site call. `code` is stable; `message` is safe to show. */ |
| 40 | export class SiteError extends Error { |
| 41 | readonly code: string; |
| 42 | readonly status: number; |
| 43 |
| 44 | constructor(code: string, message: string, status: number) { |
| 45 | super(message); |
| 46 | this.name = "SiteError"; |
| 47 | this.code = code; |
| 48 | this.status = status; |
| 49 | } |
| 50 | } |
| 51 |
| 52 | const BASE = "/_coline"; |
| 53 |
| 54 | // The first call without a cookie creates a guest. Calls made in parallel |
| 55 | // at startup would each create one, so every call waits for one session. |
| 56 | let session: Promise<SiteSession> | null = null; |
| 57 |
| 58 | function ensureSession(): Promise<SiteSession> { |
| 59 | session ??= send<SiteSession>("/session").catch((error: unknown) => { |
| 60 | session = null; |
| 61 | throw error; |
| 62 | }); |
| 63 | return session; |
| 64 | } |
| 65 |
| 66 | async function request<T>(path: string, init?: RequestInit): Promise<T> { |
| 67 | await ensureSession(); |
| 68 | return send<T>(path, init); |
| 69 | } |
| 70 |
| 71 | async function send<T>(path: string, init?: RequestInit): Promise<T> { |
| 72 | let response: Response; |
| 73 | try { |
| 74 | response = await fetch(`${BASE}${path}`, { |
| 75 | credentials: "same-origin", |
| 76 | ...init, |
| 77 | headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, |
| 78 | }); |
| 79 | } catch { |
| 80 | throw new SiteError("network_error", "Couldn't reach the site. Check your connection.", 0); |
| 81 | } |
| 82 | const body = (await response.json().catch(() => null)) as |
| 83 | | { data?: T; error?: { code?: string; message?: string } } |
| 84 | | null; |
| 85 | if (!response.ok || !body || body.error) { |
| 86 | throw new SiteError( |
| 87 | body?.error?.code ?? "site_error", |
| 88 | body?.error?.message ?? "Something went wrong.", |
| 89 | response.status, |
| 90 | ); |
| 91 | } |
| 92 | return body.data as T; |
| 93 | } |
| 94 |
| 95 | function call<R>(operation: string, args: unknown[]): Promise<R> { |
| 96 | return request<R>("/capability", { method: "POST", body: JSON.stringify({ operation, args }) }); |
| 97 | } |
| 98 |
| 99 | function unavailable(operation: string): never { |
| 100 | throw new SiteError( |
| 101 | "unavailable_to_visitors", |
| 102 | `"${operation}" is not available on a site. Run it in a site tool instead.`, |
| 103 | 403, |
| 104 | ); |
| 105 | } |
| 106 |
| 107 | type Handler = (message: { event: string; data: unknown }) => void; |
| 108 | const handlers = new Map<string, Set<Handler>>(); |
| 109 | let socket: WebSocket | null = null; |
| 110 | let connecting: Promise<void> | null = null; |
| 111 | let reconnect: ReturnType<typeof setTimeout> | null = null; |
| 112 |
| 113 | function connect(): Promise<void> { |
| 114 | if (socket || typeof WebSocket === "undefined") return Promise.resolve(); |
| 115 | connecting ??= open().finally(() => { |
| 116 | connecting = null; |
| 117 | }); |
| 118 | return connecting; |
| 119 | } |
| 120 |
| 121 | async function open() { |
| 122 | let token: { url: string }; |
| 123 | try { |
| 124 | token = await request<{ url: string }>("/realtime"); |
| 125 | } catch { |
| 126 | // Live updates are an optimization: live collections keep polling. |
| 127 | return; |
| 128 | } |
| 129 | // Everyone unsubscribed while the token was on its way. |
| 130 | if (handlers.size === 0) return; |
| 131 | const url = new URL(token.url, location.href); |
| 132 | url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; |
| 133 | const next = new WebSocket(url); |
| 134 | socket = next; |
| 135 | next.addEventListener("message", (event) => { |
| 136 | let message: { event?: string; data?: unknown }; |
| 137 | try { |
| 138 | message = JSON.parse(String(event.data)); |
| 139 | } catch { |
| 140 | return; |
| 141 | } |
| 142 | if (typeof message.event !== "string") return; |
| 143 | for (const handler of handlers.get(message.event) ?? []) handler({ event: message.event, data: message.data ?? null }); |
| 144 | }); |
| 145 | next.addEventListener("close", () => { |
| 146 | if (socket === next) socket = null; |
| 147 | if (handlers.size > 0 && !reconnect) { |
| 148 | reconnect = setTimeout(() => { |
| 149 | reconnect = null; |
| 150 | void connect(); |
| 151 | }, 2_000); |
| 152 | } |
| 153 | }); |
| 154 | } |
| 155 |
| 156 | /** The site's data and tools, as the current visitor. */ |
| 157 | export const site: Pick<ColineCapabilities, "storage" | "realtime"> & { |
| 158 | collection<T>(name: string): ColineCollection<T>; |
| 159 | tools: { invoke<TOutput = unknown>(name: string, input?: unknown): Promise<SiteToolResult<TOutput>> }; |
| 160 | } = { |
| 161 | collection: <T>(name: string) => createCollectionClient<T>(name, call), |
| 162 | storage: { |
| 163 | collection: <T>(name: string) => createCollectionClient<T>(name, call), |
| 164 | kv: { |
| 165 | get: () => unavailable("storage.kv.get"), |
| 166 | set: () => unavailable("storage.kv.set"), |
| 167 | delete: () => unavailable("storage.kv.delete"), |
| 168 | list: () => unavailable("storage.kv.list"), |
| 169 | }, |
| 170 | batch: () => unavailable("storage.batch"), |
| 171 | }, |
| 172 | realtime: { |
| 173 | async subscribe(event, handler) { |
| 174 | const set = handlers.get(event) ?? new Set(); |
| 175 | set.add(handler); |
| 176 | handlers.set(event, set); |
| 177 | void connect(); |
| 178 | return { |
| 179 | unsubscribe() { |
| 180 | set.delete(handler); |
| 181 | if (set.size === 0) handlers.delete(event); |
| 182 | if (handlers.size === 0) { |
| 183 | socket?.close(); |
| 184 | socket = null; |
| 185 | } |
| 186 | }, |
| 187 | }; |
| 188 | }, |
| 189 | publish: () => unavailable("realtime.publish"), |
| 190 | }, |
| 191 | tools: { |
| 192 | invoke: <TOutput = unknown>(name: string, input?: unknown) => |
| 193 | request<SiteToolResult<TOutput>>(`/tools/${encodeURIComponent(name)}`, { |
| 194 | method: "POST", |
| 195 | body: JSON.stringify({ input: input ?? {} }), |
| 196 | }), |
| 197 | }, |
| 198 | }; |
| 199 |
| 200 | /** |
| 201 | * A live view of a collection for this visitor: refreshes when the site's |
| 202 | * records change, and every few seconds as a fallback. |
| 203 | */ |
| 204 | export function liveCollection<T extends Record<string, unknown>>( |
| 205 | name: string, |
| 206 | query: ColineRecordQuery<T> = {}, |
| 207 | options: LiveCollectionOptions = {}, |
| 208 | ) { |
| 209 | // Visitors can't pick a record's id or access, so optimistic inserts go |
| 210 | // without them; the next sync swaps in the server's record. |
| 211 | const visitorSite: Pick<ColineCapabilities, "storage" | "realtime"> = { |
| 212 | ...site, |
| 213 | storage: { |
| 214 | ...site.storage, |
| 215 | collection: <R>(collectionName: string) => { |
| 216 | const collection = site.collection<R>(collectionName); |
| 217 | return { ...collection, insert: (data: R) => collection.insert(data) }; |
| 218 | }, |
| 219 | }, |
| 220 | }; |
| 221 | return createLiveCollection<T>(visitorSite, name, query, { pollIntervalMs: 5_000, ...options }); |
| 222 | } |
| 223 |
| 224 | /** The current visitor and site. Creates a guest session on first call. */ |
| 225 | export function getSession(): Promise<SiteSession> { |
| 226 | return ensureSession(); |
| 227 | } |
| 228 |
| 229 | /** |
| 230 | * Send the visitor to Sign in with Coline, then back to `returnTo` (the |
| 231 | * current page by default). Works only when the site turns sign-in on. |
| 232 | */ |
| 233 | export function signIn(options: { returnTo?: string } = {}): void { |
| 234 | const returnTo = options.returnTo ?? `${location.pathname}${location.search}`; |
| 235 | location.assign(`${BASE}/auth/start?return=${encodeURIComponent(returnTo)}`); |
| 236 | } |
| 237 |
| 238 | /** |
| 239 | * Sign out. The visitor becomes a new guest, and the page reloads so no |
| 240 | * view keeps records only the signed-in person could see (sign-in reloads |
| 241 | * too). Pass `{ reload: false }` only if you clear every view yourself. |
| 242 | */ |
| 243 | export async function signOut(options: { reload?: boolean } = {}): Promise<void> { |
| 244 | await request<null>("/auth/sign-out", { method: "POST", body: "{}" }); |
| 245 | session = null; |
| 246 | if (options.reload !== false && typeof location !== "undefined") location.reload(); |
| 247 | } |
| 248 |
| 249 | /** The current path matched against the site's routes, with `:param` values. */ |
| 250 | export function matchRoute( |
| 251 | pattern: string, |
| 252 | pathname: string = typeof location === "undefined" ? "/" : location.pathname, |
| 253 | ): Record<string, string> | null { |
| 254 | const patternParts = pattern.split("/").filter(Boolean); |
| 255 | const pathParts = pathname.split("/").filter(Boolean); |
| 256 | const params: Record<string, string> = {}; |
| 257 | for (let index = 0; index < patternParts.length; index += 1) { |
| 258 | const part = patternParts[index]!; |
| 259 | if (part === "*") return params; |
| 260 | const value = pathParts[index]; |
| 261 | if (value === undefined) return null; |
| 262 | if (part.startsWith(":")) { |
| 263 | try { |
| 264 | params[part.slice(1)] = decodeURIComponent(value); |
| 265 | } catch { |
| 266 | return null; |
| 267 | } |
| 268 | } |
| 269 | else if (part !== value) return null; |
| 270 | } |
| 271 | return pathParts.length === patternParts.length ? params : null; |
| 272 | } |
| 273 |