mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Merge completed fixes before shared runtime integration
This commit is contained in:
commit
9222852834
9 files changed
+233
-34
No files matched your search
@@ -16,6 +16,43 @@ declare module "obsidian" {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
|
||||
|
||||
function splitHeaderDirectives(value: string): string[] {
|
||||
const directives: string[] = [];
|
||||
let start = 0;
|
||||
let inQuotes = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (inQuotes && escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (inQuotes && character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (character === "," && !inQuotes) {
|
||||
const directive = value.slice(start, index).trim();
|
||||
if (directive) directives.push(directive);
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const lastDirective = value.slice(start).trim();
|
||||
if (lastDirective) directives.push(lastDirective);
|
||||
return directives;
|
||||
}
|
||||
|
||||
function getDirectiveName(directive: string): string {
|
||||
const equalsIndex = directive.indexOf("=");
|
||||
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||
_customHandler: ObsHttpHandler | undefined;
|
||||
_confirmInstance: Confirm;
|
||||
@@ -182,6 +219,30 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
|
||||
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
|
||||
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
|
||||
const cacheControlKeys = Object.keys(transformedHeaders).filter(
|
||||
(key) => key.toLowerCase() === "cache-control"
|
||||
);
|
||||
const cacheControlDirectives = cacheControlKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => {
|
||||
const name = getDirectiveName(directive);
|
||||
return !cacheReuseDirectives.has(name) && name !== "no-cache" && name !== "no-store";
|
||||
});
|
||||
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
|
||||
for (const key of cacheControlKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[cacheControlKey] = [...cacheControlDirectives, "no-cache", "no-store"].join(", ");
|
||||
|
||||
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
|
||||
const pragmaDirectives = pragmaKeys
|
||||
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
|
||||
.filter((directive) => getDirectiveName(directive) !== "no-cache");
|
||||
const pragmaKey = pragmaKeys[0] ?? "pragma";
|
||||
for (const key of pragmaKeys) delete transformedHeaders[key];
|
||||
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
|
||||
}
|
||||
const contentType =
|
||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||
const requestParam: RequestUrlParam = {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RequestUrlParam } from "@/deps";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestUrl: vi.fn<
|
||||
(request: RequestUrlParam) => Promise<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
}>
|
||||
>(),
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
@@ -8,12 +16,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
@@ -36,6 +44,8 @@ function createService(workspace: Record<string, unknown>, isMobile = false): Ob
|
||||
beforeEach(() => {
|
||||
mocks.platform.isMobile = false;
|
||||
vi.clearAllMocks();
|
||||
mocks.requestUrl.mockReset();
|
||||
mocks.requestUrl.mockResolvedValue({ status: 200, headers: {}, arrayBuffer: new ArrayBuffer(0) });
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
@@ -65,3 +75,115 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
const url = "https://couchdb.example.test/vault/f%3Ametadata";
|
||||
|
||||
function sentHeaders(): Headers {
|
||||
expect(mocks.requestUrl).toHaveBeenCalledOnce();
|
||||
return new Headers(mocks.requestUrl.mock.calls[0][0].headers);
|
||||
}
|
||||
|
||||
it.each(["GET", "HEAD", "get", "head"])("bypasses cached responses for an explicit no-store %s", async (method) => {
|
||||
const headers = {
|
||||
Authorization: "Basic fixture",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"cAcHe-CoNtRoL": "max-stale",
|
||||
Pragma: "cache",
|
||||
};
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("public, no-cache, no-store");
|
||||
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
|
||||
expect(sentHeaders().get("authorization")).toBe("Basic fixture");
|
||||
expect(headers["Cache-Control"]).toBe("public, max-age=3600");
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
|
||||
const headers = {
|
||||
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
|
||||
"cAcHe-CoNtRoL": 'max-stale=30, min-fresh=5, only-if-cached, custom-two="max-age=1, only-if-cached"',
|
||||
Pragma: 'legacy="a,b", no-cache',
|
||||
"X-Custom-Header": "preserved",
|
||||
};
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache: "no-store", headers });
|
||||
|
||||
const sent = mocks.requestUrl.mock.calls[0][0].headers ?? {};
|
||||
expect(sent["Cache-Control"]).toBe(
|
||||
'no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache, no-store'
|
||||
);
|
||||
expect(sent["cAcHe-CoNtRoL"]).toBeUndefined();
|
||||
expect(sent.Pragma).toBe('legacy="a,b", no-cache');
|
||||
expect(sent["X-Custom-Header"]).toBe("preserved");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
});
|
||||
|
||||
it("uses the Request cache mode when the options do not override it", async () => {
|
||||
const request = new Request(url, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
headers: { "X-Fixture": "request" },
|
||||
});
|
||||
const headers = new Headers({ "X-Other": "options" });
|
||||
|
||||
await createService({}).nativeFetch(request, { headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
|
||||
expect(sentHeaders().get("pragma")).toBe("no-cache");
|
||||
expect(sentHeaders().get("x-fixture")).toBe("request");
|
||||
expect(sentHeaders().get("x-other")).toBe("options");
|
||||
expect(headers.has("cache-control")).toBe(false);
|
||||
expect(request.headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the options request no-store for an otherwise cacheable Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
|
||||
});
|
||||
|
||||
it("lets an explicit default cache mode override a no-store Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { cache: "default" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
|
||||
"preserves caller headers for cache mode %s",
|
||||
async (cache) => {
|
||||
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache, headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0].headers).toEqual(headers);
|
||||
}
|
||||
);
|
||||
|
||||
it("does not add cache directives to an ordinary request", async () => {
|
||||
await createService({}).nativeFetch(url);
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "no-transform" };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the effective method from the options", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { method: "POST" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user