Files
plainleaf/client/spaces/http_space_primitives.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

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;
}
}