Files
plainleaf/client/service_worker/proxy_router.ts
T
Zef Hemel 822997fd5e Client available upgrade notification
If the server is updated, but the client doesn't match the server
version, a notification will appear instructing the user to reload.
2026-03-10 12:05:33 +01:00

391 lines
12 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 {
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<ProxyRouterEvents> {
private fullSyncConfirmed = false;
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;
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<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
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<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,
});
}
}
}