diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 0f8f155..a101b95 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -1,5 +1,6 @@ import * as Automerge from "@automerge/automerge"; import type { FolkShape } from "./folk-shape"; +import type { OfflineStore } from "./offline-store"; // Shape data stored in Automerge document export interface ShapeData { @@ -58,8 +59,11 @@ export class CommunitySync extends EventTarget { #reconnectAttempts = 0; #maxReconnectAttempts = 5; #reconnectDelay = 1000; + #offlineStore: OfflineStore | null = null; + #saveDebounceTimer: ReturnType | null = null; + #wsUrl: string | null = null; - constructor(communitySlug: string) { + constructor(communitySlug: string, offlineStore?: OfflineStore) { super(); this.#communitySlug = communitySlug; @@ -75,6 +79,44 @@ export class CommunitySync extends EventTarget { }); this.#syncState = Automerge.initSyncState(); + + if (offlineStore) { + this.#offlineStore = offlineStore; + } + } + + /** + * Load document and sync state from offline cache. + * Call BEFORE connect() to show cached content immediately. + * @returns true if cached data was loaded. + */ + async initFromCache(): Promise { + if (!this.#offlineStore) return false; + + try { + const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug); + if (!docBinary) return false; + + this.#doc = Automerge.load(docBinary); + + // Try to restore sync state for incremental reconnection + const syncStateBinary = await this.#offlineStore.loadSyncState(this.#communitySlug); + if (syncStateBinary) { + this.#syncState = Automerge.decodeSyncState(syncStateBinary); + } + + // Apply cached doc to DOM + this.#applyDocToDOM(); + + this.dispatchEvent(new CustomEvent("offline-loaded", { + detail: { slug: this.#communitySlug } + })); + + return true; + } catch (e) { + console.error("[CommunitySync] Failed to load from cache:", e); + return false; + } } get doc(): Automerge.Doc { @@ -89,6 +131,8 @@ export class CommunitySync extends EventTarget { * Connect to WebSocket server for real-time sync */ connect(wsUrl: string): void { + this.#wsUrl = wsUrl; + if (this.#ws?.readyState === WebSocket.OPEN) { return; } @@ -104,6 +148,10 @@ export class CommunitySync extends EventTarget { this.#requestSync(); this.dispatchEvent(new CustomEvent("connected")); + + if (this.#offlineStore) { + this.#offlineStore.markSynced(this.#communitySlug); + } }; this.#ws.onmessage = (event) => { @@ -125,13 +173,19 @@ export class CommunitySync extends EventTarget { } #attemptReconnect(wsUrl: string): void { - if (this.#reconnectAttempts >= this.#maxReconnectAttempts) { + // When offline store is available, keep retrying forever (user has local persistence) + // Without offline store, give up after maxReconnectAttempts + if (!this.#offlineStore && this.#reconnectAttempts >= this.#maxReconnectAttempts) { console.error("[CommunitySync] Max reconnect attempts reached"); return; } this.#reconnectAttempts++; - const delay = this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1); + const maxDelay = this.#offlineStore ? 30000 : 16000; + const delay = Math.min( + this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1), + maxDelay + ); console.log(`[CommunitySync] Reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`); @@ -150,6 +204,7 @@ export class CommunitySync extends EventTarget { ); this.#syncState = nextSyncState; + this.#persistSyncState(); if (syncMessage) { this.#send({ @@ -190,6 +245,7 @@ export class CommunitySync extends EventTarget { this.#doc = Automerge.load(binary); this.#syncState = Automerge.initSyncState(); this.#applyDocToDOM(); + this.#scheduleSave(); } break; @@ -216,6 +272,10 @@ export class CommunitySync extends EventTarget { this.#doc = result[0]; this.#syncState = result[1]; + // Persist after receiving remote changes + this.#scheduleSave(); + this.#persistSyncState(); + // Apply changes to DOM if we received new patches const patch = result[2] as { patches: Automerge.Patch[] } | null; if (patch && patch.patches && patch.patches.length > 0) { @@ -229,6 +289,7 @@ export class CommunitySync extends EventTarget { ); this.#syncState = nextSyncState; + this.#persistSyncState(); if (responseMessage) { this.#send({ @@ -315,6 +376,8 @@ export class CommunitySync extends EventTarget { if (!doc.shapes) doc.shapes = {}; doc.shapes[shape.id] = shapeData; }); + + this.#scheduleSave(); } /** @@ -354,6 +417,7 @@ export class CommunitySync extends EventTarget { ); this.#syncState = nextSyncState; + this.#persistSyncState(); if (syncMessage) { this.#send({ @@ -374,6 +438,7 @@ export class CommunitySync extends EventTarget { }); this.#shapes.delete(shapeId); + this.#scheduleSave(); this.#syncToServer(); } @@ -573,6 +638,54 @@ export class CommunitySync extends EventTarget { } } + /** + * Save current state immediately. Call from beforeunload handler. + */ + saveBeforeUnload(): void { + if (!this.#offlineStore) return; + + if (this.#saveDebounceTimer) { + clearTimeout(this.#saveDebounceTimer); + this.#saveDebounceTimer = null; + } + + try { + this.#offlineStore.saveDocImmediate( + this.#communitySlug, + Automerge.save(this.#doc) + ); + } catch (e) { + console.warn("[CommunitySync] Failed to save before unload:", e); + } + } + + #scheduleSave(): void { + if (!this.#offlineStore) return; + + if (this.#saveDebounceTimer) { + clearTimeout(this.#saveDebounceTimer); + } + + this.#saveDebounceTimer = setTimeout(() => { + this.#saveDebounceTimer = null; + this.#offlineStore!.saveDoc( + this.#communitySlug, + Automerge.save(this.#doc) + ); + }, 2000); + } + + #persistSyncState(): void { + if (!this.#offlineStore) return; + + try { + const encoded = Automerge.encodeSyncState(this.#syncState); + this.#offlineStore.saveSyncState(this.#communitySlug, encoded); + } catch (e) { + console.warn("[CommunitySync] Failed to save sync state:", e); + } + } + /** * Disconnect from server */ diff --git a/lib/index.ts b/lib/index.ts index e9bb6b9..2e2b8b7 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -63,3 +63,6 @@ export * from "./folk-choice-spider"; // Sync export * from "./community-sync"; export * from "./presence"; + +// Offline support +export * from "./offline-store"; diff --git a/lib/offline-store.ts b/lib/offline-store.ts new file mode 100644 index 0000000..97b6c70 --- /dev/null +++ b/lib/offline-store.ts @@ -0,0 +1,235 @@ +/** + * OfflineStore — IndexedDB persistence for Automerge documents and sync state. + * + * Stores per-community: + * - docBinary: Automerge.save(doc) output (Uint8Array) + * - syncStateBinary: Automerge.encodeSyncState() output (Uint8Array) + * - lastSynced / lastModified timestamps + */ + +export interface OfflineCacheEntry { + slug: string; + docBinary: Uint8Array; + syncStateBinary: Uint8Array | null; + lastSynced: number; + lastModified: number; +} + +export class OfflineStore { + #db: IDBDatabase | null = null; + #dbName = "rspace-offline"; + #storeName = "communities"; + #version = 1; + #saveTimers = new Map>(); + #pendingSaves = new Map(); + #saveDebounceMs = 2000; + + /** + * Open the IndexedDB database. Must be called before any other method. + * Safe to call multiple times (idempotent). + */ + async open(): Promise { + if (this.#db) return; + + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.#dbName, this.#version); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(this.#storeName)) { + db.createObjectStore(this.#storeName, { keyPath: "slug" }); + } + }; + + request.onsuccess = () => { + this.#db = request.result; + resolve(); + }; + + request.onerror = () => { + console.error("[OfflineStore] Failed to open IndexedDB:", request.error); + reject(request.error); + }; + }); + } + + /** + * Save Automerge document binary, debounced to avoid thrashing. + */ + saveDoc(slug: string, docBinary: Uint8Array): void { + this.#pendingSaves.set(slug, docBinary); + + const existing = this.#saveTimers.get(slug); + if (existing) clearTimeout(existing); + + this.#saveTimers.set( + slug, + setTimeout(() => { + this.#saveTimers.delete(slug); + this.#pendingSaves.delete(slug); + this.#writeDoc(slug, docBinary).catch((e) => { + console.error("[OfflineStore] Failed to save doc:", e); + }); + }, this.#saveDebounceMs) + ); + } + + /** + * Immediately save document binary (bypasses debounce). + * Used before page unload. + */ + async saveDocImmediate(slug: string, docBinary: Uint8Array): Promise { + // Cancel any pending debounced save for this slug + const existing = this.#saveTimers.get(slug); + if (existing) { + clearTimeout(existing); + this.#saveTimers.delete(slug); + } + this.#pendingSaves.delete(slug); + + await this.#writeDoc(slug, docBinary); + } + + /** + * Load cached Automerge document binary. + */ + async loadDoc(slug: string): Promise { + const entry = await this.#getEntry(slug); + return entry?.docBinary ?? null; + } + + /** + * Save Automerge SyncState binary for incremental reconnection. + */ + async saveSyncState(slug: string, syncStateBinary: Uint8Array): Promise { + if (!this.#db) return; + + try { + const entry = await this.#getEntry(slug); + if (!entry) return; // No doc saved yet, skip sync state + + entry.syncStateBinary = syncStateBinary; + await this.#putEntry(entry); + } catch (e) { + console.error("[OfflineStore] Failed to save sync state:", e); + } + } + + /** + * Load cached SyncState binary. + */ + async loadSyncState(slug: string): Promise { + const entry = await this.#getEntry(slug); + return entry?.syncStateBinary ?? null; + } + + /** + * Update the lastSynced timestamp (called when server confirms sync). + */ + async markSynced(slug: string): Promise { + if (!this.#db) return; + + try { + const entry = await this.#getEntry(slug); + if (!entry) return; + + entry.lastSynced = Date.now(); + await this.#putEntry(entry); + } catch (e) { + console.error("[OfflineStore] Failed to mark synced:", e); + } + } + + /** + * Clear all cached data for a community. + */ + async clear(slug: string): Promise { + if (!this.#db) return; + + return new Promise((resolve, reject) => { + const tx = this.#db!.transaction(this.#storeName, "readwrite"); + const store = tx.objectStore(this.#storeName); + const request = store.delete(slug); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** + * List all cached community slugs. + */ + async listCommunities(): Promise { + if (!this.#db) return []; + + return new Promise((resolve, reject) => { + const tx = this.#db!.transaction(this.#storeName, "readonly"); + const store = tx.objectStore(this.#storeName); + const request = store.getAllKeys(); + + request.onsuccess = () => resolve(request.result as string[]); + request.onerror = () => reject(request.error); + }); + } + + /** + * Flush all pending debounced saves immediately. + * Call from beforeunload handler. + */ + async flush(): Promise { + const promises: Promise[] = []; + + for (const [slug, binary] of this.#pendingSaves) { + const timer = this.#saveTimers.get(slug); + if (timer) clearTimeout(timer); + this.#saveTimers.delete(slug); + promises.push(this.#writeDoc(slug, binary)); + } + this.#pendingSaves.clear(); + + await Promise.all(promises); + } + + // --- Private helpers --- + + async #writeDoc(slug: string, docBinary: Uint8Array): Promise { + if (!this.#db) return; + + const existing = await this.#getEntry(slug); + const entry: OfflineCacheEntry = { + slug, + docBinary, + syncStateBinary: existing?.syncStateBinary ?? null, + lastSynced: existing?.lastSynced ?? 0, + lastModified: Date.now(), + }; + + await this.#putEntry(entry); + } + + #getEntry(slug: string): Promise { + if (!this.#db) return Promise.resolve(null); + + return new Promise((resolve, reject) => { + const tx = this.#db!.transaction(this.#storeName, "readonly"); + const store = tx.objectStore(this.#storeName); + const request = store.get(slug); + + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }); + } + + #putEntry(entry: OfflineCacheEntry): Promise { + if (!this.#db) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const tx = this.#db!.transaction(this.#storeName, "readwrite"); + const store = tx.objectStore(this.#storeName); + const request = store.put(entry); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } +} diff --git a/tsconfig.json b/tsconfig.json index a3aab77..1c15fc9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,5 @@ } }, "include": ["**/*.ts", "vite.config.ts"], - "exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*"] + "exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*", "website/sw.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 3996254..fc11b08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,38 @@ import wasm from "vite-plugin-wasm"; export default defineConfig({ root: "website", - plugins: [wasm()], + plugins: [ + wasm(), + // Build service worker as a separate, unhashed JS file + { + name: "build-sw", + apply: "build", + closeBundle: { + sequential: true, + async handler() { + const { build } = await import("vite"); + await build({ + configFile: false, + root: resolve(__dirname, "website"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist"), + lib: { + entry: resolve(__dirname, "website/sw.ts"), + formats: ["es"], + fileName: () => "sw.js", + }, + rollupOptions: { + output: { + entryFileNames: "sw.js", + }, + }, + }, + }); + }, + }, + }, + ], resolve: { alias: { "@lib": resolve(__dirname, "./lib"), diff --git a/website/canvas.html b/website/canvas.html index fe8ee84..4b93b62 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -110,6 +110,15 @@ animation: pulse 1s infinite; } + #status.offline .indicator { + background: #f59e0b; + } + + #status.offline-empty .indicator { + background: #94a3b8; + animation: pulse 2s infinite; + } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } @@ -280,13 +289,23 @@ FolkChoiceSpider, CommunitySync, PresenceManager, - generatePeerId + generatePeerId, + OfflineStore } from "@lib"; import { mountHeader } from "@lib/rspace-header"; // Mount the header (light theme for canvas) mountHeader({ theme: "light", showBrand: true }); + // Register service worker for offline support + if ("serviceWorker" in navigator && window.location.hostname !== "localhost") { + navigator.serviceWorker.register("/sw.js").then((reg) => { + console.log("[Canvas] Service worker registered, scope:", reg.scope); + }).catch((err) => { + console.warn("[Canvas] Service worker registration failed:", err); + }); + } + // Register custom elements FolkShape.define(); FolkMarkdown.define(); @@ -344,8 +363,17 @@ "folk-choice-vote", "folk-choice-rank", "folk-choice-spider" ].join(", "); - // Initialize CommunitySync - const sync = new CommunitySync(communitySlug); + // Initialize offline store and CommunitySync + const offlineStore = new OfflineStore(); + await offlineStore.open(); + const sync = new CommunitySync(communitySlug, offlineStore); + + // Try to load from cache immediately (shows content before WebSocket connects) + const hadCache = await sync.initFromCache(); + if (hadCache) { + status.className = "offline"; + statusText.textContent = "Offline (cached)"; + } // Initialize Presence for real-time cursors const peerId = generatePeerId(); @@ -403,8 +431,13 @@ }); sync.addEventListener("disconnected", () => { - status.className = "disconnected"; - statusText.textContent = "Reconnecting..."; + if (navigator.onLine) { + status.className = "disconnected"; + statusText.textContent = "Reconnecting..."; + } else { + status.className = "offline"; + statusText.textContent = "Offline (changes saved locally)"; + } }); sync.addEventListener("synced", (e) => { @@ -1015,6 +1048,31 @@ // Connect to server const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/ws/${communitySlug}`; + + // Handle browser online/offline events + window.addEventListener("online", () => { + console.log("[Canvas] Browser went online, reconnecting..."); + status.className = "syncing"; + statusText.textContent = "Reconnecting..."; + sync.connect(wsUrl); + }); + + window.addEventListener("offline", () => { + console.log("[Canvas] Browser went offline"); + status.className = "offline"; + statusText.textContent = "Offline (changes saved locally)"; + }); + + // Handle offline-loaded event + sync.addEventListener("offline-loaded", () => { + console.log("[Canvas] Loaded from offline cache"); + }); + + // Save state before page unload + window.addEventListener("beforeunload", () => { + sync.saveBeforeUnload(); + }); + sync.connect(wsUrl); // Debug: expose sync for console inspection diff --git a/website/sw.ts b/website/sw.ts new file mode 100644 index 0000000..f62e238 --- /dev/null +++ b/website/sw.ts @@ -0,0 +1,105 @@ +/// +declare const self: ServiceWorkerGlobalScope; + +const CACHE_VERSION = "rspace-v1"; +const STATIC_CACHE = `${CACHE_VERSION}-static`; +const HTML_CACHE = `${CACHE_VERSION}-html`; + +// Vite-hashed assets are immutable (content hash in filename) +const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; + +// App shell to precache on install +const PRECACHE_URLS = ["/", "/canvas.html"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + // Clean up old versioned caches + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => !key.startsWith(CACHE_VERSION)) + .map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + + // Skip WebSocket and API requests entirely + if ( + event.request.url.startsWith("ws://") || + event.request.url.startsWith("wss://") || + url.pathname.startsWith("/ws/") || + url.pathname.startsWith("/api/") + ) { + return; + } + + // Immutable hashed assets: cache-first (they never change) + if (IMMUTABLE_PATTERN.test(url.pathname)) { + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); + } + return response; + }); + }) + ); + return; + } + + // HTML pages: network-first with cache fallback + if ( + event.request.mode === "navigate" || + event.request.headers.get("accept")?.includes("text/html") + ) { + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(HTML_CACHE).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => { + return caches + .match(event.request) + .then((cached) => cached || caches.match("/canvas.html")) as Promise; + }) + ); + return; + } + + // Other assets (images, fonts, etc.): stale-while-revalidate + event.respondWith( + caches.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => cached as Response); + return cached || fetchPromise; + }) + ); +});