feat: add offline-first support with IndexedDB persistence and Service Worker

rSpace apps now work fully offline. Automerge documents and sync state
persist to IndexedDB, enabling instant load from cache, offline editing,
and automatic incremental merge on reconnect. A Service Worker caches
the app shell (HTML/JS/WASM) for loading without network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 12:38:47 -07:00
parent cda6dfeaaf
commit 6b06168f11
7 changed files with 555 additions and 10 deletions

View File

@ -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<typeof setTimeout> | 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<boolean> {
if (!this.#offlineStore) return false;
try {
const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug);
if (!docBinary) return false;
this.#doc = Automerge.load<CommunityDoc>(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<CommunityDoc> {
@ -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<CommunityDoc>(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
*/

View File

@ -63,3 +63,6 @@ export * from "./folk-choice-spider";
// Sync
export * from "./community-sync";
export * from "./presence";
// Offline support
export * from "./offline-store";

235
lib/offline-store.ts Normal file
View File

@ -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<string, ReturnType<typeof setTimeout>>();
#pendingSaves = new Map<string, Uint8Array>();
#saveDebounceMs = 2000;
/**
* Open the IndexedDB database. Must be called before any other method.
* Safe to call multiple times (idempotent).
*/
async open(): Promise<void> {
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<void> {
// 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<Uint8Array | null> {
const entry = await this.#getEntry(slug);
return entry?.docBinary ?? null;
}
/**
* Save Automerge SyncState binary for incremental reconnection.
*/
async saveSyncState(slug: string, syncStateBinary: Uint8Array): Promise<void> {
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<Uint8Array | null> {
const entry = await this.#getEntry(slug);
return entry?.syncStateBinary ?? null;
}
/**
* Update the lastSynced timestamp (called when server confirms sync).
*/
async markSynced(slug: string): Promise<void> {
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<void> {
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<string[]> {
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<void> {
const promises: Promise<void>[] = [];
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<void> {
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<OfflineCacheEntry | null> {
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<void> {
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);
});
}
}

View File

@ -22,5 +22,5 @@
}
},
"include": ["**/*.ts", "vite.config.ts"],
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*"]
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*", "website/sw.ts"]
}

View File

@ -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"),

View File

@ -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

105
website/sw.ts Normal file
View File

@ -0,0 +1,105 @@
/// <reference lib="webworker" />
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<Response>;
})
);
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;
})
);
});