mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 08:18:18 +00:00
Merge CouchDB cache compatibility changes into Journal recovery
This commit is contained in:
commit
0ded0bcea5
16 files changed
+735
-85
No files matched your search
@@ -3167,7 +3167,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
"zh-tw": "若啟用「自動啟動 P2P 連線」,外掛啟動時會自動建立 P2P 連線。",
|
||||
},
|
||||
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": {
|
||||
def: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
|
||||
def: "When enabled, chunks are split on the UI thread (the previous behaviour).",
|
||||
es: "Si se desactiva, los chunks se dividen en el hilo de UI (comportamiento anterior)",
|
||||
fr: "Si désactivé, les fragments seront découpés sur le thread UI (comportement précédent).",
|
||||
he: "אם מכובה, נתחים יפוצלו בשרשור ממשק המשתמש (התנהגות קודמת).",
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
"I understand the risks and will proceed without a backup.": "I understand the risks and will proceed without a backup.",
|
||||
"I Understand, Overwrite Server": "I Understand, Overwrite Server",
|
||||
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.",
|
||||
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
|
||||
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "When enabled, chunks are split on the UI thread (the previous behaviour).",
|
||||
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.",
|
||||
"If enabled, changes will be automatically broadcasted to all connected peers. Notified peers will start fetching the changes.": "If enabled, changes will be automatically broadcasted to all connected peers. Notified peers will start fetching the changes.",
|
||||
"If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.",
|
||||
|
||||
@@ -437,8 +437,7 @@ If "Auto Start P2P Connection" is enabled, the P2P connection will be started au
|
||||
If "Auto Start P2P Connection" is enabled, the P2P connection will be
|
||||
started automatically when the plug-in launches.
|
||||
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).:
|
||||
If disabled(toggled), chunks will be split on the UI thread (Previous
|
||||
behaviour).
|
||||
When enabled, chunks are split on the UI thread (the previous behaviour).
|
||||
If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.:
|
||||
If enabled per-filed efficient customization sync will be used. We need a
|
||||
small migration when enabling this. And all devices should be updated to
|
||||
|
||||
@@ -82,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
|
||||
authentication,
|
||||
projectHeaders(settings.couchDB_CustomHeaders),
|
||||
settings.useRequestAPI,
|
||||
settings.useCouchDBCacheControlHeadersOnly === true,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
usesEncryptedInternalMetadata(settings),
|
||||
|
||||
@@ -7,6 +7,17 @@ import {
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
|
||||
describe("active Replicator configuration identity", () => {
|
||||
it("treats an absent headers-only CouchDB cache switch as off and leaves Object Storage unaffected", () => {
|
||||
const absent = configuredSettings();
|
||||
Reflect.deleteProperty(absent, "useCouchDBCacheControlHeadersOnly");
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...absent, useCouchDBCacheControlHeadersOnly: false })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(absent)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity({ ...absent, useCouchDBCacheControlHeadersOnly: true })).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(absent)
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a connection when the path identity mode changes", () => {
|
||||
const previous = configuredSettings({ pathNormalizationVersion: 0 });
|
||||
const next = { ...previous, pathNormalizationVersion: 1 as const };
|
||||
@@ -73,6 +84,7 @@ describe("active Replicator configuration identity", () => {
|
||||
["couchDB_PASSWORD", "secret-b"],
|
||||
["couchDB_CustomHeaders", "X-First: changed"],
|
||||
["useRequestAPI", true],
|
||||
["useCouchDBCacheControlHeadersOnly", true],
|
||||
["disableRequestURI", true],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
|
||||
@@ -28,6 +28,7 @@ export function panePowerUsers(
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
|
||||
new Setting(paneEl).autoWireToggle("useCouchDBCacheControlHeadersOnly", { onUpdate: this.onlyOnCouchDB });
|
||||
});
|
||||
void addPanel(paneEl, $msg("Configuration Encryption")).then((paneEl) => {
|
||||
const passphrase_options: Record<ConfigPassphraseStore, string> = {
|
||||
|
||||
@@ -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,37 +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 (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
|
||||
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
|
||||
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" && 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 transformedHeaders = createNativeRequestHeaders(optHeaders, method, cacheMode);
|
||||
const contentType =
|
||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||
const requestParam: RequestUrlParam = {
|
||||
|
||||
@@ -101,6 +101,35 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it.each(["GET", "HEAD", "get", "head"])("revalidates an explicit no-cache %s request", async (method) => {
|
||||
const headers = {
|
||||
"Cache-Control": 'public, 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: "cache",
|
||||
};
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-cache", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe(
|
||||
'public, no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache'
|
||||
);
|
||||
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
|
||||
});
|
||||
|
||||
it("preserves an explicit no-store directive when enforcing no-cache", async () => {
|
||||
const headers = { "Cache-Control": "no-store, max-age=3600", Pragma: "legacy" };
|
||||
const originalHeaders = { ...headers };
|
||||
|
||||
await createService({}).nativeFetch(url, { cache: "no-cache", headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-store, no-cache");
|
||||
expect(sentHeaders().get("pragma")).toBe("legacy, no-cache");
|
||||
expect(headers).toEqual(originalHeaders);
|
||||
});
|
||||
|
||||
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
|
||||
const headers = {
|
||||
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
|
||||
@@ -140,6 +169,30 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(request.headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a Request no-cache mode when the options do not override it", async () => {
|
||||
const request = new Request(url, {
|
||||
method: "HEAD",
|
||||
cache: "no-cache",
|
||||
headers: { "Cache-Control": "max-age=60", "X-Fixture": "request" },
|
||||
});
|
||||
const headers = new Headers({ "X-Other": "options" });
|
||||
|
||||
await createService({}).nativeFetch(request, { headers });
|
||||
|
||||
expect(sentHeaders().get("cache-control")).toBe("no-cache");
|
||||
expect(sentHeaders().get("x-fixture")).toBe("request");
|
||||
expect(sentHeaders().get("x-other")).toBe("options");
|
||||
expect(request.headers.get("cache-control")).toBe("max-age=60");
|
||||
expect(headers.has("cache-control")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets an explicit default cache mode override a no-cache Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "no-cache" }), { cache: "default" });
|
||||
|
||||
expect(sentHeaders().has("cache-control")).toBe(false);
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the options request no-store for an otherwise cacheable Request", async () => {
|
||||
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
|
||||
|
||||
@@ -153,7 +206,7 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
|
||||
it.each([undefined, "default", "reload", "force-cache"] as const)(
|
||||
"preserves caller headers for cache mode %s",
|
||||
async (cache) => {
|
||||
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
|
||||
@@ -171,6 +224,14 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-cache %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "max-age=60, no-transform", Pragma: "fixture" };
|
||||
|
||||
await createService({}).nativeFetch(url, { method, cache: "no-cache", headers });
|
||||
|
||||
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
|
||||
});
|
||||
|
||||
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
|
||||
const headers = { "Cache-Control": "no-transform" };
|
||||
|
||||
|
||||
@@ -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,98 @@
|
||||
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.each([
|
||||
["", "no-cache"],
|
||||
[", , ,", "no-cache"],
|
||||
[" , no-transform , , ", "no-transform, no-cache"],
|
||||
])("ignores empty header-list members in %j", (value, expected) => {
|
||||
const headers = Object.freeze({ "Cache-Control": value, Pragma: " , , " });
|
||||
|
||||
expect(createNativeRequestHeaders(headers, "GET", "no-cache")).toEqual({
|
||||
"Cache-Control": expected,
|
||||
Pragma: "no-cache",
|
||||
});
|
||||
expect(headers["Cache-Control"]).toBe(value);
|
||||
});
|
||||
|
||||
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