import { fsEndpoint } from "../spaces/constants.ts"; import { decodePageURI } from "@silverbulletmd/silverbullet/lib/ref"; import type { SpacePrimitives } from "../spaces/space_primitives.ts"; import { fileMetaToHeaders, headersToFileMeta } from "../lib/util.ts"; import { notFoundError, offlineError, pingInterval, } from "@silverbulletmd/silverbullet/constants"; import type { SyncEngine } from "./sync_engine.ts"; import { EventEmitter } from "../plugos/event.ts"; const alwaysProxy = [ "/.auth", "/.shell", "/.logout", "/.config", "/.logs", "/.proxy", ]; export type ProxyRouterEvents = { // Use case: the user likely has this file open in the editor, so it's good to prioritize syncing it observedRequest: (path: string) => void; // Use case: client showing the "yellow bar" indicating not being online onlineStatusUpdated: (isOnline: boolean) => void; }; /** * Implements a service worker level HTTP proxy (fetch requests) that serves /.fs calls locally for synced spaces */ export class ProxyRouter extends EventEmitter { private fullSyncConfirmed = false; online = true; localSpacePrimitives?: SpacePrimitives; syncEngine?: SyncEngine; constructor( private basePathName: string, private baseURI: string, private precacheFiles: Record, ) { super(); // Actively check if we're online by pinging the server void this.checkOnline(); setInterval(() => { void this.checkOnline(); }, pingInterval); } /** * Called as soon the service worker is configured, and the service worker is ready to start serving requests. */ configure(syncEngine: SyncEngine) { this.localSpacePrimitives = syncEngine.local; this.syncEngine = syncEngine; syncEngine.on({ spaceSyncComplete: () => { if (!this.fullSyncConfirmed) { this.fullSyncConfirmed = true; console.log( "First full sync confirmed, will now start serving requests locally", ); } }, }); } /** * Stops service worker operation only to be continued after reconfiguration */ reset() { console.log("Shutting down proxy router and linked components"); if (this.syncEngine) { this.syncEngine.stop(); this.syncEngine = undefined; } } async checkOnline() { if (this.syncEngine) { try { const serverVersion = await this.syncEngine.remote.ping(); // If the ping is successful, we are online this.online = true; if (serverVersion) { const clients = await (self as any).clients.matchAll(); for (const client of clients) { client.postMessage({ type: "server-version", serverVersion, }); } } } catch { // Otherwise we're not this.online = false; } finally { void this.emit("onlineStatusUpdated", this.online); } } else { console.info( "Sync engine not initialized yet, cannot check online status", ); } } /** * Handles /.fs fetch events from the service worker. * @param event FetchEvent from the service worker */ public onFetch(event: any) { const url = new URL(event.request.url); const pathname = url.pathname.substring(this.basePathName.length); //url.pathname with any URL prefix removed // Use the custom cache key if available, otherwise use the request URL const cacheKey = this.precacheFiles[pathname] || event.request.url; event.respondWith( (async () => { const request = event.request; const requestUrl = new URL(request.url); try { // Are we fetching a URL from the same origin as the app? If not, we don't handle it and pass it on if (!requestUrl.href.startsWith(this.baseURI)) { return fetch(request); } // Try the static (client) file cache first const cachedResponse = await caches.match(cacheKey); // Return the cached response if found if (cachedResponse) { return cachedResponse; } //requestUrl.pathname without with any URL prefix removed const pathname = requestUrl.pathname.substring( this.basePathName.length, ); if ( // Not yet configured -> Proxy !this.localSpacePrimitives || !this.syncEngine || // Not fully synced but online -> Proxy (!this.fullSyncConfirmed && this.online) || // A path we always need to proxy -> Proxy alwaysProxy.find((prefix) => pathname.startsWith(prefix)) ) { try { return await fetch(request); } catch (e: any) { if (e.message === "Offline") { console.info( "Detected offline, marking offline and falling through", ); this.online = false; } else { throw e; } } } // We are now in a state we're configured and either a full sync cycle has complete (since boot) OR we're offline if ( pathname.startsWith(fsEndpoint) && pathname.endsWith(".md") && !request.headers.has("X-Sync-Mode") ) { // This handles the case of ending up with a .md URL in the browser address bar (likely due to a auth proxy redirect) return Response.redirect( `${pathname.slice(fsEndpoint.length, -3)}`, ); } else if (pathname.startsWith(fsEndpoint)) { // Handle /.fs file system APIs return this.handleRequest(pathname, request); } else { // Fallback to the app shell for all other requests (SPA) return ( (await caches.match(this.precacheFiles["/"])) || fetch(request) ); } } catch (e: any) { console.warn("Fetch failed for", request.url, "error:", e.message); this.online = false; return new Response(offlineError.message, { status: 503, // Service Unavailable }); } })(), ); } handleRequest(pathname: string, request: Request): Promise { const path = decodePageURI(pathname.slice(fsEndpoint.length + 1)); switch (request.method) { case "GET": { if (!path) { // .fs GET return this.handleFileListing(); } else { // .fs/* GET return this.handleGet(path, request); } } case "PUT": { return this.handlePut(path, request); } case "DELETE": { return this.handleDelete(path, request); } default: { console.log("Unhandled method", request.method, "proxying to server"); return fetch(request); } } } /** * Shortcut to nonSyncedFiles kept in snapshot */ get nonSyncedFiles() { return this.syncEngine!.snapshot.nonSyncedFiles; } async handleFileListing(): Promise { if (!this.syncEngine || !this.localSpacePrimitives) { throw new Error("This should not happen"); } const files = await this.localSpacePrimitives.fetchFileList(); // Now augment this with non-synced file metadata for (const nonSyncedFile of this.nonSyncedFiles.values()) { const existingFile = files.find( (file) => file.name === nonSyncedFile.name, ); if (!existingFile) { files.push(nonSyncedFile); } } return new Response(JSON.stringify(files), { headers: { "Content-Type": "application/json", }, }); } async handleGet(path: string, request: Request): Promise { if (!this.syncEngine || !this.localSpacePrimitives) { throw new Error("This should not happen"); } try { if (request.headers.has("x-get-meta")) { // Requesting only file meta const meta = await this.localSpacePrimitives.getFileMeta(path); if (request.headers.has("x-observing")) { setTimeout(() => { // Next tick void this.emit("observedRequest", path); }); } return new Response(null, { headers: fileMetaToHeaders(meta), }); } else { const { meta, data } = await this.localSpacePrimitives.readFile(path); return new Response(data as any, { headers: fileMetaToHeaders(meta), }); } } catch (err: any) { if (err.message === notFoundError.message && this.online) { console.info("No local copy of", path, "proxying to server"); // Not found locally, but we're online, so let's try the server return fetch(request); } else if (err.message === notFoundError.message) { console.warn( "No local copy of", path, "and offline, so will 404 on this one", ); // We're not online so let's assume the file indeed doesn't exist // TODO: What could be nice here is to check if this is a nonSyncedFile and if so serve some sort of offline placeholder return new Response(notFoundError.message, { status: 404, }); } console.error("Error reading", path, err.message); return new Response(err.message, { status: 500, }); } } async handlePut(path: string, request: Request): Promise { if (!this.syncEngine || !this.localSpacePrimitives) { throw new Error("This should not happen"); } try { // console.log("Doing a write for", path); if (!this.syncEngine.isSyncCandidate(path) && this.online) { console.log("Handling file write for non-synced file", path); // Writing a non-synced file while being online // Proxy the request const resp = await fetch(request); // Update the nonSynced snapshot in place for later file listing consistency this.nonSyncedFiles.set(path, headersToFileMeta(path, resp.headers)!); return resp; } else { // Synced file const body = await request.arrayBuffer(); // console.log("Handling file write", path, body.byteLength); const meta = await this.localSpacePrimitives.writeFile( path, new Uint8Array(body), // Note: there are going to be many cases where no meta is supplied in the request, this is ok, in that case this argument will be undefined headersToFileMeta(path, request.headers), ); // Attempt immediate sync try { const operations = await this.syncEngine.syncSingleFile(path); if (operations === -1) { console.info("File sync delayed for", path); // Sync was in progress, will sync later return new Response("Delayed", { status: 202, headers: fileMetaToHeaders(meta), }); } } catch (e: any) { console.error( "File sync delayed for", path, "due to error", e.message, ); // Sync failed (could be offline or other reason) return new Response(e.message, { status: 202, headers: fileMetaToHeaders(meta), }); } return new Response("OK", { status: 200, headers: fileMetaToHeaders(meta), }); } } catch (e: any) { console.error("Error writing", path, e.message); return new Response(e.message, { status: 500, }); } } async handleDelete(path: string, request: Request): Promise { if (!this.syncEngine || !this.localSpacePrimitives) { throw new Error("This should not happen"); } try { if (!this.syncEngine.isSyncCandidate(path)) { console.log("Handling file delete for non-synced file", path); this.nonSyncedFiles.delete(path); // Proxy the request return fetch(request); } // console.log("Handling file delete", path); await this.localSpacePrimitives.deleteFile(path); return new Response("OK", { status: 200, }); } catch (e: any) { console.error("Error deleting", path, e.message); if (e.message === notFoundError.message) { return new Response(notFoundError.message, { status: 404, }); } return new Response(e.message, { status: 500, }); } } }