Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225// The web host (docs/WEB-APP.md, Phase C): what main.ts + store.ts + the// HTTP API are to the desktop, this module is to the browser — the glue// between the SpacesProvider (polling the home space through the OAuth// session), the materialized documents in the memory store, and the tile// frames. Same provider, same tile engine, same protocol hub; only the// plumbing between them is browser-shaped.
import { SpaceClient, type SessionAuth } from '../sync/spaces/client.ts';import type { ResolverOptions } from '../sync/atproto.ts';import { SpacesProvider } from '../sync/spaces/provider.ts';import { homeRef } from '../sync/spaces/scope.ts';import { docIdForPath, parseTile, type ParsedTile, readTileData, renameTile, writeTileData } from '../server/tilefile.ts';import { DataProtocolHub, type HostBridge } from '../ui/protocol-hub.ts';
interface OpenDoc { localId: string; path: string; tile: ParsedTile; name: string;}
export interface WebHostOptions { /** Tests: point identity resolution at the fake network. */ resolver?: ResolverOptions; /** Tests: disable the provider's own poll timers. */ autoPoll?: boolean; /** How long saves settle before the durability checkpoint (tests shrink it). */ publishDelayMs?: number; /** Skip wiring the tile-protocol hub (the app uses the shared dataHub singleton instead — two message listeners would double-handle every tile frame). */ initHub?: boolean;}
export class WebHost { readonly provider: SpacesProvider; /** The host side of the tile protocols — also the seam the Phase D SharedWorker RPC will speak. */ readonly bridge: HostBridge; readonly hub: DataProtocolHub; readonly events = new EventTarget(); #publishDelayMs: number; /** Documents materialized this session, by localId. */ #docs = new Map<string, OpenDoc>(); /** The doc whose tile frame is showing (drives hot polling). */ #openId: string | null = null; #did: string; onChange: (() => void) | undefined;
constructor(auth: SessionAuth, fetchImpl: typeof fetch, options: WebHostOptions = {}) { this.#did = auth.did; this.#publishDelayMs = options.publishDelayMs ?? 2_500; this.provider = new SpacesProvider({ host: { doc: (id) => { const doc = this.#docs.get(id); return doc ? { info: { path: doc.path, name: doc.name }, tile: { manifest: doc.tile.manifest as Record<string, unknown> } } : undefined; }, openPath: (path) => this.openPath(path), isOpen: (id) => this.#openId === id && (typeof document === 'undefined' || document.visibilityState !== 'hidden'), broadcast: () => this.onChange?.(), broadcastEvent: (event) => { const type = String(event.type ?? ''); if (type) this.events.dispatchEvent(new CustomEvent(type, { detail: event })); }, }, auth: () => Promise.resolve(auth), clientFor: () => new SpaceClient({ auth, fetch: fetchImpl, resolver: options.resolver }), // Per-tab and disposable, like every other browser-held byte: cursors // rebuild from checkpoint frontiers (the registry's own contract). registryFile: '/state/spaces.json', documentsDir: '/documents', atInfo: () => ({ signedIn: true }), handle: () => undefined, autoPoll: options.autoPoll, // A browser tab is a guest, not a daemon: slower everywhere, and a // hidden tab drops to warm entirely (isOpen below). The protocol has // no push a browser can use yet — the notify bridge is the recorded // follow-up; until then, polite polling. hotPollMs: 5_000, warmPollMs: 60_000, listSpacesMs: 120_000, inboxPollMs: 30_000, }); const bridge: HostBridge = { syncAttach: (docId) => { const at = this.provider.attach(docId); return Promise.resolve(at ? { shared: true, ...at } : { shared: false }); }, syncSend: (docId, b64) => this.provider.send(docId, b64), syncEphemeral: (docId, b64) => { this.provider.ephemeral(docId, b64); return Promise.resolve(); }, loadData: async (docId, name) => { const doc = this.#docs.get(docId); if (!doc) throw new Error('document is not open'); return (await readTileData(doc.path, name)) ?? null; }, putData: async (docId, name, data) => { const doc = this.#docs.get(docId); if (!doc) throw new Error('document is not open'); await writeTileData(doc.path, name, data); // The materialized tile changed shape: reparse so resource reads and // future checkpoints see the new bytes. doc.tile = await parseTile(doc.path, { verify: false }); await this.provider.onLocalDataWrite(docId); // Web-only durability rule: the desktop's disk makes putData durable // by itself; here the materialized tile is a disposable cache, and a // storage-only tile (no ops) would otherwise lose saves between the // RARE threshold checkpoints. Publish soon after saves settle — blob // dedup means only the changed data file and manifest travel. this.#publishSoon(docId); }, events: this.events, }; this.bridge = bridge; this.hub = new DataProtocolHub(bridge); if (options.initHub !== false) this.hub.init(); // Coming back to a hidden tab: catch up immediately instead of waiting // out the warm interval. if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') this.provider.pollNow(true).catch(() => {}); }); } }
#publishTimers = new Map<string, number>(); #publishSoon(docId: string): void { const prior = this.#publishTimers.get(docId); if (prior !== undefined) clearTimeout(prior); this.#publishTimers.set( docId, setTimeout(() => { this.#publishTimers.delete(docId); this.provider.publish(docId).catch((err) => console.warn('web: could not checkpoint after save:', (err as Error).message) ); }, this.#publishDelayMs) as unknown as number, ); }
async init(): Promise<void> { await this.provider.init(); }
close(): void { this.provider.close(); }
/** Materialize-or-find a home-space document and return it ready to render: the provider's adopt() instantiates from the newest checkpoint and replays the op tail through normal polling. */ async openHomeDoc(docRkey: string): Promise<OpenDoc> { return await this.openSpaceDoc(homeRef(this.#did), docRkey); }
/** Same, for any space this identity can read (shared docs open in their own tabs exactly like home docs). */ async openSpaceDoc(ref: string, docRkey: string): Promise<OpenDoc> { let adopted: { localId: string; path: string }; try { adopted = await this.provider.adopt(ref, docRkey); } catch (err) { // Wire refusals, in words a person can act on. const raw = (err as Error).message; if (/spacedeleted|has been deleted/i.test(raw)) { throw new Error('This document has been deleted by its owner.'); } if (/spacenotfound|notfound/i.test(raw)) { throw new Error('This document no longer exists — it may have been deleted.'); } throw err; } const { localId, path } = adopted; if (!this.#docs.has(localId)) await this.openPath(path); const doc = this.#docs.get(localId); if (!doc) throw new Error('the document did not materialize'); return doc; }
/** The provider's openPath seam: parse, verify, register. */ async openPath(path: string): Promise<{ id: string }> { const tile = await parseTile(path); if (tile.damaged) throw new Error(`damaged tile: ${tile.damage}`); const id = await docIdForPath(path); this.#docs.set(id, { localId: id, path, tile, name: (tile.manifest.name as string | undefined) ?? 'document', }); return { id }; }
doc(localId: string): OpenDoc | undefined { return this.#docs.get(localId); }
/** Rename an open document: the materialized tile's manifest, the space record (so every member and the home list see it), and a checkpoint so re-materialization keeps the name. */ async rename(localId: string, name: string): Promise<void> { const doc = this.#docs.get(localId); if (!doc) throw new Error('document is not open'); const clean = name.trim(); if (!clean || clean === doc.name) return; await renameTile(doc.path, clean); doc.tile = await parseTile(doc.path, { verify: false }); doc.name = clean; await this.provider.renameDoc(localId, clean); this.#publishSoon(localId); }
/** Mark which doc's frame is showing — hot polling follows it. */ setOpen(localId: string | null): void { this.#openId = localId; if (localId) this.provider.pollNow(true).catch(() => {}); }}