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 type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||||
import { requestUrl, type RequestUrlParam } from "@/deps";
|
import { requestUrl, type RequestUrlParam } from "@/deps";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
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.
|
// All Services will be migrated to be based on Plain Services, not Injectable Services.
|
||||||
// This is a migration step.
|
// 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> {
|
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||||
_customHandler: ObsHttpHandler | undefined;
|
_customHandler: ObsHttpHandler | undefined;
|
||||||
_confirmInstance: Confirm;
|
_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);
|
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
|
||||||
if (["no-cache", "no-store"].includes(cacheMode ?? "") && ["GET", "HEAD"].includes(method.toUpperCase())) {
|
const transformedHeaders = createNativeRequestHeaders(optHeaders, 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) => {
|
|
||||||
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 contentType =
|
const contentType =
|
||||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||||
const requestParam: RequestUrlParam = {
|
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