Extract pure native request header helpers

This commit is contained in:
vorotamoroz committed 2026-11-04 02:09:00 +00:00
1 parent 8c51d66adf
commit 0e891998fd
3 files changed
+186 -76

No files matched your search

+2 -76
View File
@@ -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<ObsidianServiceContext> {
_customHandler: ObsHttpHandler | undefined;
_confirmInstance: Confirm;
@@ -212,46 +176,8 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
}
}
}
const transformedHeaders = { ...optHeaders };
// 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"];
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
if (["no-cache", "no-store"].includes(cacheMode ?? "") && ["GET", "HEAD"].includes(method.toUpperCase())) {
// 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) => {
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 = {
@@ -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<Record<string, string>>,
method: string,
cacheMode: RequestCache | undefined
): Record<string, string> {
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;
}
@@ -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);
});
});