diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index 50c0ea3a..5f5a5862 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -6,6 +6,7 @@ import { ObsidianConfirm } from "./ObsidianConfirm"; import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; import { requestUrl, type RequestUrlParam } from "@/deps"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { createNativeRequestHeaders } from "./nativeRequestHeaders"; // All Services will be migrated to be based on Plain Services, not Injectable Services. // This is a migration step. @@ -16,43 +17,6 @@ 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; @@ -212,46 +176,8 @@ 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" && - (!enforceNoStore || name !== "no-store") - ); - }); - const cacheControlKey = cacheControlKeys[0] ?? "cache-control"; - for (const key of cacheControlKeys) delete transformedHeaders[key]; - transformedHeaders[cacheControlKey] = [ - ...cacheControlDirectives, - "no-cache", - ...(enforceNoStore ? ["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 transformedHeaders = createNativeRequestHeaders(optHeaders, method, cacheMode); const contentType = transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json"; const requestParam: RequestUrlParam = { diff --git a/src/modules/services/nativeRequestHeaders.ts b/src/modules/services/nativeRequestHeaders.ts new file mode 100644 index 00000000..2a125664 --- /dev/null +++ b/src/modules/services/nativeRequestHeaders.ts @@ -0,0 +1,100 @@ +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(); +} + +/** + * Whether native GET or HEAD requests must revalidate an explicitly bypassed cache. + * + * @param method - HTTP method, compared without regard to letter case. + * @param cacheMode - Effective Request or options cache mode. + * @returns True only for GET/HEAD with `no-cache` or `no-store`. + */ +export function shouldRevalidateNativeRequest(method: string, cacheMode: RequestCache | undefined): boolean { + return (cacheMode === "no-cache" || cacheMode === "no-store") && ["GET", "HEAD"].includes(method.toUpperCase()); +} + +function shouldRetainCacheControlDirective(directive: string, enforceNoStore: boolean): boolean { + const name = getDirectiveName(directive); + return !cacheReuseDirectives.has(name) && name !== "no-cache" && (!enforceNoStore || name !== "no-store"); +} + +/** + * Copy request headers and apply the native fetch adapter's header/cache policy. + * + * @param headers - Merged input headers; this function does not mutate them. + * @param method - HTTP method used to decide whether cache revalidation applies. + * @param cacheMode - Effective Request or options cache mode. + * @returns Fresh headers with the adapter's native-controlled headers removed. + * Revalidated GET/HEAD requests retain non-conflicting directives and receive + * `no-cache`; `no-store` also adds that directive. Existing header spelling is retained. + */ +export function createNativeRequestHeaders( + headers: Readonly>, + method: string, + cacheMode: RequestCache | undefined +): Record { + const transformedHeaders = { ...headers }; + // Delete headers that should not be sent by native fetch, + // they are controlled by the browser and may cause CORS preflight failure if sent manually. + delete transformedHeaders["host"]; + delete transformedHeaders["Host"]; + delete transformedHeaders["content-length"]; + delete transformedHeaders["Content-Length"]; + if (shouldRevalidateNativeRequest(method, cacheMode)) { + // requestUrl has no cache option; no-store alone can reuse an existing cached response. + const enforceNoStore = cacheMode === "no-store"; + const cacheControlKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "cache-control"); + const cacheControlDirectives = cacheControlKeys + .flatMap((key) => splitHeaderDirectives(transformedHeaders[key])) + .filter((directive) => shouldRetainCacheControlDirective(directive, enforceNoStore)); + const cacheControlKey = cacheControlKeys[0] ?? "cache-control"; + for (const key of cacheControlKeys) delete transformedHeaders[key]; + transformedHeaders[cacheControlKey] = [ + ...cacheControlDirectives, + "no-cache", + ...(enforceNoStore ? ["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(", "); + } + return transformedHeaders; +} diff --git a/src/modules/services/nativeRequestHeaders.unit.spec.ts b/src/modules/services/nativeRequestHeaders.unit.spec.ts new file mode 100644 index 00000000..96455ef5 --- /dev/null +++ b/src/modules/services/nativeRequestHeaders.unit.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { createNativeRequestHeaders, shouldRevalidateNativeRequest } from "./nativeRequestHeaders"; + +describe("native cache revalidation decision", () => { + it.each(["GET", "HEAD", "get", "head"])("revalidates explicit no-cache/no-store for %s", (method) => { + expect(shouldRevalidateNativeRequest(method, "no-cache")).toBe(true); + expect(shouldRevalidateNativeRequest(method, "no-store")).toBe(true); + }); + + it.each([undefined, "default", "reload", "force-cache", "only-if-cached"] as const)( + "does not strengthen cache mode %s", + (mode) => { + expect(shouldRevalidateNativeRequest("GET", mode)).toBe(false); + expect(shouldRevalidateNativeRequest("HEAD", mode)).toBe(false); + } + ); + + it.each(["POST", "PUT", "PATCH", "DELETE", "OPTIONS"])("does not revalidate %s", (method) => { + expect(shouldRevalidateNativeRequest(method, "no-cache")).toBe(false); + expect(shouldRevalidateNativeRequest(method, "no-store")).toBe(false); + }); +}); + +describe("native request header creation", () => { + it("copies input headers and removes the existing native-controlled header forms", () => { + const input = Object.freeze({ + host: "first.test", + Host: "second.test", + "content-length": "1", + "Content-Length": "2", + Authorization: "Bearer fixture", + "Cache-Control": "max-age=60", + }); + const result = createNativeRequestHeaders(input, "GET", "default"); + expect(result).toEqual({ Authorization: "Bearer fixture", "Cache-Control": "max-age=60" }); + expect(result).not.toBe(input); + expect(input.host).toBe("first.test"); + expect(input["Content-Length"]).toBe("2"); + }); + + it.each(["GET", "HEAD"])("creates no-store revalidation headers for %s", (method) => { + expect(createNativeRequestHeaders({}, method, "no-store")).toEqual({ + "cache-control": "no-cache, no-store", + pragma: "no-cache", + }); + }); + + it("preserves an existing no-store directive when enforcing no-cache", () => { + expect( + createNativeRequestHeaders( + { "Cache-Control": "no-store, max-age=3600", Pragma: "legacy" }, + "GET", + "no-cache" + ) + ).toEqual({ "Cache-Control": "no-store, no-cache", Pragma: "legacy, no-cache" }); + }); + + it("combines differently cased header fields and retains non-conflicting directives", () => { + const input = Object.freeze({ + "Cache-Control": 'Max-Age=99, no-transform, custom="a,b", No-Cache', + "cache-control": "max-stale=5, min-fresh=6, only-if-cached, NO-STORE, private", + Pragma: 'legacy="a,b", No-Cache', + pragma: "extension, no-cache", + }); + expect(createNativeRequestHeaders(input, "get", "no-store")).toEqual({ + "Cache-Control": 'no-transform, custom="a,b", private, no-cache, no-store', + Pragma: 'legacy="a,b", extension, no-cache', + }); + expect(input["cache-control"]).toContain("max-stale=5"); + }); + + it("does not split directive commas inside escaped quoted strings", () => { + const custom = String.raw`custom="a\"b,c"`; + expect(createNativeRequestHeaders({ "cache-control": `${custom}, max-age=1` }, "HEAD", "no-cache")).toEqual({ + "cache-control": `${custom}, no-cache`, + pragma: "no-cache", + }); + }); + + it("retains cache headers on a non-revalidated method", () => { + const headers = { "cache-control": "max-age=99", Pragma: "legacy" }; + expect(createNativeRequestHeaders(headers, "POST", "no-store")).toEqual(headers); + }); +});