// Client side of the backend API: fetch wrappers plus the /api/events // WebSocket with auto-reconnect. All server truth arrives as full state // snapshots; the desk store consumes them. import type { AtProfile, Bounds, DocInfo, DocWindowRec, ModelEntry, ServerState, StoreTileView, SyncFrame, SyncMember } from '../types.ts'; export interface OpenResult { doc: DocInfo; win: DocWindowRec; url: string; existing: boolean; /** Desktop mode: the server already raised the existing window. */ focused?: boolean; /** Web mode: open the url as an ordinary browser tab, not a popup. */ tab?: boolean; } /** Launch-time bearer token, delivered in the URL the server navigated this window to. Everything the page asks of the backend must carry it. */ export const apiKey = new URLSearchParams(location.search).get('key') ?? ''; /** For URLs that can't send headers (img src, WebSocket, beacons). */ export const keyed = (url: string): string => `${url}${url.includes('?') ? '&' : '?'}key=${apiKey}`; const authHeaders = (extra: Record = {}) => ({ 'x-ziran-key': apiKey, ...extra }); export interface PickResult { path?: string; cancelled?: boolean; unsupported?: boolean; /** The request itself failed (auth, backend gone): not a dialog outcome. */ error?: string; } async function pickRequest(url: string): Promise { try { const res = await fetch(url, { headers: authHeaders() }); const data = await res.json().catch(() => ({})); if (!res.ok) return { error: res.status === 401 ? 'unauthorized' : (data.error ?? `HTTP ${res.status}`) }; return data as PickResult; } catch (err) { return { error: (err as Error).message }; } } async function post(url: string, body?: unknown): Promise { const res = await fetch(url, { method: 'POST', headers: authHeaders(body === undefined ? {} : { 'content-type': 'application/json' }), body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? `${url} failed (${res.status})`); return data as T; } // The desktop implementation, and the swap seam the web app uses. The // exported `backend` is a Proxy delegating to whichever implementation is // current: methods come back BOUND to the implementation (a class instance // with private fields must be its own `this`), and property writes (like // desk assigning `onState`) land on the implementation too. Object.assign // is exactly wrong here — class methods live on the prototype, so a copy // silently keeps the desktop's HTTP methods and the web app ends up // JSON.parsing its own index.html. export type Backend = typeof desktopBackend; let current: Backend = undefined as unknown as Backend; // set right below export function setBackend(impl: unknown): void { current = impl as Backend; } export const backend: Backend = new Proxy({} as Backend, { get(_t, key) { const value = (current as unknown as Record)[key]; return typeof value === 'function' ? (value as (...a: unknown[]) => unknown).bind(current) : value; }, set(_t, key, value) { (current as unknown as Record)[key] = value; return true; }, has(_t, key) { return key in (current as object); }, }); const desktopBackend = { onState: undefined as ((state: ServerState) => void) | undefined, /** Non-state events (sync traffic); fan-out to whoever subscribes. */ events: new EventTarget(), connect(winId: string): void { const ws = new WebSocket(keyed(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`)); ws.addEventListener('message', (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === 'state') this.onState?.(msg.state); else if (typeof msg.type === 'string') { this.events.dispatchEvent(new CustomEvent(msg.type, { detail: msg })); } } catch { // ignore malformed frames } }); ws.addEventListener('close', () => setTimeout(() => this.connect(winId), 1500)); }, state: async (): Promise => { const res = await fetch('/api/state', { headers: authHeaders() }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(res.status === 401 ? 'unauthorized' : (data.error ?? `state failed (${res.status})`)); } return await res.json() as ServerState; }, postBounds: (winId: string, b: Bounds): Promise => fetch(`/api/windows/${winId}/bounds`, { method: 'POST', headers: authHeaders({ 'content-type': 'application/json' }), body: JSON.stringify(b), }), beaconBounds: (winId: string, b: Bounds): void => { navigator.sendBeacon(keyed(`/api/windows/${winId}/bounds`), JSON.stringify(b)); }, openPath: (path: string): Promise => post('/api/docs/open', { path }), fromModel: (modelId: string): Promise => post('/api/docs/from-model', { modelId }), winOp: (winId: string, body: Record): Promise => post(`/api/windows/${winId}/op`, body), rename: (id: string, name: string): Promise => post(`/api/docs/${id}/rename`, { name }), saveAsModel: (docId: string): Promise => post(`/api/models/from/${docId}`), pick: (): Promise => pickRequest('/api/pick'), pickSave: (name: string): Promise => pickRequest(`/api/pick-save?name=${encodeURIComponent(name)}`), moveDoc: (id: string, dest: string): Promise<{ oldId: string; info: DocInfo }> => post(`/api/docs/${id}/move`, { dest }), removeRecent: (id: string): Promise => fetch(`/api/recents/${id}`, { method: 'DELETE', headers: authHeaders() }).then((r) => r.json()), reveal: (path: string): Promise<{ ok: boolean }> => post('/api/reveal', { path }), importTile: async (name: string, bytes: ArrayBuffer): Promise => { const res = await fetch(`/api/import?name=${encodeURIComponent(name)}`, { method: 'POST', headers: authHeaders(), body: bytes, }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? 'import failed'); return data as OpenResult; }, /* ——— collaboration ——— */ syncShare: (docId: string, opts?: { space?: string; newSpaceName?: string }): Promise => post(`/api/docs/${docId}/sync/share`, opts ?? {}), syncAdopt: (space: string, docRkey: string): Promise => post('/api/sync/adopt', { space, docRkey }), syncRetry: (): Promise => post('/api/sync/retry'), syncInvite: ( docId: string, did: string, role: string, ): Promise<{ link: string; invitation: { pending?: boolean } }> => post(`/api/docs/${docId}/sync/invite`, { did, role }), syncAttach: (docId: string): Promise< { shared: boolean; me?: SyncMember; roster?: SyncMember[]; buffered?: SyncFrame[] } > => post(`/api/docs/${docId}/sync/attach`), syncSend: (docId: string, b64: string): Promise<{ seq: number }> => post(`/api/docs/${docId}/sync/send`, { b64 }), syncEphemeral: (docId: string, b64: string): Promise => post(`/api/docs/${docId}/sync/ephemeral`, { b64 }), syncSetRole: (docId: string, did: string, role: string): Promise => post(`/api/docs/${docId}/sync/role`, { did, role }), syncRemoveMember: (docId: string, did: string): Promise => post(`/api/docs/${docId}/sync/remove`, { did }), deleteDoc: (docId: string): Promise => fetch(`/api/docs/${docId}`, { method: 'DELETE', headers: authHeaders() }).then(async (r) => { const data = await r.json(); if (!r.ok) throw new Error(data.error ?? `delete failed (${r.status})`); return data; }), removeModel: (modelId: string): Promise => fetch(`/api/models/${encodeURIComponent(modelId)}`, { method: 'DELETE', headers: authHeaders() }) .then((r) => r.json()), /* ——— the store (web-first; the desktop answers honestly) ——— */ storeList: (_publisher?: string): Promise => Promise.reject(new Error('the store arrives on the web first — open app.ziran.space')), storeGet: (_publisher: string, _name: string): Promise => Promise.reject(new Error('the store arrives on the web first — open app.ziran.space')), storeInstall: (_publisher: string, _name: string): Promise => Promise.reject(new Error('the store arrives on the web first — open app.ziran.space')), storePublish: (_path: string, _name: string): Promise => Promise.reject(new Error('publishing arrives on the web first — open app.ziran.space')), installRemove: (_rkey: string): Promise => Promise.reject(new Error('the sidebar is managed on the web for now')), installReorder: (_rkeys: string[]): Promise => Promise.reject(new Error('the sidebar is managed on the web for now')), openInstall: (_rkey: string): Promise => Promise.reject(new Error('installed tiles open on the web for now')), acceptInvitation: (token: string): Promise => post(`/api/invitations/${token}/accept`), dismissInvitation: (token: string): Promise => post(`/api/invitations/${token}/dismiss`), atSignIn: (identifier: string): Promise<{ url: string }> => post('/api/at/signin', { identifier }), atSignOut: (): Promise => post('/api/at/signout'), atProfiles: async (dids: string[]): Promise => { const res = await fetch(`/api/at/profiles?dids=${encodeURIComponent(dids.join(','))}`, { headers: authHeaders(), }); if (!res.ok) return []; return ((await res.json()).profiles ?? []) as AtProfile[]; }, atSearch: async (q: string): Promise => { const res = await fetch(`/api/at/search?q=${encodeURIComponent(q)}`, { headers: authHeaders() }); if (!res.ok) return []; return ((await res.json()).actors ?? []) as AtProfile[]; }, loadData: async (docId: string, name: string): Promise => { const res = await fetch(`/api/docs/${docId}/data/${name}`, { headers: authHeaders() }); if (res.status === 404) return null; if (!res.ok) throw new Error(`loadData failed (${res.status})`); return new Uint8Array(await res.arrayBuffer()); }, putData: async (docId: string, name: string, data: Uint8Array): Promise => { const res = await fetch(`/api/docs/${docId}/data/${name}`, { method: 'PUT', headers: authHeaders(), body: data as BodyInit, }); if (!res.ok) throw new Error(`putData failed (${res.status})`); }, }; // Icon and bundle URLs are consumed by / the tile loader, which cannot // send headers — the key rides in the query string. current = desktopBackend; export const docIconUrl = (d: DocInfo): string | undefined => d.icon ?? (d.hasIcon ? keyed(`/api/docs/${d.id}/icon`) : undefined); export const recentIconUrl = (d: DocInfo): string | undefined => d.icon ?? (d.hasIcon ? keyed(`/api/recents/${d.id}/icon`) : undefined); export const modelIconUrl = (m: ModelEntry): string | undefined => m.icon ?? (m.hasIcon ? keyed(`/api/models/${encodeURIComponent(m.id)}/icon`) : undefined); export const bundleUrl = (docId: string): string => keyed(`${location.origin}/api/docs/${docId}/bundle.tile`); const MIN = 60_000, HOUR = 3_600_000, DAY = 86_400_000; export function relativeTime(ts: number): string { const delta = Date.now() - ts; if (delta < MIN) return 'just now'; if (delta < HOUR) return `${Math.round(delta / MIN)}m ago`; if (delta < DAY) return `${Math.round(delta / HOUR)}h ago`; if (delta < 2 * DAY) return 'yesterday'; if (delta < 14 * DAY) return `${Math.round(delta / DAY)}d ago`; return `${Math.round(delta / (7 * DAY))}w ago`; } export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }