From 74d9f80761c00abc6b410c9b58d2ae9eafda710a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 1 Nov 2026 10:38:35 +0000 Subject: [PATCH] Preserve custom headers for no-store requests --- src/modules/services/ObsidianAPIService.ts | 64 +++++++++++++++++-- .../services/ObsidianAPIService.unit.spec.ts | 25 +++++++- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index b319ac43..ebf4e011 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -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 { _customHandler: ObsHttpHandler | undefined; _confirmInstance: Confirm; @@ -185,13 +222,26 @@ export class ObsidianAPIService extends InjectableAPIService 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"; diff --git a/src/modules/services/ObsidianAPIService.unit.spec.ts b/src/modules/services/ObsidianAPIService.unit.spec.ts index 7ad012c7..10f13081 100644 --- a/src/modules/services/ObsidianAPIService.unit.spec.ts +++ b/src/modules/services/ObsidianAPIService.unit.spec.ts @@ -94,13 +94,34 @@ describe("ObsidianAPIService.nativeFetch cache control", () => { await createService({}).nativeFetch(url, { method, cache: "no-store", headers }); - expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store"); - expect(sentHeaders().get("pragma")).toBe("no-cache"); + 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",