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:
parent
cda6dfeaaf
commit
6b06168f11
|
|
@ -1,5 +1,6 @@
|
||||||
import * as Automerge from "@automerge/automerge";
|
import * as Automerge from "@automerge/automerge";
|
||||||
import type { FolkShape } from "./folk-shape";
|
import type { FolkShape } from "./folk-shape";
|
||||||
|
import type { OfflineStore } from "./offline-store";
|
||||||
|
|
||||||
// Shape data stored in Automerge document
|
// Shape data stored in Automerge document
|
||||||
export interface ShapeData {
|
export interface ShapeData {
|
||||||
|
|
@ -58,8 +59,11 @@ export class CommunitySync extends EventTarget {
|
||||||
#reconnectAttempts = 0;
|
#reconnectAttempts = 0;
|
||||||
#maxReconnectAttempts = 5;
|
#maxReconnectAttempts = 5;
|
||||||
#reconnectDelay = 1000;
|
#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();
|
super();
|
||||||
this.#communitySlug = communitySlug;
|
this.#communitySlug = communitySlug;
|
||||||
|
|
||||||
|
|
@ -75,6 +79,44 @@ export class CommunitySync extends EventTarget {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#syncState = Automerge.initSyncState();
|
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> {
|
get doc(): Automerge.Doc<CommunityDoc> {
|
||||||
|
|
@ -89,6 +131,8 @@ export class CommunitySync extends EventTarget {
|
||||||
* Connect to WebSocket server for real-time sync
|
* Connect to WebSocket server for real-time sync
|
||||||
*/
|
*/
|
||||||
connect(wsUrl: string): void {
|
connect(wsUrl: string): void {
|
||||||
|
this.#wsUrl = wsUrl;
|
||||||
|
|
||||||
if (this.#ws?.readyState === WebSocket.OPEN) {
|
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +148,10 @@ export class CommunitySync extends EventTarget {
|
||||||
this.#requestSync();
|
this.#requestSync();
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent("connected"));
|
this.dispatchEvent(new CustomEvent("connected"));
|
||||||
|
|
||||||
|
if (this.#offlineStore) {
|
||||||
|
this.#offlineStore.markSynced(this.#communitySlug);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#ws.onmessage = (event) => {
|
this.#ws.onmessage = (event) => {
|
||||||
|
|
@ -125,13 +173,19 @@ export class CommunitySync extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
#attemptReconnect(wsUrl: string): void {
|
#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");
|
console.error("[CommunitySync] Max reconnect attempts reached");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#reconnectAttempts++;
|
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})`);
|
console.log(`[CommunitySync] Reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`);
|
||||||
|
|
||||||
|
|
@ -150,6 +204,7 @@ export class CommunitySync extends EventTarget {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#syncState = nextSyncState;
|
this.#syncState = nextSyncState;
|
||||||
|
this.#persistSyncState();
|
||||||
|
|
||||||
if (syncMessage) {
|
if (syncMessage) {
|
||||||
this.#send({
|
this.#send({
|
||||||
|
|
@ -190,6 +245,7 @@ export class CommunitySync extends EventTarget {
|
||||||
this.#doc = Automerge.load<CommunityDoc>(binary);
|
this.#doc = Automerge.load<CommunityDoc>(binary);
|
||||||
this.#syncState = Automerge.initSyncState();
|
this.#syncState = Automerge.initSyncState();
|
||||||
this.#applyDocToDOM();
|
this.#applyDocToDOM();
|
||||||
|
this.#scheduleSave();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -216,6 +272,10 @@ export class CommunitySync extends EventTarget {
|
||||||
this.#doc = result[0];
|
this.#doc = result[0];
|
||||||
this.#syncState = result[1];
|
this.#syncState = result[1];
|
||||||
|
|
||||||
|
// Persist after receiving remote changes
|
||||||
|
this.#scheduleSave();
|
||||||
|
this.#persistSyncState();
|
||||||
|
|
||||||
// Apply changes to DOM if we received new patches
|
// Apply changes to DOM if we received new patches
|
||||||
const patch = result[2] as { patches: Automerge.Patch[] } | null;
|
const patch = result[2] as { patches: Automerge.Patch[] } | null;
|
||||||
if (patch && patch.patches && patch.patches.length > 0) {
|
if (patch && patch.patches && patch.patches.length > 0) {
|
||||||
|
|
@ -229,6 +289,7 @@ export class CommunitySync extends EventTarget {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#syncState = nextSyncState;
|
this.#syncState = nextSyncState;
|
||||||
|
this.#persistSyncState();
|
||||||
|
|
||||||
if (responseMessage) {
|
if (responseMessage) {
|
||||||
this.#send({
|
this.#send({
|
||||||
|
|
@ -315,6 +376,8 @@ export class CommunitySync extends EventTarget {
|
||||||
if (!doc.shapes) doc.shapes = {};
|
if (!doc.shapes) doc.shapes = {};
|
||||||
doc.shapes[shape.id] = shapeData;
|
doc.shapes[shape.id] = shapeData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.#scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -354,6 +417,7 @@ export class CommunitySync extends EventTarget {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#syncState = nextSyncState;
|
this.#syncState = nextSyncState;
|
||||||
|
this.#persistSyncState();
|
||||||
|
|
||||||
if (syncMessage) {
|
if (syncMessage) {
|
||||||
this.#send({
|
this.#send({
|
||||||
|
|
@ -374,6 +438,7 @@ export class CommunitySync extends EventTarget {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#shapes.delete(shapeId);
|
this.#shapes.delete(shapeId);
|
||||||
|
this.#scheduleSave();
|
||||||
this.#syncToServer();
|
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
|
* Disconnect from server
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,6 @@ export * from "./folk-choice-spider";
|
||||||
// Sync
|
// Sync
|
||||||
export * from "./community-sync";
|
export * from "./community-sync";
|
||||||
export * from "./presence";
|
export * from "./presence";
|
||||||
|
|
||||||
|
// Offline support
|
||||||
|
export * from "./offline-store";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,5 +22,5 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "vite.config.ts"],
|
"include": ["**/*.ts", "vite.config.ts"],
|
||||||
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*"]
|
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*", "website/sw.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,38 @@ import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: "website",
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@lib": resolve(__dirname, "./lib"),
|
"@lib": resolve(__dirname, "./lib"),
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,15 @@
|
||||||
animation: pulse 1s infinite;
|
animation: pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#status.offline .indicator {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.offline-empty .indicator {
|
||||||
|
background: #94a3b8;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
|
|
@ -280,13 +289,23 @@
|
||||||
FolkChoiceSpider,
|
FolkChoiceSpider,
|
||||||
CommunitySync,
|
CommunitySync,
|
||||||
PresenceManager,
|
PresenceManager,
|
||||||
generatePeerId
|
generatePeerId,
|
||||||
|
OfflineStore
|
||||||
} from "@lib";
|
} from "@lib";
|
||||||
import { mountHeader } from "@lib/rspace-header";
|
import { mountHeader } from "@lib/rspace-header";
|
||||||
|
|
||||||
// Mount the header (light theme for canvas)
|
// Mount the header (light theme for canvas)
|
||||||
mountHeader({ theme: "light", showBrand: true });
|
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
|
// Register custom elements
|
||||||
FolkShape.define();
|
FolkShape.define();
|
||||||
FolkMarkdown.define();
|
FolkMarkdown.define();
|
||||||
|
|
@ -344,8 +363,17 @@
|
||||||
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider"
|
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider"
|
||||||
].join(", ");
|
].join(", ");
|
||||||
|
|
||||||
// Initialize CommunitySync
|
// Initialize offline store and CommunitySync
|
||||||
const sync = new CommunitySync(communitySlug);
|
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
|
// Initialize Presence for real-time cursors
|
||||||
const peerId = generatePeerId();
|
const peerId = generatePeerId();
|
||||||
|
|
@ -403,8 +431,13 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
sync.addEventListener("disconnected", () => {
|
sync.addEventListener("disconnected", () => {
|
||||||
status.className = "disconnected";
|
if (navigator.onLine) {
|
||||||
statusText.textContent = "Reconnecting...";
|
status.className = "disconnected";
|
||||||
|
statusText.textContent = "Reconnecting...";
|
||||||
|
} else {
|
||||||
|
status.className = "offline";
|
||||||
|
statusText.textContent = "Offline (changes saved locally)";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sync.addEventListener("synced", (e) => {
|
sync.addEventListener("synced", (e) => {
|
||||||
|
|
@ -1015,6 +1048,31 @@
|
||||||
// Connect to server
|
// Connect to server
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/${communitySlug}`;
|
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);
|
sync.connect(wsUrl);
|
||||||
|
|
||||||
// Debug: expose sync for console inspection
|
// Debug: expose sync for console inspection
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue