Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-04 19:08:09 -08:00
commit afc66ac4c1
22 changed files with 1472 additions and 140 deletions

View File

@ -5,6 +5,9 @@
* navigates to the flipbook reader. Authenticated users can upload. * navigates to the flipbook reader. Authenticated users can upload.
*/ */
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
interface BookData { interface BookData {
id: string; id: string;
slug: string; slug: string;
@ -27,6 +30,7 @@ export class FolkBookShelf extends HTMLElement {
private _spaceSlug = "personal"; private _spaceSlug = "personal";
private _searchTerm = ""; private _searchTerm = "";
private _selectedTag: string | null = null; private _selectedTag: string | null = null;
private _offlineUnsub: (() => void) | null = null;
static get observedAttributes() { static get observedAttributes() {
return ["space-slug"]; return ["space-slug"];
@ -48,8 +52,49 @@ export class FolkBookShelf extends HTMLElement {
connectedCallback() { connectedCallback() {
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this._spaceSlug = this.getAttribute("space-slug") || this._spaceSlug;
this.render(); this.render();
if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") { this.loadDemoBooks(); } if (this._spaceSlug === "demo" || this.getAttribute("space") === "demo") {
this.loadDemoBooks();
} else {
this.subscribeOffline();
}
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = booksCatalogDocId(this._spaceSlug) as DocumentId;
const doc = await runtime.subscribe(docId, booksCatalogSchema);
this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFromDoc(updated);
});
} catch {
// Runtime unavailable — server-side hydration handles data
}
}
private renderFromDoc(doc: BooksCatalogDoc) {
if (!doc?.items || Object.keys(doc.items).length === 0) return;
// Only populate from doc if not already hydrated by server
if (this._books.length > 0) return;
this.books = Object.values(doc.items).map(b => ({
id: b.id, slug: b.slug, title: b.title, author: b.author,
description: b.description, pdf_size_bytes: b.pdfSizeBytes,
page_count: b.pageCount, tags: b.tags || [],
cover_color: b.coverColor || '#333',
contributor_name: b.contributorName, featured: b.featured,
view_count: b.viewCount, created_at: new Date(b.createdAt).toISOString(),
}));
} }
private loadDemoBooks() { private loadDemoBooks() {

View File

@ -100,6 +100,10 @@ function leafletZoomToSpatial(zoom: number): number {
if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8; if (zoom <= 15) return 6; if (zoom <= 17) return 7; return 8;
} }
// ── Offline-first imports ──
import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
// ── Component ── // ── Component ──
class FolkCalendarView extends HTMLElement { class FolkCalendarView extends HTMLElement {
@ -117,6 +121,7 @@ class FolkCalendarView extends HTMLElement {
private error = ""; private error = "";
private filteredSources = new Set<string>(); private filteredSources = new Set<string>();
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null; private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
private _offlineUnsub: (() => void) | null = null;
// Spatio-temporal state // Spatio-temporal state
private temporalGranularity = 4; // MONTH private temporalGranularity = 4; // MONTH
@ -150,11 +155,14 @@ class FolkCalendarView extends HTMLElement {
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
document.addEventListener("keydown", this.boundKeyHandler); document.addEventListener("keydown", this.boundKeyHandler);
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadMonth(); this.loadMonth();
this.render(); this.render();
} }
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
if (this.boundKeyHandler) { if (this.boundKeyHandler) {
document.removeEventListener("keydown", this.boundKeyHandler); document.removeEventListener("keydown", this.boundKeyHandler);
this.boundKeyHandler = null; this.boundKeyHandler = null;
@ -167,6 +175,55 @@ class FolkCalendarView extends HTMLElement {
} }
} }
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = calendarDocId(this.space) as DocumentId;
const doc = await runtime.subscribe(docId, calendarSchema);
this.renderFromCalDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFromCalDoc(updated);
});
} catch {
// Runtime unavailable — REST fallback handles data loading
}
}
private renderFromCalDoc(doc: CalendarDoc) {
if (!doc) return;
// Populate sources from Automerge doc
if (doc.sources && Object.keys(doc.sources).length > 0) {
this.sources = Object.values(doc.sources).map(s => ({
id: s.id, name: s.name, source_type: s.sourceType,
url: s.url, color: s.color, is_active: s.isActive,
is_visible: s.isVisible, owner_id: s.ownerId,
}));
}
// Populate events from Automerge doc (supplement REST data)
if (doc.events && Object.keys(doc.events).length > 0) {
const docEvents = Object.values(doc.events).map(e => ({
id: e.id, title: e.title, description: e.description,
start_time: new Date(e.startTime).toISOString(),
end_time: new Date(e.endTime).toISOString(),
all_day: e.allDay, source_id: e.sourceId,
source_name: e.sourceName, source_color: e.sourceColor,
location_name: e.locationName,
location_lat: e.locationLat, location_lng: e.locationLng,
}));
// Only use doc events if REST hasn't loaded yet
if (this.events.length === 0 && docEvents.length > 0) {
this.events = docEvents;
}
}
this.render();
}
// ── Keyboard ── // ── Keyboard ──
private handleKeydown(e: KeyboardEvent) { private handleKeydown(e: KeyboardEvent) {

View File

@ -3,6 +3,9 @@
* Shows catalog items, order creation flow, and order status tracking. * Shows catalog items, order creation flow, and order status tracking.
*/ */
import { catalogSchema, catalogDocId, type CatalogDoc, orderSchema, type OrderDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkCartShop extends HTMLElement { class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = "default"; private space = "default";
@ -10,6 +13,7 @@ class FolkCartShop extends HTMLElement {
private orders: any[] = []; private orders: any[] = [];
private view: "catalog" | "orders" = "catalog"; private view: "catalog" | "orders" = "catalog";
private loading = true; private loading = true;
private _offlineUnsubs: (() => void)[] = [];
constructor() { constructor() {
super(); super();
@ -32,9 +36,67 @@ class FolkCartShop extends HTMLElement {
} }
this.render(); this.render();
this.subscribeOffline();
this.loadData(); this.loadData();
} }
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
// Subscribe to catalog (single doc per space)
const catDocId = catalogDocId(this.space) as DocumentId;
const catDoc = await runtime.subscribe(catDocId, catalogSchema);
if (catDoc?.items && Object.keys(catDoc.items).length > 0 && this.catalog.length === 0) {
this.catalog = Object.values((catDoc as CatalogDoc).items).map(item => ({
id: item.id, title: item.title, description: '',
product_type: item.productType, status: item.status,
tags: item.tags || [],
created_at: new Date(item.createdAt).toISOString(),
}));
this.loading = false;
this.render();
}
this._offlineUnsubs.push(runtime.onChange(catDocId, (doc: CatalogDoc) => {
if (doc?.items) {
this.catalog = Object.values(doc.items).map(item => ({
id: item.id, title: item.title, description: '',
product_type: item.productType, status: item.status,
tags: item.tags || [],
created_at: new Date(item.createdAt).toISOString(),
}));
this.render();
}
}));
// Subscribe to orders (multi-doc)
const orderDocs = await runtime.subscribeModule('cart', 'orders', orderSchema);
if (orderDocs.size > 0 && this.orders.length === 0) {
const fromDocs: any[] = [];
for (const [docId, doc] of orderDocs) {
const d = doc as OrderDoc;
if (!d?.order) continue;
fromDocs.push({
id: d.order.id, status: d.order.status,
quantity: d.order.quantity, total_price: d.order.totalPrice,
currency: d.order.currency,
created_at: new Date(d.order.createdAt).toISOString(),
});
}
if (fromDocs.length > 0) {
this.orders = fromDocs;
this.render();
}
}
} catch { /* runtime unavailable */ }
}
private loadDemoData() { private loadDemoData() {
const now = Date.now(); const now = Date.now();
this.catalog = [ this.catalog = [

View File

@ -5,6 +5,9 @@
* space="slug" shared space to browse (default: "default") * space="slug" shared space to browse (default: "default")
*/ */
import { filesSchema, type FilesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkFileBrowser extends HTMLElement { class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = "default"; private space = "default";
@ -12,6 +15,7 @@ class FolkFileBrowser extends HTMLElement {
private cards: any[] = []; private cards: any[] = [];
private tab: "files" | "cards" = "files"; private tab: "files" | "cards" = "files";
private loading = false; private loading = false;
private _offlineUnsubs: (() => void)[] = [];
constructor() { constructor() {
super(); super();
@ -27,10 +31,47 @@ class FolkFileBrowser extends HTMLElement {
} }
this.render(); this.render();
this.subscribeOffline();
this.loadFiles(); this.loadFiles();
this.loadCards(); this.loadCards();
} }
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('files', 'cards', filesSchema);
for (const [docId, doc] of docs) {
const d = doc as FilesDoc;
if (!d) continue;
// Populate files metadata from Automerge if REST hasn't loaded yet
if (d.files && Object.keys(d.files).length > 0 && this.files.length === 0) {
this.files = Object.values(d.files).map(f => ({
id: f.id, name: f.originalFilename, original_filename: f.originalFilename,
title: f.title || f.originalFilename, size: f.fileSize, file_size: f.fileSize,
mime_type: f.mimeType, created_at: new Date(f.createdAt).toISOString(),
space: this.space,
}));
}
// Populate memory cards
if (d.memoryCards && Object.keys(d.memoryCards).length > 0 && this.cards.length === 0) {
this.cards = Object.values(d.memoryCards);
}
this._offlineUnsubs.push(runtime.onChange(docId, () => this.render()));
}
if (this.files.length > 0 || this.cards.length > 0) this.render();
} catch { /* runtime unavailable */ }
}
private loadDemoData() { private loadDemoData() {
const now = Date.now(); const now = Date.now();
this.files = [ this.files = [

View File

@ -16,6 +16,8 @@ import { PORT_DEFS, deriveThresholds } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow"; import { mapFlowToNodes } from "../lib/map-flow";
import { flowsSchema, flowsDocId, type FlowsDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
interface FlowSummary { interface FlowSummary {
id: string; id: string;
@ -83,6 +85,7 @@ class FolkFlowsApp extends HTMLElement {
private loading = false; private loading = false;
private error = ""; private error = "";
private _offlineUnsub: (() => void) | null = null;
// Canvas state // Canvas state
private canvasZoom = 1; private canvasZoom = 1;
@ -160,6 +163,50 @@ class FolkFlowsApp extends HTMLElement {
this.view = "landing"; this.view = "landing";
this.loadFlows(); this.loadFlows();
} }
// Subscribe to offline-first Automerge doc for flow associations
if (!this.isDemo) this.subscribeOffline();
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = flowsDocId(this.space) as DocumentId;
const doc = await runtime.subscribe(docId, flowsSchema);
// Render cached flow associations immediately
this.renderFlowsFromDoc(doc);
// Listen for remote changes
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFlowsFromDoc(updated);
});
} catch {
// Offline runtime unavailable — REST fallback already running
}
}
private renderFlowsFromDoc(doc: FlowsDoc) {
if (!doc?.spaceFlows) return;
const entries = Object.values(doc.spaceFlows);
if (entries.length === 0 && this.flows.length > 0) return; // Don't clobber REST data with empty doc
// Merge Automerge flow associations as summaries
const fromDoc: FlowSummary[] = entries.map(sf => ({
id: sf.flowId,
name: sf.flowId,
status: 'active',
}));
if (fromDoc.length > 0 && this.flows.length === 0) {
this.flows = fromDoc;
this.render();
}
} }
private getApiBase(): string { private getApiBase(): string {

View File

@ -5,6 +5,9 @@
* creating new instances. * creating new instances.
*/ */
import { forumSchema, FORUM_DOC_ID, type ForumDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkForumDashboard extends HTMLElement { class FolkForumDashboard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private instances: any[] = []; private instances: any[] = [];
@ -14,6 +17,7 @@ class FolkForumDashboard extends HTMLElement {
private loading = false; private loading = false;
private pollTimer: number | null = null; private pollTimer: number | null = null;
private space = ""; private space = "";
private _offlineUnsub: (() => void) | null = null;
constructor() { constructor() {
super(); super();
@ -23,6 +27,7 @@ class FolkForumDashboard extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || ""; this.space = this.getAttribute("space") || "";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.render(); this.render();
this.loadInstances(); this.loadInstances();
} }
@ -39,6 +44,41 @@ class FolkForumDashboard extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
if (this.pollTimer) clearInterval(this.pollTimer); if (this.pollTimer) clearInterval(this.pollTimer);
this._offlineUnsub?.();
this._offlineUnsub = null;
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
// Forum uses a global doc (not space-scoped)
const docId = FORUM_DOC_ID as DocumentId;
const doc = await runtime.subscribe(docId, forumSchema);
if (doc?.instances && Object.keys(doc.instances).length > 0 && this.instances.length === 0) {
this.instances = Object.values((doc as ForumDoc).instances).map(inst => ({
id: inst.id, name: inst.name, domain: inst.domain,
status: inst.status, region: inst.region, size: inst.size,
adminEmail: inst.adminEmail, vpsIp: inst.vpsIp,
sslProvisioned: inst.sslProvisioned,
}));
this.render();
}
this._offlineUnsub = runtime.onChange(docId, (updated: ForumDoc) => {
if (updated?.instances) {
this.instances = Object.values(updated.instances).map(inst => ({
id: inst.id, name: inst.name, domain: inst.domain,
status: inst.status, region: inst.region, size: inst.size,
adminEmail: inst.adminEmail, vpsIp: inst.vpsIp,
sslProvisioned: inst.sslProvisioned,
}));
this.render();
}
});
} catch { /* runtime unavailable */ }
} }
private getApiBase(): string { private getApiBase(): string {

View File

@ -5,9 +5,13 @@
* and approval workflow interface. Includes a help/guide popout. * and approval workflow interface. Includes a help/guide popout.
*/ */
import { mailboxSchema, type MailboxDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkInboxClient extends HTMLElement { class FolkInboxClient extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = "demo"; private space = "demo";
private _offlineUnsubs: (() => void)[] = [];
private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes"; private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes";
private mailboxes: any[] = []; private mailboxes: any[] = [];
private threads: any[] = []; private threads: any[] = [];
@ -73,9 +77,41 @@ class FolkInboxClient extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadMailboxes(); this.loadMailboxes();
} }
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('inbox', 'mailboxes', mailboxSchema);
if (docs.size > 0 && this.mailboxes.length === 0) {
const fromDocs: any[] = [];
for (const [docId, doc] of docs) {
const d = doc as MailboxDoc;
if (!d?.mailbox) continue;
fromDocs.push({
slug: d.mailbox.slug, name: d.mailbox.name,
email: d.mailbox.email, description: d.mailbox.description,
thread_count: Object.keys(d.threads || {}).length,
});
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
}
if (fromDocs.length > 0) {
this.mailboxes = fromDocs;
this.render();
}
}
} catch { /* runtime unavailable */ }
}
private loadDemoData() { private loadDemoData() {
this.mailboxes = [ this.mailboxes = [
{ slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 }, { slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 },

View File

@ -10,6 +10,8 @@
*/ */
import * as Automerge from '@automerge/automerge'; import * as Automerge from '@automerge/automerge';
import { notebookSchema } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
@ -104,13 +106,12 @@ class FolkNotesApp extends HTMLElement {
private isRemoteUpdate = false; private isRemoteUpdate = false;
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null; private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
// Automerge sync state // Automerge sync state (via shared runtime)
private ws: WebSocket | null = null;
private doc: Automerge.Doc<NotebookDoc> | null = null; private doc: Automerge.Doc<NotebookDoc> | null = null;
private syncState: Automerge.SyncState = Automerge.initSyncState();
private subscribedDocId: string | null = null; private subscribedDocId: string | null = null;
private syncConnected = false; private syncConnected = false;
private pingInterval: ReturnType<typeof setInterval> | null = null; private _offlineUnsub: (() => void) | null = null;
private _offlineNotebookUnsubs: (() => void)[] = [];
// ── Demo data ── // ── Demo data ──
private demoNotebooks: (Notebook & { notes: Note[] })[] = []; private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
@ -124,10 +125,48 @@ class FolkNotesApp extends HTMLElement {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.setupShadow(); this.setupShadow();
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.connectSync(); this.subscribeOfflineRuntime();
this.loadNotebooks(); this.loadNotebooks();
} }
private async subscribeOfflineRuntime() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
// Discover all cached notebooks for this space
const docs = await runtime.subscribeModule('notes', 'notebooks', notebookSchema);
this.syncConnected = runtime.isOnline;
// Listen for connection state changes
this._offlineUnsub = runtime.onStatusChange((status: string) => {
this.syncConnected = status === 'online';
});
// Populate notebook list from cached docs if REST hasn't loaded
if (docs.size > 0 && this.notebooks.length === 0) {
const fromDocs: Notebook[] = [];
for (const [, doc] of docs) {
const d = doc as NotebookDoc;
if (!d?.notebook?.id) continue;
fromDocs.push({
id: d.notebook.id, title: d.notebook.title,
description: d.notebook.description || '',
cover_color: d.notebook.coverColor || '#3b82f6',
note_count: String(Object.keys(d.items || {}).length),
updated_at: d.notebook.updatedAt ? new Date(d.notebook.updatedAt).toISOString() : new Date().toISOString(),
});
}
if (fromDocs.length > 0) {
this.notebooks = fromDocs;
this.renderNav();
}
}
} catch {
// Runtime unavailable — REST fallback handles data
}
}
private setupShadow() { private setupShadow() {
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = this.getStyles(); style.textContent = this.getStyles();
@ -360,107 +399,51 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
disconnectedCallback() { disconnectedCallback() {
this.destroyEditor(); this.destroyEditor();
this.disconnectSync(); this._offlineUnsub?.();
this._offlineUnsub = null;
for (const unsub of this._offlineNotebookUnsubs) unsub();
this._offlineNotebookUnsubs = [];
} }
// ── WebSocket Sync ── // ── Sync (via shared runtime) ──
private connectSync() { private async subscribeNotebook(notebookId: string) {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${proto}//${location.host}/ws/${this.space}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.syncConnected = true;
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
}
}, 30000);
if (this.subscribedDocId && this.doc) {
this.subscribeNotebook(this.subscribedDocId.split(":").pop()!);
}
};
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "sync" && msg.docId === this.subscribedDocId) {
this.handleSyncMessage(new Uint8Array(msg.data));
}
} catch {
// ignore parse errors
}
};
this.ws.onclose = () => {
this.syncConnected = false;
if (this.pingInterval) clearInterval(this.pingInterval);
setTimeout(() => {
if (this.isConnected) this.connectSync();
}, 3000);
};
this.ws.onerror = () => {};
}
private disconnectSync() {
if (this.pingInterval) clearInterval(this.pingInterval);
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
this.syncConnected = false;
}
private handleSyncMessage(syncMsg: Uint8Array) {
if (!this.doc) return;
const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
this.doc, this.syncState, syncMsg
);
this.doc = newDoc;
this.syncState = newSyncState;
const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState);
this.syncState = nextState;
if (reply && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: "sync", docId: this.subscribedDocId,
data: Array.from(reply),
}));
}
this.renderFromDoc();
}
private subscribeNotebook(notebookId: string) {
this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`; this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`;
this.doc = Automerge.init<NotebookDoc>(); const runtime = (window as any).__rspaceOfflineRuntime;
this.syncState = Automerge.initSyncState();
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; if (runtime?.isInitialized) {
try {
const docId = this.subscribedDocId as DocumentId;
const doc = await runtime.subscribe(docId, notebookSchema);
this.doc = doc;
this.renderFromDoc();
this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] })); const unsub = runtime.onChange(docId, (updated: any) => {
this.doc = updated;
const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState); this.renderFromDoc();
this.syncState = s; });
if (m) { this._offlineNotebookUnsubs.push(unsub);
this.ws.send(JSON.stringify({ } catch {
type: "sync", docId: this.subscribedDocId, // Fallback: initialize empty doc for fresh notebook
data: Array.from(m), this.doc = Automerge.init<NotebookDoc>();
})); }
} else {
// No runtime — initialize empty doc
this.doc = Automerge.init<NotebookDoc>();
} }
} }
private unsubscribeNotebook() { private unsubscribeNotebook() {
if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) { if (this.subscribedDocId) {
this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] })); const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
runtime.unsubscribe(this.subscribedDocId as DocumentId);
}
} }
this.subscribedDocId = null; this.subscribedDocId = null;
this.doc = null; this.doc = null;
this.syncState = Automerge.initSyncState(); for (const unsub of this._offlineNotebookUnsubs) unsub();
this._offlineNotebookUnsubs = [];
} }
/** Extract notebook + notes from Automerge doc into component state */ /** Extract notebook + notes from Automerge doc into component state */
@ -586,30 +569,36 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
// ── Automerge mutations ── // ── Automerge mutations ──
private createNoteViaSync() { private createNoteViaSync() {
if (!this.doc || !this.selectedNotebook) return; if (!this.doc || !this.selectedNotebook || !this.subscribedDocId) return;
const noteId = crypto.randomUUID(); const noteId = crypto.randomUUID();
const now = Date.now(); const now = Date.now();
const notebookId = this.selectedNotebook.id;
this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { const runtime = (window as any).__rspaceOfflineRuntime;
if (!d.items) (d as any).items = {}; if (runtime?.isInitialized) {
d.items[noteId] = { runtime.change(this.subscribedDocId as DocumentId, "Create note", (d: NotebookDoc) => {
id: noteId, if (!d.items) (d as any).items = {};
notebookId: this.selectedNotebook!.id, d.items[noteId] = {
title: "Untitled Note", id: noteId, notebookId, title: "Untitled Note",
content: "", content: "", contentPlain: "", contentFormat: "tiptap-json",
contentPlain: "", type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
contentFormat: "tiptap-json", createdAt: now, updatedAt: now,
type: "NOTE", };
tags: [], });
isPinned: false, this.doc = runtime.get(this.subscribedDocId as DocumentId);
sortOrder: 0, } else {
createdAt: now, this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => {
updatedAt: now, if (!d.items) (d as any).items = {};
}; d.items[noteId] = {
}); id: noteId, notebookId, title: "Untitled Note",
content: "", contentPlain: "", contentFormat: "tiptap-json",
type: "NOTE", tags: [], isPinned: false, sortOrder: 0,
createdAt: now, updatedAt: now,
};
});
}
this.sendSyncAfterChange();
this.renderFromDoc(); this.renderFromDoc();
// Open the new note // Open the new note
@ -626,26 +615,20 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} }
private updateNoteField(noteId: string, field: string, value: string) { private updateNoteField(noteId: string, field: string, value: string) {
if (!this.doc || !this.doc.items?.[noteId]) return; if (!this.doc || !this.doc.items?.[noteId] || !this.subscribedDocId) return;
this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { const runtime = (window as any).__rspaceOfflineRuntime;
(d.items[noteId] as any)[field] = value; if (runtime?.isInitialized) {
d.items[noteId].updatedAt = Date.now(); runtime.change(this.subscribedDocId as DocumentId, `Update ${field}`, (d: NotebookDoc) => {
}); (d.items[noteId] as any)[field] = value;
d.items[noteId].updatedAt = Date.now();
this.sendSyncAfterChange(); });
} this.doc = runtime.get(this.subscribedDocId as DocumentId);
} else {
private sendSyncAfterChange() { this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => {
if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; (d.items[noteId] as any)[field] = value;
d.items[noteId].updatedAt = Date.now();
const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState); });
this.syncState = newState;
if (msg) {
this.ws.send(JSON.stringify({
type: "sync", docId: this.subscribedDocId,
data: Array.from(msg),
}));
} }
} }

View File

@ -2,9 +2,12 @@
* <folk-schedule-app> schedule management UI. * <folk-schedule-app> schedule management UI.
* *
* Job list with create/edit forms, execution log viewer, * Job list with create/edit forms, execution log viewer,
* and manual run triggers. REST-based (no Automerge client sync). * and manual run triggers. REST-based with offline-first Automerge sync.
*/ */
import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
interface JobData { interface JobData {
id: string; id: string;
name: string; name: string;
@ -83,6 +86,7 @@ class FolkScheduleApp extends HTMLElement {
private editingReminder: ReminderData | null = null; private editingReminder: ReminderData | null = null;
private loading = false; private loading = false;
private runningJobId: string | null = null; private runningJobId: string | null = null;
private _offlineUnsub: (() => void) | null = null;
// Reminder form state // Reminder form state
private rFormTitle = ""; private rFormTitle = "";
@ -109,9 +113,89 @@ class FolkScheduleApp extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.subscribeOffline();
this.loadJobs(); this.loadJobs();
} }
disconnectedCallback() {
if (this._offlineUnsub) {
this._offlineUnsub();
this._offlineUnsub = null;
}
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = scheduleDocId(this.space) as DocumentId;
const doc = await runtime.subscribe(docId, scheduleSchema);
if (doc) this.renderFromDoc(doc as ScheduleDoc);
this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => {
if (doc) this.renderFromDoc(doc);
});
} catch { /* runtime unavailable */ }
}
private renderFromDoc(doc: ScheduleDoc) {
if (doc.jobs && Object.keys(doc.jobs).length > 0) {
this.jobs = Object.values(doc.jobs).map((j) => ({
id: j.id,
name: j.name,
description: j.description,
enabled: j.enabled,
cronExpression: j.cronExpression,
timezone: j.timezone,
actionType: j.actionType,
actionConfig: j.actionConfig as Record<string, unknown>,
lastRunAt: j.lastRunAt,
lastRunStatus: j.lastRunStatus,
lastRunMessage: j.lastRunMessage,
nextRunAt: j.nextRunAt,
runCount: j.runCount,
createdBy: j.createdBy,
createdAt: j.createdAt,
updatedAt: j.updatedAt,
}));
}
if (doc.reminders && Object.keys(doc.reminders).length > 0) {
this.reminders = Object.values(doc.reminders).map((r) => ({
id: r.id,
title: r.title,
description: r.description,
remindAt: r.remindAt,
allDay: r.allDay,
timezone: r.timezone,
notifyEmail: r.notifyEmail,
notified: r.notified,
completed: r.completed,
sourceModule: r.sourceModule,
sourceLabel: r.sourceLabel,
sourceColor: r.sourceColor,
cronExpression: r.cronExpression,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
}
if (doc.log && doc.log.length > 0) {
this.log = doc.log.map((e) => ({
id: e.id,
jobId: e.jobId,
status: e.status,
message: e.message,
durationMs: e.durationMs,
timestamp: e.timestamp,
}));
}
this.loading = false;
this.render();
}
private getApiBase(): string { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rschedule/); const match = path.match(/^(\/[^/]+)?\/rschedule/);

View File

@ -7,6 +7,9 @@
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
*/ */
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
interface SplatItem { interface SplatItem {
id: string; id: string;
slug: string; slug: string;
@ -30,6 +33,7 @@ export class FolkSplatViewer extends HTMLElement {
private _splatDesc = ""; private _splatDesc = "";
private _viewer: any = null; private _viewer: any = null;
private _uploadMode: "splat" | "media" = "splat"; private _uploadMode: "splat" | "media" = "splat";
private _offlineUnsub: (() => void) | null = null;
static get observedAttributes() { static get observedAttributes() {
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
@ -54,11 +58,46 @@ export class FolkSplatViewer extends HTMLElement {
if (this._mode === "viewer") { if (this._mode === "viewer") {
this.renderViewer(); this.renderViewer();
} else { } else {
if (this._spaceSlug === "demo") this.loadDemoData(); if (this._spaceSlug === "demo") {
this.loadDemoData();
} else {
this.subscribeOffline();
}
this.renderGallery(); this.renderGallery();
} }
} }
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docId = splatScenesDocId(this._spaceSlug) as DocumentId;
const doc = await runtime.subscribe(docId, splatScenesSchema);
this.renderFromDoc(doc);
this._offlineUnsub = runtime.onChange(docId, (updated: any) => {
this.renderFromDoc(updated);
});
} catch {
// Runtime unavailable — server-side hydration handles data
}
}
private renderFromDoc(doc: SplatScenesDoc) {
if (!doc?.items || Object.keys(doc.items).length === 0) return;
if (this._splats.length > 0) return; // Don't clobber server-hydrated data
this._splats = Object.values(doc.items).map(s => ({
id: s.id, slug: s.slug, title: s.title, description: s.description,
file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes,
view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
processing_status: s.processingStatus ?? undefined,
source_file_count: s.sourceFileCount,
created_at: new Date(s.createdAt).toISOString(),
}));
if (this._mode === "gallery") this.renderGallery();
}
private loadDemoData() { private loadDemoData() {
this._splats = [ this._splats = [
{ id: "s1", slug: "matterhorn-scan", title: "Matterhorn Summit", description: "Photogrammetry capture of the Matterhorn peak from drone footage, 42 source images.", file_format: "splat", file_size_bytes: 18_874_368, view_count: 284, contributor_name: "Alpine Explorer Team", processing_status: "ready", created_at: "2026-02-10" }, { id: "s1", slug: "matterhorn-scan", title: "Matterhorn Summit", description: "Photogrammetry capture of the Matterhorn peak from drone footage, 42 source images.", file_format: "splat", file_size_bytes: 18_874_368, view_count: 284, contributor_name: "Alpine Explorer Team", processing_status: "ready", created_at: "2026-02-10" },
@ -71,6 +110,8 @@ export class FolkSplatViewer extends HTMLElement {
} }
disconnectedCallback() { disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
if (this._viewer) { if (this._viewer) {
try { this._viewer.dispose(); } catch {} try { this._viewer.dispose(); } catch {}
this._viewer = null; this._viewer = null;

View File

@ -6,6 +6,9 @@
* Demo: 4 trips with varied statuses and rich destination chains. * Demo: 4 trips with varied statuses and rich destination chains.
*/ */
import { tripSchema, type TripDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkTripsPlanner extends HTMLElement { class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
@ -14,6 +17,7 @@ class FolkTripsPlanner extends HTMLElement {
private trip: any = null; private trip: any = null;
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview"; private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = ""; private error = "";
private _offlineUnsubs: (() => void)[] = [];
constructor() { constructor() {
super(); super();
@ -23,10 +27,43 @@ class FolkTripsPlanner extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadTrips(); this.loadTrips();
this.render(); this.render();
} }
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('trips', 'trips', tripSchema);
if (docs.size > 0 && this.trips.length === 0) {
const fromDocs: any[] = [];
for (const [docId, doc] of docs) {
const d = doc as TripDoc;
if (!d?.trip) continue;
fromDocs.push({
id: d.trip.id, title: d.trip.title, status: d.trip.status,
start_date: d.trip.startDate, end_date: d.trip.endDate,
budget_total: d.trip.budgetTotal, description: d.trip.description,
destination_count: Object.keys(d.destinations || {}).length,
});
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
}
if (fromDocs.length > 0) {
this.trips = fromDocs;
this.render();
}
}
} catch { /* runtime unavailable */ }
}
private loadDemoData() { private loadDemoData() {
this.trips = [ this.trips = [
{ {

View File

@ -4,6 +4,9 @@
* Browse spaces, create/view proposals, cast votes (ranking + final). * Browse spaces, create/view proposals, cast votes (ranking + final).
*/ */
import { proposalSchema, type ProposalDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
interface VoteSpace { interface VoteSpace {
slug: string; slug: string;
name: string; name: string;
@ -40,6 +43,7 @@ class FolkVoteDashboard extends HTMLElement {
private selectedProposal: Proposal | null = null; private selectedProposal: Proposal | null = null;
private loading = false; private loading = false;
private error = ""; private error = "";
private _offlineUnsubs: (() => void)[] = [];
constructor() { constructor() {
super(); super();
@ -49,9 +53,46 @@ class FolkVoteDashboard extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadSpaces(); this.loadSpaces();
} }
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('vote', 'proposals', proposalSchema);
if (docs.size > 0 && this.proposals.length === 0) {
const fromDocs: Proposal[] = [];
for (const [docId, doc] of docs) {
const d = doc as ProposalDoc;
if (!d?.proposal) continue;
fromDocs.push({
id: d.proposal.id, title: d.proposal.title,
description: d.proposal.description, status: d.proposal.status,
score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length),
final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo,
final_abstain: d.proposal.finalAbstain,
created_at: new Date(d.proposal.createdAt).toISOString(),
voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null,
});
this._offlineUnsubs.push(runtime.onChange(docId, () => {}));
}
if (fromDocs.length > 0) {
this.proposals = fromDocs;
this.view = "proposals";
this.render();
}
}
} catch { /* runtime unavailable */ }
}
private loadDemoData() { private loadDemoData() {
this.spaces = [ this.spaces = [
{ {

View File

@ -5,6 +5,9 @@
* Supports task creation, status changes, and priority labels. * Supports task creation, status changes, and priority labels.
*/ */
import { boardSchema, type BoardDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
class FolkWorkBoard extends HTMLElement { class FolkWorkBoard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
@ -20,6 +23,7 @@ class FolkWorkBoard extends HTMLElement {
private editingTaskId: string | null = null; private editingTaskId: string | null = null;
private showCreateForm = false; private showCreateForm = false;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = [];
constructor() { constructor() {
super(); super();
@ -29,10 +33,55 @@ class FolkWorkBoard extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); return; } if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadWorkspaces(); this.loadWorkspaces();
this.render(); this.render();
} }
disconnectedCallback() {
for (const unsub of this._offlineUnsubs) unsub();
this._offlineUnsubs = [];
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
try {
const docs = await runtime.subscribeModule('work', 'boards', boardSchema);
// Build workspace list from cached boards
if (docs.size > 0 && this.workspaces.length === 0) {
const boards: any[] = [];
for (const [docId, doc] of docs) {
const d = doc as BoardDoc;
if (!d?.board) continue;
boards.push({ slug: d.board.slug, name: d.board.name, icon: null, task_count: Object.keys(d.tasks || {}).length });
this._offlineUnsubs.push(runtime.onChange(docId, () => this.refreshFromDocs()));
}
if (boards.length > 0) {
this.workspaces = boards;
this.render();
}
}
} catch { /* runtime unavailable */ }
}
private refreshFromDocs() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.workspaceSlug) return;
// Reload tasks for current board from runtime
const docId = runtime.makeDocId('work', 'boards', this.workspaceSlug);
const doc = runtime.get(docId) as BoardDoc | undefined;
if (doc?.tasks && Object.keys(doc.tasks).length > 0) {
this.tasks = Object.values(doc.tasks).map(t => ({
id: t.id, title: t.title, description: t.description,
status: t.status, priority: t.priority, labels: t.labels,
assignee: t.assigneeId,
}));
this.render();
}
}
private loadDemoData() { private loadDemoData() {
this.isDemo = true; this.isDemo = true;
this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 2 }]; this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 2 }];

View File

@ -62,6 +62,19 @@ interface RelayRestoreMessage {
data: number[]; data: number[];
} }
interface DocListRequestMessage {
type: 'doc-list-request';
prefix: string;
requestId: string;
}
interface DocListResponseMessage {
type: 'doc-list-response';
prefix: string;
docIds: string[];
requestId: string;
}
type WireMessage = type WireMessage =
| SyncMessage | SyncMessage
| SubscribeMessage | SubscribeMessage
@ -69,6 +82,8 @@ type WireMessage =
| AwarenessMessage | AwarenessMessage
| RelayBackupMessage | RelayBackupMessage
| RelayRestoreMessage | RelayRestoreMessage
| DocListRequestMessage
| DocListResponseMessage
| { type: 'ping' } | { type: 'ping' }
| { type: 'pong' }; | { type: 'pong' };
@ -199,6 +214,9 @@ export class SyncServer {
case 'relay-backup': case 'relay-backup':
this.#handleRelayBackup(peer, msg as RelayBackupMessage); this.#handleRelayBackup(peer, msg as RelayBackupMessage);
break; break;
case 'doc-list-request':
this.#handleDocListRequest(peer, msg as DocListRequestMessage);
break;
case 'ping': case 'ping':
this.#sendToPeer(peer, { type: 'pong' }); this.#sendToPeer(peer, { type: 'pong' });
break; break;
@ -400,6 +418,16 @@ export class SyncServer {
} }
} }
#handleDocListRequest(peer: Peer, msg: DocListRequestMessage): void {
const docIds = this.getDocIds().filter(id => id.startsWith(msg.prefix));
this.#sendToPeer(peer, {
type: 'doc-list-response',
prefix: msg.prefix,
docIds,
requestId: msg.requestId,
});
}
#sendSyncToPeer(peer: Peer, docId: string): void { #sendSyncToPeer(peer: Peer, docId: string): void {
const doc = this.#docs.get(docId); const doc = this.#docs.get(docId);
if (!doc) return; if (!doc) return;

View File

@ -100,7 +100,7 @@ export function renderShell(opts: ShellOptions): string {
<style>${WELCOME_CSS}</style> <style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style> <style>${ACCESS_GATE_CSS}</style>
</head> </head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}"> <body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}">
<header class="rstack-header"> <header class="rstack-header">
<div class="rstack-header__left"> <div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a> <a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
@ -112,6 +112,7 @@ export function renderShell(opts: ShellOptions): string {
</div> </div>
<div class="rstack-header__right"> <div class="rstack-header__right">
<a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a> <a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="${shellDemoUrl}">Try Demo</a>
<rstack-offline-indicator></rstack-offline-indicator>
<rstack-notification-bell></rstack-notification-bell> <rstack-notification-bell></rstack-notification-bell>
<rstack-identity></rstack-identity> <rstack-identity></rstack-identity>
</div> </div>

View File

@ -0,0 +1,107 @@
/**
* <rstack-offline-indicator> shows connection status in the shell header.
*
* States:
* - online: hidden (no visual noise)
* - syncing: brief spinner (shown during init)
* - offline: orange dot + "Offline"
* - error: red dot + tooltip with error info
*/
import type { RuntimeStatus } from '../local-first/runtime';
export class RStackOfflineIndicator extends HTMLElement {
#shadow: ShadowRoot;
#status: RuntimeStatus = 'idle';
#unsub: (() => void) | null = null;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#render();
// Connect to runtime when available
const tryConnect = () => {
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime) {
this.#status = runtime.status;
this.#render();
this.#unsub = runtime.onStatusChange((s: RuntimeStatus) => {
this.#status = s;
this.#render();
});
} else {
// Runtime not ready yet, try again shortly
setTimeout(tryConnect, 500);
}
};
tryConnect();
}
disconnectedCallback() {
this.#unsub?.();
this.#unsub = null;
}
#render() {
const hidden = this.#status === 'online' || this.#status === 'idle';
const isOffline = this.#status === 'offline';
const isError = this.#status === 'error';
const isSyncing = this.#status === 'initializing';
const dotColor = isError ? '#ef4444' : isOffline ? '#f59e0b' : '#3b82f6';
const label = isError ? 'Sync Error' : isOffline ? 'Offline' : isSyncing ? 'Syncing' : '';
const tooltip = isError
? 'Unable to connect to sync server. Changes saved locally.'
: isOffline
? 'Working offline. Changes will sync when reconnected.'
: isSyncing
? 'Connecting to sync server...'
: '';
this.#shadow.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 6px; }
.indicator {
display: ${hidden ? 'none' : 'inline-flex'};
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 12px;
background: var(--rs-bg-secondary, rgba(255,255,255,0.06));
font-size: 11px;
color: var(--rs-text-secondary, #999);
cursor: default;
user-select: none;
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: ${dotColor};
flex-shrink: 0;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.dot.syncing {
animation: pulse 1.2s ease-in-out infinite;
background: #3b82f6;
}
</style>
<div class="indicator" title="${tooltip}">
<span class="dot${isSyncing ? ' syncing' : ''}"></span>
<span>${label}</span>
</div>
`;
}
static define() {
if (!customElements.get('rstack-offline-indicator')) {
customElements.define('rstack-offline-indicator', RStackOfflineIndicator);
}
}
}

View File

@ -43,6 +43,23 @@ export {
type WireMessage, type WireMessage,
} from './sync'; } from './sync';
// Runtime (singleton coordinator for offline-first)
export {
RSpaceOfflineRuntime,
type RuntimeStatus,
type StatusCallback,
} from './runtime';
// Storage Quota
export {
getStorageInfo,
requestPersistentStorage,
evictStaleDocs,
isQuotaWarning,
QUOTA_WARNING_PERCENT,
type StorageInfo,
} from './storage-quota';
// Backup // Backup
export { export {
BackupSyncManager, BackupSyncManager,

View File

@ -0,0 +1,272 @@
/**
* Offline Runtime singleton coordinator for all modules.
*
* Composes DocumentManager, EncryptedDocStore, and DocSyncManager into a
* single entry point that web components use via `window.__rspaceOfflineRuntime`.
*
* Lifecycle:
* 1. Shell creates runtime with space slug
* 2. runtime.init() opens IndexedDB, connects WebSocket
* 3. Components call runtime.subscribe(docId, schema) to get a live doc
* 4. Components call runtime.change(docId, msg, fn) for mutations
* 5. Shell calls runtime.flush() on beforeunload
*/
import * as Automerge from '@automerge/automerge';
import {
type DocumentId,
type DocSchema,
makeDocumentId,
DocumentManager,
} from './document';
import { EncryptedDocStore } from './storage';
import { DocSyncManager } from './sync';
import { DocCrypto } from './crypto';
import {
getStorageInfo,
evictStaleDocs,
requestPersistentStorage,
QUOTA_WARNING_PERCENT,
} from './storage-quota';
// ============================================================================
// TYPES
// ============================================================================
export type RuntimeStatus = 'idle' | 'initializing' | 'online' | 'offline' | 'error';
export type StatusCallback = (status: RuntimeStatus) => void;
// ============================================================================
// RSpaceOfflineRuntime
// ============================================================================
export class RSpaceOfflineRuntime {
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#crypto: DocCrypto;
#space: string;
#status: RuntimeStatus = 'idle';
#statusListeners = new Set<StatusCallback>();
#initialized = false;
constructor(space: string) {
this.#space = space;
this.#crypto = new DocCrypto();
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
}
// ── Getters ──
get space(): string { return this.#space; }
get isInitialized(): boolean { return this.#initialized; }
get isOnline(): boolean { return this.#sync.isConnected; }
get status(): RuntimeStatus { return this.#status; }
// ── Lifecycle ──
/**
* Open IndexedDB and connect to the sync server.
* Safe to call multiple times no-ops if already initialized.
*/
async init(): Promise<void> {
if (this.#initialized) return;
this.#setStatus('initializing');
try {
// 1. Open IndexedDB
await this.#store.open();
// 2. Connect WebSocket (non-blocking — works offline)
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
this.#sync.onConnect(() => this.#setStatus('online'));
this.#sync.onDisconnect(() => this.#setStatus('offline'));
try {
await this.#sync.connect(wsUrl, this.#space);
this.#setStatus('online');
} catch {
// WebSocket failed — still usable offline
this.#setStatus('offline');
}
this.#initialized = true;
// 3. Storage housekeeping (non-blocking)
this.#runStorageHousekeeping();
} catch (e) {
this.#setStatus('error');
console.error('[OfflineRuntime] Init failed:', e);
throw e;
}
}
/**
* Subscribe to a document. Loads from IndexedDB (instant), syncs via WebSocket.
* Returns the current Automerge doc.
*/
async subscribe<T extends Record<string, any>>(
docId: DocumentId,
schema: DocSchema<T>,
): Promise<Automerge.Doc<T>> {
// Register schema for future use
this.#documents.registerSchema(schema);
// Try loading from IndexedDB first (instant cache hit)
const cached = await this.#store.load(docId);
const doc = this.#documents.open(docId, schema, cached ?? undefined);
// Subscribe for sync (sends subscribe + initial sync to server)
await this.#sync.subscribe([docId]);
return doc;
}
/**
* Unsubscribe from a document. Persists current state, stops syncing.
*/
unsubscribe(docId: DocumentId): void {
// Save before unsubscribing
const binary = this.#documents.save(docId);
if (binary) {
const meta = this.#documents.getMeta(docId);
this.#store.save(docId, binary, meta ? {
module: meta.module,
collection: meta.collection,
version: meta.version,
} : undefined);
}
this.#sync.unsubscribe([docId]);
this.#documents.close(docId);
}
/**
* Mutate a document. Persists to IndexedDB + syncs to server.
*/
change<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void {
this.#sync.change<T>(docId, message, fn);
}
/**
* Read the current state of a document.
*/
get<T>(docId: DocumentId): Automerge.Doc<T> | undefined {
return this.#documents.get<T>(docId);
}
/**
* Listen for changes (local or remote) on a document.
* Returns an unsubscribe function.
*/
onChange<T>(docId: DocumentId, cb: (doc: Automerge.Doc<T>) => void): () => void {
// Listen to both DocumentManager (local changes) and DocSyncManager (remote)
const unsub1 = this.#documents.onChange<T>(docId, cb);
const unsub2 = this.#sync.onChange(docId, cb as any);
return () => { unsub1(); unsub2(); };
}
/**
* Build a DocumentId for a module using the current space.
*/
makeDocId(module: string, collection: string, itemId?: string): DocumentId {
return makeDocumentId(this.#space, module, collection, itemId);
}
/**
* List all cached doc IDs for a module/collection from IndexedDB.
*/
async listByModule(module: string, collection?: string): Promise<DocumentId[]> {
return this.#store.listByModule(module, collection);
}
/**
* Subscribe to all documents for a multi-doc module.
* Discovers doc IDs from both IndexedDB cache and the server,
* then subscribes to each. Returns all discovered docs.
*/
async subscribeModule<T extends Record<string, any>>(
module: string,
collection: string,
schema: DocSchema<T>,
): Promise<Map<DocumentId, Automerge.Doc<T>>> {
this.#documents.registerSchema(schema);
const prefix = `${this.#space}:${module}:${collection}`;
const docIds = await this.#sync.requestDocList(prefix);
const results = new Map<DocumentId, Automerge.Doc<T>>();
for (const id of docIds) {
const docId = id as DocumentId;
const doc = await this.subscribe<T>(docId, schema);
results.set(docId, doc);
}
return results;
}
/**
* Listen for runtime status changes (online/offline/syncing/error).
*/
onStatusChange(cb: StatusCallback): () => void {
this.#statusListeners.add(cb);
return () => { this.#statusListeners.delete(cb); };
}
/**
* Flush all pending writes to IndexedDB. Call on beforeunload.
*/
async flush(): Promise<void> {
await Promise.all([
this.#sync.flush(),
this.#store.flush(),
]);
}
/**
* Tear down: flush, disconnect, clear listeners.
*/
destroy(): void {
this.#sync.disconnect();
this.#statusListeners.clear();
this.#initialized = false;
this.#setStatus('idle');
}
// ── Private ──
async #runStorageHousekeeping(): Promise<void> {
try {
// Request persistent storage (browser may grant silently)
await requestPersistentStorage();
// Check quota and evict stale docs if needed
const info = await getStorageInfo();
if (info.percent >= QUOTA_WARNING_PERCENT) {
console.warn(
`[OfflineRuntime] Storage usage at ${info.percent}% ` +
`(${(info.usage / 1e6).toFixed(1)}MB / ${(info.quota / 1e6).toFixed(1)}MB). ` +
`Evicting stale documents...`
);
await evictStaleDocs();
}
} catch (e) {
console.warn('[OfflineRuntime] Storage housekeeping failed:', e);
}
}
#setStatus(status: RuntimeStatus): void {
if (this.#status === status) return;
this.#status = status;
for (const cb of this.#statusListeners) {
try { cb(status); } catch { /* ignore */ }
}
}
}

View File

@ -0,0 +1,166 @@
/**
* Storage Quota Management monitors IndexedDB usage and evicts stale docs.
*
* Uses the Storage API (navigator.storage.estimate) to check quota,
* and evicts documents not accessed in 30+ days via LRU policy.
*/
import type { DocumentId } from './document';
// ============================================================================
// TYPES
// ============================================================================
export interface StorageInfo {
/** Bytes used */
usage: number;
/** Total quota in bytes */
quota: number;
/** Usage as a percentage (0100) */
percent: number;
/** Whether persistent storage is granted */
persisted: boolean;
}
// ============================================================================
// CONSTANTS
// ============================================================================
/** Warn the user when storage usage exceeds this percentage */
export const QUOTA_WARNING_PERCENT = 70;
/** Evict docs not accessed in this many milliseconds (30 days) */
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Get current storage usage info.
*/
export async function getStorageInfo(): Promise<StorageInfo> {
if (!navigator.storage?.estimate) {
return { usage: 0, quota: 0, percent: 0, persisted: false };
}
const [estimate, persisted] = await Promise.all([
navigator.storage.estimate(),
navigator.storage.persisted?.() ?? Promise.resolve(false),
]);
const usage = estimate.usage ?? 0;
const quota = estimate.quota ?? 0;
const percent = quota > 0 ? Math.round((usage / quota) * 100) : 0;
return { usage, quota, percent, persisted };
}
/**
* Request persistent storage so the browser won't evict our data under pressure.
* Returns true if granted.
*/
export async function requestPersistentStorage(): Promise<boolean> {
if (!navigator.storage?.persist) return false;
return navigator.storage.persist();
}
/**
* Evict stale documents from IndexedDB.
* Removes docs whose `updatedAt` is older than 30 days.
* Returns the list of evicted document IDs.
*/
export async function evictStaleDocs(dbName = 'rspace-docs'): Promise<DocumentId[]> {
const evicted: DocumentId[] = [];
const cutoff = Date.now() - STALE_THRESHOLD_MS;
let db: IDBDatabase;
try {
db = await openDb(dbName);
} catch {
return evicted;
}
try {
// Scan meta store for stale entries
const staleIds = await new Promise<string[]>((resolve, reject) => {
const tx = db.transaction('meta', 'readonly');
const request = tx.objectStore('meta').openCursor();
const ids: string[] = [];
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) { resolve(ids); return; }
const meta = cursor.value as { docId: string; updatedAt: number };
if (meta.updatedAt < cutoff) {
ids.push(meta.docId);
}
cursor.continue();
};
request.onerror = () => reject(request.error);
});
// Delete stale docs + meta + sync states
if (staleIds.length > 0) {
const tx = db.transaction(['docs', 'meta', 'sync'], 'readwrite');
const docsStore = tx.objectStore('docs');
const metaStore = tx.objectStore('meta');
for (const id of staleIds) {
docsStore.delete(id);
metaStore.delete(id);
evicted.push(id as DocumentId);
}
// Clean sync states for evicted docs
const syncStore = tx.objectStore('sync');
const syncCursor = syncStore.openCursor();
await new Promise<void>((resolve, reject) => {
syncCursor.onsuccess = () => {
const cursor = syncCursor.result;
if (!cursor) { resolve(); return; }
const key = cursor.key as string;
if (staleIds.some(id => key.startsWith(`${id}\0`))) {
cursor.delete();
}
cursor.continue();
};
syncCursor.onerror = () => reject(syncCursor.error);
});
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
} finally {
db.close();
}
if (evicted.length > 0) {
console.log(`[StorageQuota] Evicted ${evicted.length} stale documents`);
}
return evicted;
}
/**
* Check if storage usage is above the warning threshold.
*/
export async function isQuotaWarning(): Promise<boolean> {
const info = await getStorageInfo();
return info.percent >= QUOTA_WARNING_PERCENT;
}
// ============================================================================
// HELPERS
// ============================================================================
function openDb(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

View File

@ -65,6 +65,21 @@ export interface RelayRestoreMessage {
data: number[]; data: number[];
} }
/** Client requests doc IDs matching a prefix (for multi-doc modules). */
export interface DocListRequestMessage {
type: 'doc-list-request';
prefix: string;
requestId: string;
}
/** Server responds with matching doc IDs. */
export interface DocListResponseMessage {
type: 'doc-list-response';
prefix: string;
docIds: string[];
requestId: string;
}
export type WireMessage = export type WireMessage =
| SyncMessage | SyncMessage
| SubscribeMessage | SubscribeMessage
@ -72,6 +87,8 @@ export type WireMessage =
| AwarenessMessage | AwarenessMessage
| RelayBackupMessage | RelayBackupMessage
| RelayRestoreMessage | RelayRestoreMessage
| DocListRequestMessage
| DocListResponseMessage
| { type: 'ping' } | { type: 'ping' }
| { type: 'pong' }; | { type: 'pong' };
@ -89,6 +106,7 @@ export interface DocSyncManagerOptions {
type DocChangeCallback = (doc: any) => void; type DocChangeCallback = (doc: any) => void;
type AwarenessCallback = (msg: AwarenessMessage) => void; type AwarenessCallback = (msg: AwarenessMessage) => void;
type ConnectionCallback = () => void; type ConnectionCallback = () => void;
type DocListCallback = (docIds: string[]) => void;
// ============================================================================ // ============================================================================
// DocSyncManager (Client) // DocSyncManager (Client)
@ -121,6 +139,9 @@ export class DocSyncManager {
// Keep-alive // Keep-alive
#pingInterval: ReturnType<typeof setInterval> | null = null; #pingInterval: ReturnType<typeof setInterval> | null = null;
// Pending doc-list requests
#docListCallbacks = new Map<string, DocListCallback>();
constructor(opts: DocSyncManagerOptions) { constructor(opts: DocSyncManagerOptions) {
this.#documents = opts.documents; this.#documents = opts.documents;
this.#store = opts.store ?? null; this.#store = opts.store ?? null;
@ -299,6 +320,46 @@ export class DocSyncManager {
} as AwarenessMessage); } as AwarenessMessage);
} }
/**
* Request a list of doc IDs matching a prefix from the server.
* Used by multi-doc modules to discover which documents exist.
* Falls back to IndexedDB listing if offline.
*/
async requestDocList(prefix: string): Promise<string[]> {
// Try IndexedDB first for instant offline results
const parts = prefix.split(':');
let localIds: string[] = [];
if (this.#store && parts.length >= 2) {
const module = parts[1];
const collection = parts[2];
localIds = await this.#store.listByModule(module, collection) as string[];
localIds = localIds.filter(id => id.startsWith(prefix));
}
// If connected, also ask the server
if (this.isConnected) {
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const serverIds = await new Promise<string[]>((resolve) => {
const timeout = setTimeout(() => {
this.#docListCallbacks.delete(requestId);
resolve([]);
}, 5000);
this.#docListCallbacks.set(requestId, (ids) => {
clearTimeout(timeout);
this.#docListCallbacks.delete(requestId);
resolve(ids);
});
this.#send({ type: 'doc-list-request', prefix, requestId });
});
// Merge local + server (unique)
const all = new Set([...localIds, ...serverIds]);
return Array.from(all);
}
return localIds;
}
/** /**
* Listen for changes on a specific document. * Listen for changes on a specific document.
*/ */
@ -416,6 +477,9 @@ export class DocSyncManager {
case 'relay-restore': case 'relay-restore':
this.#handleRelayRestore(msg as RelayRestoreMessage); this.#handleRelayRestore(msg as RelayRestoreMessage);
break; break;
case 'doc-list-response':
this.#handleDocListResponse(msg as DocListResponseMessage);
break;
case 'pong': case 'pong':
// Keep-alive acknowledged // Keep-alive acknowledged
break; break;
@ -489,6 +553,11 @@ export class DocSyncManager {
} }
} }
#handleDocListResponse(msg: DocListResponseMessage): void {
const cb = this.#docListCallbacks.get(msg.requestId);
if (cb) cb(msg.docIds);
}
#sendSyncMessage(docId: DocumentId): void { #sendSyncMessage(docId: DocumentId): void {
const doc = this.#documents.get(docId); const doc = this.#documents.get(docId);
if (!doc) return; if (!doc) return;

View File

@ -14,8 +14,10 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"
import { RStackTabBar } from "../shared/components/rstack-tab-bar"; import { RStackTabBar } from "../shared/components/rstack-tab-bar";
import { RStackMi } from "../shared/components/rstack-mi"; import { RStackMi } from "../shared/components/rstack-mi";
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"; import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
import { rspaceNavUrl } from "../shared/url-helpers"; import { rspaceNavUrl } from "../shared/url-helpers";
import { TabCache } from "../shared/tab-cache"; import { TabCache } from "../shared/tab-cache";
import { RSpaceOfflineRuntime } from "../shared/local-first/runtime";
// Expose URL helper globally (used by shell inline scripts + components) // Expose URL helper globally (used by shell inline scripts + components)
(window as any).__rspaceNavUrl = rspaceNavUrl; (window as any).__rspaceNavUrl = rspaceNavUrl;
@ -31,6 +33,24 @@ RStackSpaceSwitcher.define();
RStackTabBar.define(); RStackTabBar.define();
RStackMi.define(); RStackMi.define();
RStackSpaceSettings.define(); RStackSpaceSettings.define();
RStackOfflineIndicator.define();
// ── Offline Runtime ──
// Instantiate the shared runtime from the space slug on the <body> tag.
// Components access it via window.__rspaceOfflineRuntime.
const spaceSlug = document.body?.getAttribute("data-space-slug");
if (spaceSlug && spaceSlug !== "demo") {
const runtime = new RSpaceOfflineRuntime(spaceSlug);
(window as any).__rspaceOfflineRuntime = runtime;
runtime.init().catch((e: unknown) => {
console.warn("[shell] Offline runtime init failed — REST fallback only:", e);
});
// Flush pending writes before the page unloads
window.addEventListener("beforeunload", () => {
runtime.flush();
});
}
// Reload space list when user signs in/out (to show/hide private spaces) // Reload space list when user signs in/out (to show/hide private spaces)
document.addEventListener("auth-change", () => { document.addEventListener("auth-change", () => {

View File

@ -4,6 +4,7 @@ declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = "rspace-v1"; const CACHE_VERSION = "rspace-v1";
const STATIC_CACHE = `${CACHE_VERSION}-static`; const STATIC_CACHE = `${CACHE_VERSION}-static`;
const HTML_CACHE = `${CACHE_VERSION}-html`; const HTML_CACHE = `${CACHE_VERSION}-html`;
const API_CACHE = `${CACHE_VERSION}-api`;
// Vite-hashed assets are immutable (content hash in filename) // Vite-hashed assets are immutable (content hash in filename)
const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/; const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
@ -11,6 +12,9 @@ const IMMUTABLE_PATTERN = /\/assets\/.*\.[a-f0-9]{8}\.(js|css|wasm)$/;
// App shell to precache on install // App shell to precache on install
const PRECACHE_URLS = ["/", "/canvas.html"]; const PRECACHE_URLS = ["/", "/canvas.html"];
// Max age for cached API GET responses (5 minutes)
const API_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) caches.open(HTML_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
@ -40,16 +44,65 @@ self.addEventListener("fetch", (event) => {
// Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached // Skip non-http(s) schemes (chrome-extension://, etc.) — they can't be cached
if (!url.protocol.startsWith("http")) return; if (!url.protocol.startsWith("http")) return;
// Skip WebSocket and API requests entirely (including module APIs like /space/module/api/...) // Skip WebSocket requests entirely
if ( if (
event.request.url.startsWith("ws://") || event.request.url.startsWith("ws://") ||
event.request.url.startsWith("wss://") || event.request.url.startsWith("wss://") ||
url.pathname.startsWith("/ws/") || url.pathname.startsWith("/ws/")
url.pathname.includes("/api/")
) { ) {
return; return;
} }
// API GET requests: stale-while-revalidate for offline browsing
// Skip POST/PUT/DELETE — those must go to the server
if (url.pathname.includes("/api/")) {
if (event.request.method !== "GET") return;
event.respondWith(
caches.open(API_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
const fetchPromise = fetch(event.request)
.then((response) => {
if (response.ok) {
// Store with a timestamp header for staleness checking
const headers = new Headers(response.headers);
headers.set("x-sw-cached-at", String(Date.now()));
const timedResponse = new Response(response.clone().body, {
status: response.status,
statusText: response.statusText,
headers,
});
cache.put(event.request, timedResponse);
}
return response;
})
.catch(() => {
// Offline — return cached if available
if (cached) return cached;
return new Response(
JSON.stringify({ error: "offline", message: "You are offline and this data is not cached." }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
});
// Return cached immediately if fresh enough, revalidate in background
if (cached) {
const cachedAt = Number(cached.headers.get("x-sw-cached-at") || 0);
if (Date.now() - cachedAt < API_CACHE_MAX_AGE_MS) {
// Fresh cache — serve immediately, revalidate in background
event.waitUntil(fetchPromise);
return cached;
}
}
// No cache or stale — wait for network
return fetchPromise;
})
);
return;
}
// Immutable hashed assets: cache-first (they never change) // Immutable hashed assets: cache-first (they never change)
if (IMMUTABLE_PATTERN.test(url.pathname)) { if (IMMUTABLE_PATTERN.test(url.pathname)) {
event.respondWith( event.respondWith(
@ -67,7 +120,7 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// HTML pages: network-first with cache fallback // HTML pages: network-first with cache fallback + offline page
if ( if (
event.request.mode === "navigate" || event.request.mode === "navigate" ||
event.request.headers.get("accept")?.includes("text/html") event.request.headers.get("accept")?.includes("text/html")
@ -84,7 +137,7 @@ self.addEventListener("fetch", (event) => {
.catch(() => { .catch(() => {
return caches return caches
.match(event.request) .match(event.request)
.then((cached) => cached || caches.match("/canvas.html")) as Promise<Response>; .then((cached) => cached || caches.match("/") || offlineFallbackPage()) as Promise<Response>;
}) })
); );
return; return;
@ -106,3 +159,39 @@ self.addEventListener("fetch", (event) => {
}) })
); );
}); });
/** Minimal offline fallback page when nothing is cached. */
function offlineFallbackPage(): Response {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - rSpace</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; align-items: center;
justify-content: center; min-height: 100vh; margin: 0;
background: #0a0a0f; color: #e0e0e0; }
.offline { text-align: center; max-width: 400px; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #999; line-height: 1.5; }
button { margin-top: 1rem; padding: 8px 20px; border-radius: 6px;
border: 1px solid #333; background: #1a1a2e; color: #e0e0e0;
cursor: pointer; font-size: 0.9rem; }
button:hover { background: #2a2a3e; }
</style>
</head>
<body>
<div class="offline">
<h1>You're offline</h1>
<p>rSpace can't reach the server right now. Previously visited pages and
locally cached data are still available.</p>
<button onclick="location.reload()">Try Again</button>
</div>
</body>
</html>`;
return new Response(html, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}