Files
plainleaf/client/service_worker/proxy_router.ts
T
Zef Hemel 0627e7cb49 Fixes #1931 and #1923 (hopefully)
The service worker's proxy fallback only matched the wrapped "Offline"
error from HttpSpacePrimitives, but raw fetch() throws browser-native
errors like
"Failed to fetch" (Chrome), "NetworkError..." (Firefox), or "Load
failed"
(Safari). This caused a hard 503 instead of falling through to serve
local
data. Additionally, the `fullSyncConfirmed` flag reset on every service
worker
restart, forcing unnecessary proxying even when synced data existed in
IndexedDB.

In this commit:
- Use `isNetworkError()` in proxy_router.ts to catch all browser network
  errors
  and fall through to local serving instead of returning 503
* Recover `fullSyncConfirmed` from persisted sync snapshot on SW
  restart, so
  previously synced spaces serve locally immediately
* Adds e2e tests trying to emulate this behavior and prevent regressions
2026-04-14 08:01:43 +02:00

437 lines
15 KiB
TypeScript

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 {
isNetworkError,
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.
*
* Offline serving strategy:
* - Static client assets (HTML, JS, CSS) are always served from the pre-cache (populated on SW install).
* - File system (/.fs) requests are served locally from IndexedDB once a full sync has been confirmed.
* - Before a full sync is confirmed, requests are proxied to the server. If the server is unreachable
* (network error), we fall through to serve locally so the app still works offline.
* - `fullSyncConfirmed` is restored from the persisted sync snapshot on SW restart, so previously
* synced spaces serve locally immediately without needing a new sync cycle.
*/
export class ProxyRouter extends EventEmitter<ProxyRouterEvents> {
// Tracks whether at least one full sync cycle has completed. Once true, /.fs
// requests are served from local IndexedDB instead of being proxied. On SW
// restart this is recovered from the persisted sync snapshot (see configure()).
private fullSyncConfirmed = false;
// Assumed online until checkOnline() determines otherwise. In airplane mode
// the ping fails instantly; when the server is down but network is available
// the ping times out after `pingTimeout` ms (see constants.ts).
online = true;
localSpacePrimitives?: SpacePrimitives;
syncEngine?: SyncEngine;
constructor(
private basePathName: string,
private baseURI: string,
private precacheFiles: Record<string, string>,
) {
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;
// If a previous sync snapshot exists with data, we can serve locally
// immediately instead of waiting for a new sync cycle to complete.
// This survives service worker restarts because the snapshot is
// persisted in IndexedDB.
if (syncEngine.snapshot.files.size > 0) {
this.fullSyncConfirmed = true;
console.log(
"Previous sync snapshot found with",
syncEngine.snapshot.files.size,
"files, serving requests locally immediately",
);
}
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,
);
// Paths that can never be served locally (auth, shell, etc.) — always proxy.
// If the proxy fails, there's no local fallback, so let the outer catch
// return 503.
if (alwaysProxy.find((prefix) => pathname.startsWith(prefix))) {
return await fetch(request);
}
// Not yet configured (no sync engine / local storage) — must proxy.
// No local data exists to fall back to.
if (!this.localSpacePrimitives || !this.syncEngine) {
return await fetch(request);
}
// Configured but no full sync confirmed yet and we think we're online —
// try the server first. If it fails with a network error, fall through to
// serve from local data (which may exist from a previous session's
// snapshot). fullSyncConfirmed is recovered from the persisted snapshot on
// SW restart (see configure()), so this condition only applies when no
// previous sync data exists at all.
if (!this.fullSyncConfirmed && this.online) {
try {
return await fetch(request);
} catch (e: any) {
// When the proxy fetch fails due to a network error, mark offline
// and fall through to serve from local data instead of returning
// a hard 503. We check for both the wrapped "Offline" error (from
// HttpSpacePrimitives) and raw browser network errors (e.g.
// "Failed to fetch" in Chrome, "NetworkError..." in Firefox,
// "Load failed" in Safari) via isNetworkError().
if (e.message === "Offline" || isNetworkError(e)) {
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 completed (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<Response> {
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<Response> {
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
const localFileNames = new Set(files.map((f) => f.name));
for (const nonSyncedFile of this.nonSyncedFiles.values()) {
if (!localFileNames.has(nonSyncedFile.name)) {
files.push(nonSyncedFile);
}
}
return new Response(JSON.stringify(files), {
headers: {
"Content-Type": "application/json",
},
});
}
async handleGet(path: string, request: Request): Promise<Response> {
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<Response> {
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<Response> {
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,
});
}
}
}