mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Extract pure native request header helpers
This commit is contained in:
1 parent
8c51d66adf
commit
0e891998fd
3 files changed
+186
-76
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user