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
284 lines
9.4 KiB
TypeScript
284 lines
9.4 KiB
TypeScript
import type { SpacePrimitives } from "./space_primitives.ts";
|
|
import { encodePageURI } from "@silverbulletmd/silverbullet/lib/ref";
|
|
import { flushCachesAndUnregisterServiceWorker } from "../../client/service_worker/util.ts";
|
|
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
|
|
import {
|
|
isNetworkError,
|
|
notFoundError,
|
|
offlineError,
|
|
pingTimeout,
|
|
wrongSpacePathError,
|
|
} from "@silverbulletmd/silverbullet/constants";
|
|
import { headersToFileMeta } from "../lib/util.ts";
|
|
|
|
const defaultFetchTimeout = 30000; // 30 seconds
|
|
|
|
// WebKit (Safari, WKWebView) strips custom response headers (X-Last-Modified,
|
|
// etc.) when it recognizes a file extension in the URL. Encoding the last dot
|
|
// as %2E prevents this; the server decodes it transparently.
|
|
const isWebKit = typeof navigator !== "undefined" &&
|
|
/AppleWebKit/.test(navigator.userAgent) &&
|
|
!/Chrome/.test(navigator.userAgent);
|
|
|
|
function encodeExtensionDot(url: string): string {
|
|
// Only encode the file extension dot, not dots in the path prefix (like /.fs).
|
|
// Manipulate the raw string — Safari's URL.pathname setter rejects %2E.
|
|
const fsIdx = url.indexOf("/.fs/");
|
|
if (fsIdx < 0) return url;
|
|
const afterFs = fsIdx + 5; // position after "/.fs/"
|
|
const filePart = url.substring(afterFs);
|
|
const qIdx = filePart.indexOf("?");
|
|
const path = qIdx >= 0 ? filePart.substring(0, qIdx) : filePart;
|
|
const rest = qIdx >= 0 ? filePart.substring(qIdx) : "";
|
|
const lastDot = path.lastIndexOf(".");
|
|
if (lastDot > path.lastIndexOf("/")) {
|
|
return url.substring(0, afterFs) +
|
|
path.substring(0, lastDot) + "%2E" + path.substring(lastDot + 1) + rest;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
export class HttpSpacePrimitives implements SpacePrimitives {
|
|
constructor(
|
|
readonly url: string,
|
|
readonly expectedSpacePath: string,
|
|
private authErrorCallback: (message: string, ...args: any[]) => void,
|
|
private bearerToken?: string,
|
|
) {}
|
|
|
|
public async authenticatedFetch(
|
|
url: string,
|
|
options: RequestInit,
|
|
fetchTimeout: number = defaultFetchTimeout,
|
|
): Promise<Response> {
|
|
if (!options.headers) {
|
|
options.headers = {};
|
|
}
|
|
options.headers = {
|
|
...options.headers,
|
|
"X-Sync-Mode": "true",
|
|
};
|
|
if (this.bearerToken) {
|
|
options.headers = {
|
|
...options.headers,
|
|
Authorization: `Bearer ${this.bearerToken}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
if (isWebKit) {
|
|
url = encodeExtensionDot(url);
|
|
}
|
|
if (fetchTimeout > 0) {
|
|
options.signal = AbortSignal.timeout(fetchTimeout);
|
|
}
|
|
options.redirect = "manual";
|
|
const result = await fetch(url, options);
|
|
if (result.status >= 500 && result.status < 600) {
|
|
throw offlineError;
|
|
}
|
|
const redirectHeader = result.headers.get("location");
|
|
|
|
if (result.type === "opaqueredirect" && !redirectHeader) {
|
|
console.log("Result", result, "for", url, JSON.stringify(options));
|
|
// This is a scenario where the server sent a redirect, but this redirect is not visible to the client, likely due to CORS
|
|
// The best we can do is to reload the page and hope that the server will redirect us to the correct location
|
|
this.authErrorCallback(
|
|
"You are not authenticated, reloading to reauthenticate",
|
|
"reload",
|
|
);
|
|
// console.log("Unregistering service workers", redirectHeader);
|
|
// await unregisterServiceWorkers();
|
|
// location.reload();
|
|
// Let's throw to avoid any further processing
|
|
throw Error("Not authenticated");
|
|
}
|
|
|
|
// console.log("Got response", result.status, result.statusText, result.url);
|
|
|
|
// Attempting to handle various authentication proxies
|
|
if (result.status >= 300 && result.status < 400) {
|
|
if (redirectHeader) {
|
|
// Got a redirect
|
|
this.authErrorCallback(
|
|
"Received an authentication redirect",
|
|
redirectHeader,
|
|
);
|
|
// location.href = redirectHeader;
|
|
throw new Error("Redirected");
|
|
} else {
|
|
console.error("Got a redirect status but no location header", result);
|
|
}
|
|
}
|
|
// Check for unauthorized status
|
|
if (result.status === 401 || result.status === 403) {
|
|
// If it came with a redirect header, we'll redirect to that URL
|
|
if (redirectHeader) {
|
|
console.log(
|
|
"Received unauthorized status and got a redirect via the API so will redirect to URL",
|
|
result.url,
|
|
);
|
|
this.authErrorCallback("You are not authenticated ", redirectHeader);
|
|
// location.href = redirectHeader;
|
|
throw new Error("Not authenticated");
|
|
} else {
|
|
// If not, let's reload
|
|
this.authErrorCallback(
|
|
"You are not authenticated, going to reload and hope that that kicks off authentication",
|
|
);
|
|
// location.reload();
|
|
throw new Error("Not authenticated");
|
|
}
|
|
}
|
|
return result;
|
|
} catch (e: any) {
|
|
// AbortSignal.timeout() throws a DOMException with name "TimeoutError".
|
|
// This is NOT an offline condition — the network may be fine, just slow.
|
|
if (e.name === "TimeoutError") {
|
|
console.warn("Request timed out for", url);
|
|
throw new Error(`Request timed out after ${fetchTimeout}ms`);
|
|
}
|
|
if (isNetworkError(e)) {
|
|
console.error("Got error fetching, throwing offline", url, e.message);
|
|
throw offlineError;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async fetchFileList(): Promise<FileMeta[]> {
|
|
const resp = await this.authenticatedFetch(this.url, {
|
|
method: "GET",
|
|
});
|
|
|
|
await this.validateSpacePathFromHeaders(resp);
|
|
return resp.json();
|
|
}
|
|
|
|
/**
|
|
* The /.fs file listing and /.ping endpoints both expose the currently exposed space path, if this doesn't match what the client expects, the client has to restart
|
|
*/
|
|
async validateSpacePathFromHeaders(resp: Response) {
|
|
if (
|
|
resp.status === 200 &&
|
|
this.expectedSpacePath &&
|
|
resp.headers.get("X-Space-Path") &&
|
|
resp.headers.get("X-Space-Path") !== this.expectedSpacePath
|
|
) {
|
|
console.log("Expected space path", this.expectedSpacePath);
|
|
console.log("Got space path", resp.headers.get("X-Space-Path"));
|
|
await flushCachesAndUnregisterServiceWorker();
|
|
this.authErrorCallback(wrongSpacePathError.message, "reload");
|
|
}
|
|
}
|
|
|
|
async readFile(path: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
|
|
const res = await this.authenticatedFetch(
|
|
`${this.url}/${encodePageURI(path)}`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
// This header won't trigger CORS preflight requests but can be interpreted on the server
|
|
Accept: "application/octet-stream",
|
|
},
|
|
},
|
|
);
|
|
if (res.status === 404) {
|
|
throw notFoundError;
|
|
}
|
|
return {
|
|
data: new Uint8Array(await res.arrayBuffer()),
|
|
meta: headersToFileMeta(path, res.headers)!,
|
|
};
|
|
}
|
|
|
|
async writeFile(
|
|
path: string,
|
|
data: Uint8Array,
|
|
meta?: FileMeta,
|
|
): Promise<FileMeta> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/octet-stream",
|
|
};
|
|
if (meta) {
|
|
headers["X-Created"] = `${meta.created}`;
|
|
headers["X-Last-Modified"] = `${meta.lastModified}`;
|
|
headers["X-Perm"] = `${meta.perm}`;
|
|
}
|
|
|
|
const res = await this.authenticatedFetch(
|
|
`${this.url}/${encodePageURI(path)}`,
|
|
{
|
|
method: "PUT",
|
|
headers,
|
|
// Casting to any due to TypeScript fetch type limitations
|
|
body: data as any,
|
|
},
|
|
0, // No timeout for uploads — transfer time depends on file size and connection speed
|
|
);
|
|
return headersToFileMeta(path, res.headers)!;
|
|
}
|
|
|
|
async deleteFile(path: string): Promise<void> {
|
|
const req = await this.authenticatedFetch(
|
|
`${this.url}/${encodePageURI(path)}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
);
|
|
if (req.status !== 200) {
|
|
throw Error(`Failed to delete file: ${req.statusText}`);
|
|
}
|
|
}
|
|
|
|
async getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
|
|
const res = await this.authenticatedFetch(
|
|
`${this.url}/${encodePageURI(path)}`,
|
|
// This used to use HEAD, but it seems that Safari on iOS is blocking cookies/credentials to be sent along with HEAD requests
|
|
// so we'll use GET instead with a magic header which the server may or may not use to omit the body.
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
"X-Get-Meta": "true",
|
|
...(observing ? { "X-Observing": "true" } : {}),
|
|
},
|
|
},
|
|
);
|
|
if (res.status === 404) {
|
|
throw notFoundError;
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to get file meta: ${res.statusText}`);
|
|
}
|
|
return headersToFileMeta(path, res.headers)!;
|
|
}
|
|
|
|
// If not: throws an error or invokes a redirect
|
|
async ping(): Promise<string | undefined> {
|
|
const parentEndpoint = `${this.url.split("/").slice(0, -1).join("/")}/.ping`;
|
|
const resp = await this.authenticatedFetch(
|
|
parentEndpoint,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
},
|
|
pingTimeout,
|
|
);
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`Ping failed: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
|
|
await this.validateSpacePathFromHeaders(resp);
|
|
|
|
const serverVersion = resp.headers.get("X-Server-Version") ?? undefined;
|
|
|
|
// Consume the response body to avoid leaks
|
|
await resp.text();
|
|
|
|
return serverVersion;
|
|
}
|
|
}
|