@decocms/tanstack 7.78.1 → 7.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/routes/cmsRoute.ts +7 -24
- package/src/routes/requestSingleFlight.test.ts +62 -0
- package/src/routes/requestSingleFlight.ts +17 -0
- package/src/sdk/kvHydration.ts +55 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +126 -0
- package/src/sdk/workerEntry.ts +58 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.79.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"lint:unused": "knip"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@decocms/blocks": "7.
|
|
31
|
-
"@decocms/blocks-admin": "7.
|
|
32
|
-
"@decocms/blocks-cli": "7.
|
|
30
|
+
"@decocms/blocks": "7.79.0",
|
|
31
|
+
"@decocms/blocks-admin": "7.79.0",
|
|
32
|
+
"@decocms/blocks-cli": "7.79.0",
|
|
33
33
|
"@deco-cx/warp-node": "^0.3.16",
|
|
34
34
|
"fast-json-patch": "^3.1.0",
|
|
35
35
|
"ws": "^8.18.0"
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
} from "@tanstack/react-start/server";
|
|
70
70
|
import { createElement } from "react";
|
|
71
71
|
import { derivePageUrl, isClientNavigation } from "./pageUrl";
|
|
72
|
+
import { createRequestSingleFlight } from "./requestSingleFlight";
|
|
72
73
|
import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
|
|
73
74
|
import type { SiteGlobalRef } from "./withSiteGlobals";
|
|
74
75
|
|
|
@@ -92,7 +93,7 @@ export function setSectionChunkMap(map: Record<string, string>): void {
|
|
|
92
93
|
// ---------------------------------------------------------------------------
|
|
93
94
|
|
|
94
95
|
type PageResult = Awaited<ReturnType<typeof loadCmsPageInternal>>;
|
|
95
|
-
const
|
|
96
|
+
const loadPageForRequest = createRequestSingleFlight<PageResult>();
|
|
96
97
|
|
|
97
98
|
/** Same shape as `resolveSiteGlobals()`'s empty result — used when `resolveGlobals: false`. */
|
|
98
99
|
const EMPTY_GLOBALS: { resolvedSections: ResolvedSection[]; rawRefs: SiteGlobalRef[] } = {
|
|
@@ -337,33 +338,15 @@ export const loadCmsPage = createServerFn({ method: "GET" })
|
|
|
337
338
|
.handler(async (ctx) => {
|
|
338
339
|
const { path: fullPath, resolveGlobals } = parseLoadCmsPageInput(ctx.data);
|
|
339
340
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// SSR and client-nav produce the same eager/deferred split now, so the
|
|
345
|
-
// original reason for the `__nav:` bucket — a client-nav request must never
|
|
346
|
-
// inherit an SSR response's deferredSections (#277) — no longer applies. It
|
|
347
|
-
// is kept anyway, because this map is module-global and the payload it
|
|
348
|
-
// shares carries `pageUrl`, `flags`, and `device` derived from whichever
|
|
349
|
-
// request won the race. That cross-request bleed is pre-existing and wider
|
|
350
|
-
// than this bucket (two concurrent same-path requests already collide on
|
|
351
|
-
// cookies/UA/geo), but SSR and client-nav are exactly the pair whose
|
|
352
|
-
// `derivePageUrl` inputs differ most, so collapsing them would widen a
|
|
353
|
-
// known hole for no gain. `resolveGlobals` is part of the key for the same
|
|
354
|
-
// reason: a `resolveGlobals: false` request must not share a promise with a
|
|
355
|
-
// `resolveGlobals: true` one.
|
|
341
|
+
// Whole-page results contain request-specific cookies, flags, device and URL.
|
|
342
|
+
// A module-global URL key also lets one stalled request block later visitors.
|
|
343
|
+
// Dedup only within this request; keep query and globals options distinct.
|
|
356
344
|
const clientNav = isClientNavigation(fullPath, getRequestUrl());
|
|
357
345
|
const inflightKey =
|
|
358
346
|
(clientNav ? `__nav:${fullPath}` : fullPath) + (resolveGlobals ? "" : "|noGlobals");
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const promise = loadCmsPageInternal(fullPath, resolveGlobals).finally(() =>
|
|
363
|
-
pageInflight.delete(inflightKey),
|
|
347
|
+
return loadPageForRequest(getRequest(), inflightKey, () =>
|
|
348
|
+
loadCmsPageInternal(fullPath, resolveGlobals),
|
|
364
349
|
);
|
|
365
|
-
pageInflight.set(inflightKey, promise);
|
|
366
|
-
return promise;
|
|
367
350
|
});
|
|
368
351
|
|
|
369
352
|
/** Accepted by `loadCmsHomePage` — optional so existing no-arg callers keep working. */
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createRequestSingleFlight } from "./requestSingleFlight";
|
|
3
|
+
|
|
4
|
+
describe("request-scoped page single-flight", () => {
|
|
5
|
+
it("lets a healthy visitor complete while an earlier visitor's page is stuck", async () => {
|
|
6
|
+
const load = createRequestSingleFlight<string>();
|
|
7
|
+
const path = "/granado/sabonetes/sabonete-barra";
|
|
8
|
+
let release!: (value: string) => void;
|
|
9
|
+
const first = load(
|
|
10
|
+
new Request(`https://shop.test${path}`),
|
|
11
|
+
path,
|
|
12
|
+
() =>
|
|
13
|
+
new Promise<string>((resolve) => {
|
|
14
|
+
release = resolve;
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
const healthy = load(
|
|
18
|
+
new Request(`https://shop.test${path}`),
|
|
19
|
+
path,
|
|
20
|
+
async () => "healthy visitor",
|
|
21
|
+
);
|
|
22
|
+
try {
|
|
23
|
+
expect(
|
|
24
|
+
await Promise.race([
|
|
25
|
+
healthy,
|
|
26
|
+
new Promise((resolve) => setTimeout(() => resolve("blocked"), 100)),
|
|
27
|
+
]),
|
|
28
|
+
).toBe("healthy visitor");
|
|
29
|
+
} finally {
|
|
30
|
+
release("earlier visitor");
|
|
31
|
+
await first;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("deduplicates concurrent callers within one request and removes completed work", async () => {
|
|
36
|
+
const load = createRequestSingleFlight<string>();
|
|
37
|
+
const request = new Request("https://shop.test/category");
|
|
38
|
+
const run = vi.fn(async () => "page");
|
|
39
|
+
const first = load(request, "/category", run);
|
|
40
|
+
expect(load(request, "/category", run)).toBe(first);
|
|
41
|
+
await first;
|
|
42
|
+
await load(request, "/category", run);
|
|
43
|
+
expect(run).toHaveBeenCalledTimes(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("keeps page options separate and allows retry after a failure", async () => {
|
|
47
|
+
const load = createRequestSingleFlight<string>();
|
|
48
|
+
const request = new Request("https://shop.test/category");
|
|
49
|
+
await expect(
|
|
50
|
+
load(request, "/category", async () => {
|
|
51
|
+
throw new Error("failed");
|
|
52
|
+
}),
|
|
53
|
+
).rejects.toThrow("failed");
|
|
54
|
+
expect(await load(request, "/category", async () => "retry")).toBe("retry");
|
|
55
|
+
const results = await Promise.all([
|
|
56
|
+
load(request, "/category?p=1", async () => "first page"),
|
|
57
|
+
load(request, "/category?p=2", async () => "second page"),
|
|
58
|
+
load(request, "/category?p=1|noGlobals", async () => "without globals"),
|
|
59
|
+
]);
|
|
60
|
+
expect(results).toEqual(["first page", "second page", "without globals"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Share pending work only among callers belonging to the same HTTP request. */
|
|
2
|
+
export function createRequestSingleFlight<T>() {
|
|
3
|
+
const requests = new WeakMap<Request, Map<string, Promise<T>>>();
|
|
4
|
+
|
|
5
|
+
return (request: Request, key: string, run: () => Promise<T>): Promise<T> => {
|
|
6
|
+
const pending = requests.get(request) ?? new Map<string, Promise<T>>();
|
|
7
|
+
requests.set(request, pending);
|
|
8
|
+
const existing = pending.get(key);
|
|
9
|
+
if (existing) return existing;
|
|
10
|
+
|
|
11
|
+
const promise = Promise.resolve()
|
|
12
|
+
.then(run)
|
|
13
|
+
.finally(() => pending.delete(key));
|
|
14
|
+
pending.set(key, promise);
|
|
15
|
+
return promise;
|
|
16
|
+
};
|
|
17
|
+
}
|
package/src/sdk/kvHydration.ts
CHANGED
|
@@ -51,6 +51,8 @@ const G = globalThis as unknown as {
|
|
|
51
51
|
kvHydrated?: boolean;
|
|
52
52
|
kvHydration?: Promise<void> | null;
|
|
53
53
|
kvLastPolledAt?: number;
|
|
54
|
+
dataVersion?: string | null;
|
|
55
|
+
dataVersionAt?: number;
|
|
54
56
|
};
|
|
55
57
|
};
|
|
56
58
|
if (!G.__deco) G.__deco = {};
|
|
@@ -255,9 +257,62 @@ async function pollRevisionOnce(kv: KVNamespace, deploymentId: string): Promise<
|
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
|
|
260
|
+
/** KV key holding the preserved data-cache version. Writing a new value (the
|
|
261
|
+
* control-plane does, via the CF API) purges preserved loader/fetch data for
|
|
262
|
+
* every isolate within one poll — no deploy. */
|
|
263
|
+
export const DATA_CACHE_VERSION_KEY = "cache:data-version";
|
|
264
|
+
|
|
265
|
+
/** Deadline for one `cache:data-version` read. Calibration knob. */
|
|
266
|
+
export const DATA_VERSION_TIMEOUT_MS = 1_000;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Current `DATA_CACHE_VERSION_KEY` value, or "" without a `DECO_KV` binding.
|
|
270
|
+
* The first call per isolate awaits the read (so a cold isolate never reads
|
|
271
|
+
* purged keys); after that the cached value is returned and refreshed in the
|
|
272
|
+
* background every `POLL_INTERVAL_MS`. A failed or slow read (over
|
|
273
|
+
* `DATA_VERSION_TIMEOUT_MS`) keeps the last value. Only resolved strings are
|
|
274
|
+
* kept, never a pending read: a `DECO_KV.get` that never settles would otherwise
|
|
275
|
+
* hold every later request of the isolate.
|
|
276
|
+
*/
|
|
277
|
+
export async function getDataCacheVersion(env: Env, ctx?: ExecutionContextLike): Promise<string> {
|
|
278
|
+
const kv = getKV(env);
|
|
279
|
+
if (!kv) return "";
|
|
280
|
+
const d = G.__deco!;
|
|
281
|
+
const read = (prev: string): Promise<string> => {
|
|
282
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
283
|
+
return Promise.race([
|
|
284
|
+
kv.get(DATA_CACHE_VERSION_KEY).then(
|
|
285
|
+
(v) => v ?? "",
|
|
286
|
+
() => prev,
|
|
287
|
+
),
|
|
288
|
+
new Promise<string>((resolve) => {
|
|
289
|
+
timer = setTimeout(() => resolve(prev), DATA_VERSION_TIMEOUT_MS);
|
|
290
|
+
}),
|
|
291
|
+
]).finally(() => clearTimeout(timer));
|
|
292
|
+
};
|
|
293
|
+
if (d.dataVersion == null) {
|
|
294
|
+
d.dataVersionAt = Date.now();
|
|
295
|
+
d.dataVersion = await read("");
|
|
296
|
+
return d.dataVersion;
|
|
297
|
+
}
|
|
298
|
+
const current = d.dataVersion;
|
|
299
|
+
if (Date.now() - (d.dataVersionAt ?? 0) >= POLL_INTERVAL_MS) {
|
|
300
|
+
d.dataVersionAt = Date.now();
|
|
301
|
+
// Background refresh: requests keep the current value meanwhile.
|
|
302
|
+
const swap = read(current).then((v) => {
|
|
303
|
+
d.dataVersion = v;
|
|
304
|
+
});
|
|
305
|
+
if (ctx?.waitUntil) ctx.waitUntil(swap);
|
|
306
|
+
else void swap.catch(() => {});
|
|
307
|
+
}
|
|
308
|
+
return current;
|
|
309
|
+
}
|
|
310
|
+
|
|
258
311
|
/** Test-only: reset the isolate-level hydration flags. */
|
|
259
312
|
export function __resetKvHydrationStateForTests(): void {
|
|
260
313
|
G.__deco!.kvHydrated = false;
|
|
261
314
|
G.__deco!.kvHydration = null;
|
|
262
315
|
G.__deco!.kvLastPolledAt = 0;
|
|
316
|
+
G.__deco!.dataVersion = null;
|
|
317
|
+
G.__deco!.dataVersionAt = 0;
|
|
263
318
|
}
|
|
@@ -8,6 +8,15 @@ import {
|
|
|
8
8
|
getCacheStorageContext,
|
|
9
9
|
} from "@decocms/blocks/sdk/cacheStorage";
|
|
10
10
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { createFetchCache } from "@decocms/blocks/sdk/fetchCache";
|
|
12
|
+
import { __resetKvHydrationStateForTests, DATA_VERSION_TIMEOUT_MS } from "./kvHydration";
|
|
13
|
+
|
|
14
|
+
const FETCH_KNOBS = {
|
|
15
|
+
maxEntries: 10,
|
|
16
|
+
freshTtlMs: { success: 60_000, notFound: 1_000, serverError: 0 },
|
|
17
|
+
staleIfErrorMs: 60_000,
|
|
18
|
+
inflightBackstopMs: 5_000,
|
|
19
|
+
};
|
|
11
20
|
import { createDecoWorkerEntry } from "./workerEntry";
|
|
12
21
|
|
|
13
22
|
function makeKV(): CacheKVNamespace {
|
|
@@ -91,6 +100,123 @@ describe("worker cache storage injection", () => {
|
|
|
91
100
|
await flush();
|
|
92
101
|
});
|
|
93
102
|
|
|
103
|
+
// A fresh fetch cache per request: its memory tier can't answer, so a hit
|
|
104
|
+
// proves the shared (KV) entry was found under the same key.
|
|
105
|
+
function fetchWorker(opts: object = {}) {
|
|
106
|
+
const upstream = vi.fn(async () => Response.json({ ok: true }));
|
|
107
|
+
const worker = createDecoWorkerEntry(
|
|
108
|
+
{
|
|
109
|
+
fetch: async () => {
|
|
110
|
+
const cache = createFetchCache({ provider: "test", ...FETCH_KNOBS });
|
|
111
|
+
const body = await cache.fetchWithCache("https://upstream.test/x", upstream);
|
|
112
|
+
return Response.json(body, { headers: { "cache-control": "no-store" } });
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{ ...options, ...opts },
|
|
116
|
+
);
|
|
117
|
+
return { worker, upstream };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it("DECO_DATA_CACHE_ON_DEPLOY=preserve keeps fetch results across deploys and publishes", async () => {
|
|
121
|
+
const { worker, upstream } = fetchWorker();
|
|
122
|
+
const bindings = { ...env(), DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
|
|
123
|
+
await worker.fetch(request("/one"), bindings, ctx);
|
|
124
|
+
await flush();
|
|
125
|
+
// Distinct paths: only the fetch cache is under test, not the HTML cache.
|
|
126
|
+
await worker.fetch(request("/two"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
|
|
127
|
+
// A CMS publish (new decofile revision) keeps them too.
|
|
128
|
+
setBlocks({ published: { __resolveType: "x" } });
|
|
129
|
+
await worker.fetch(request("/three"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
|
|
130
|
+
expect(upstream).toHaveBeenCalledTimes(1);
|
|
131
|
+
// A new DECO_DATA_CACHE_PURGE value drops the preserved data once.
|
|
132
|
+
await worker.fetch(request("/four"), { ...bindings, DECO_DATA_CACHE_PURGE: "2026-09-22" }, ctx);
|
|
133
|
+
expect(upstream).toHaveBeenCalledTimes(2);
|
|
134
|
+
await flush();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("loader results stay per-build even with preserve", async () => {
|
|
138
|
+
const upstream = vi.fn(async () => ({ n: 1 }));
|
|
139
|
+
const loader = createCachedLoader("worker-preserve-loader", upstream, {
|
|
140
|
+
policy: "no-cache",
|
|
141
|
+
maxAge: 60_000,
|
|
142
|
+
});
|
|
143
|
+
const worker = createDecoWorkerEntry(
|
|
144
|
+
{
|
|
145
|
+
fetch: async () =>
|
|
146
|
+
Response.json(await loader({}), { headers: { "cache-control": "no-store" } }),
|
|
147
|
+
},
|
|
148
|
+
options,
|
|
149
|
+
);
|
|
150
|
+
const bindings = { ...env(), DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
|
|
151
|
+
await worker.fetch(request("/a"), bindings, ctx);
|
|
152
|
+
await flush();
|
|
153
|
+
clearLoaderCache();
|
|
154
|
+
await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
|
|
155
|
+
expect(upstream).toHaveBeenCalledTimes(2);
|
|
156
|
+
await flush();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("invalidate or unset keeps fetch results per build", async () => {
|
|
160
|
+
for (const extra of [{}, { DECO_DATA_CACHE_ON_DEPLOY: "invalidate" }, { DECO_DATA_CACHE_ON_DEPLOY: "yes" }]) {
|
|
161
|
+
const { worker, upstream } = fetchWorker();
|
|
162
|
+
const bindings = { ...env(), ...extra };
|
|
163
|
+
await worker.fetch(request("/a"), bindings, ctx);
|
|
164
|
+
await flush();
|
|
165
|
+
await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
|
|
166
|
+
expect(upstream).toHaveBeenCalledTimes(2);
|
|
167
|
+
await flush();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("a new cache:data-version in DECO_KV purges preserved data without a deploy", async () => {
|
|
172
|
+
__resetKvHydrationStateForTests();
|
|
173
|
+
const { worker, upstream } = fetchWorker();
|
|
174
|
+
const DECO_KV = makeKV();
|
|
175
|
+
const bindings = { ...env(), DECO_KV, DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
|
|
176
|
+
await worker.fetch(request("/a"), bindings, ctx);
|
|
177
|
+
await flush();
|
|
178
|
+
await worker.fetch(request("/b"), bindings, ctx);
|
|
179
|
+
expect(upstream).toHaveBeenCalledTimes(1);
|
|
180
|
+
await DECO_KV.put("cache:data-version", "v2", { expirationTtl: 60 });
|
|
181
|
+
__resetKvHydrationStateForTests(); // skip the poll interval
|
|
182
|
+
await worker.fetch(request("/c"), bindings, ctx);
|
|
183
|
+
expect(upstream).toHaveBeenCalledTimes(2);
|
|
184
|
+
await flush();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("a hung cache:data-version read falls back instead of holding the request", async () => {
|
|
188
|
+
vi.useFakeTimers();
|
|
189
|
+
try {
|
|
190
|
+
__resetKvHydrationStateForTests();
|
|
191
|
+
const { worker, upstream } = fetchWorker();
|
|
192
|
+
const DECO_KV = { ...makeKV(), get: () => new Promise<string | null>(() => {}) };
|
|
193
|
+
const bindings = { ...env(), DECO_KV, DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
|
|
194
|
+
const pending = worker.fetch(request("/a"), bindings, ctx);
|
|
195
|
+
await vi.advanceTimersByTimeAsync(DATA_VERSION_TIMEOUT_MS);
|
|
196
|
+
expect((await pending).status).toBe(200);
|
|
197
|
+
expect(upstream).toHaveBeenCalledTimes(1);
|
|
198
|
+
} finally {
|
|
199
|
+
vi.useRealTimers();
|
|
200
|
+
await flush();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("honours dataCacheOnDeployEnv renames and opt-out", async () => {
|
|
205
|
+
const run = async (opts: object, extra: Record<string, string>) => {
|
|
206
|
+
const { worker, upstream } = fetchWorker(opts);
|
|
207
|
+
const bindings = { ...env(), ...extra };
|
|
208
|
+
await worker.fetch(request("/a"), bindings, ctx);
|
|
209
|
+
await flush();
|
|
210
|
+
await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
|
|
211
|
+
await flush();
|
|
212
|
+
return upstream.mock.calls.length;
|
|
213
|
+
};
|
|
214
|
+
expect(await run({ dataCacheOnDeployEnv: "KEEP" }, { KEEP: "preserve" })).toBe(1);
|
|
215
|
+
expect(
|
|
216
|
+
await run({ dataCacheOnDeployEnv: false }, { DECO_DATA_CACHE_ON_DEPLOY: "preserve" }),
|
|
217
|
+
).toBe(2);
|
|
218
|
+
});
|
|
219
|
+
|
|
94
220
|
it("stores public server-function POSTs by body and excludes unmarked responses", async () => {
|
|
95
221
|
const origin = {
|
|
96
222
|
fetch: vi.fn(
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -105,7 +105,7 @@ import {
|
|
|
105
105
|
registerDraftOverride,
|
|
106
106
|
requestCarriesDraft,
|
|
107
107
|
} from "./draft";
|
|
108
|
-
import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
|
|
108
|
+
import { ensureBlocksHydrated, getDataCacheVersion, maybePollRevision } from "./kvHydration";
|
|
109
109
|
import { lookupExactRedirect } from "./kvRedirects";
|
|
110
110
|
import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
|
|
111
111
|
import { type SpeculationRulesConfig, setSpeculationRules } from "./speculationRules";
|
|
@@ -423,6 +423,33 @@ export interface DecoWorkerEntryOptions {
|
|
|
423
423
|
*/
|
|
424
424
|
cacheVersionEnv?: string | false;
|
|
425
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Environment variable choosing what a deploy does to the `createFetchCache`
|
|
428
|
+
* entries (`fetch:*`, raw upstream responses) in shared storage (e.g. KV):
|
|
429
|
+
*
|
|
430
|
+
* - `"invalidate"` (or unset — the default): keyed by build and CMS revision,
|
|
431
|
+
* a deploy or publish starts them cold. Today's behaviour.
|
|
432
|
+
* - `"preserve"`: keyed without build or CMS revision, they survive both.
|
|
433
|
+
* A raw response doesn't depend on the code or the decofile; the new
|
|
434
|
+
* code re-transforms it, so a deploy can't serve an old shape.
|
|
435
|
+
*
|
|
436
|
+
* `cachedLoader` results, HTML responses and sections stay per-build in both
|
|
437
|
+
* modes: they hold code output. Segment/device and geo stay in every key.
|
|
438
|
+
* Any other value falls back to `"invalidate"`.
|
|
439
|
+
*
|
|
440
|
+
* To drop preserved responses once, set `DECO_DATA_CACHE_PURGE` to a new value
|
|
441
|
+
* (any string, e.g. the date), or write a new `cache:data-version` in DECO_KV.
|
|
442
|
+
*
|
|
443
|
+
* @default "DECO_DATA_CACHE_ON_DEPLOY"
|
|
444
|
+
*
|
|
445
|
+
* @example
|
|
446
|
+
* ```jsonc
|
|
447
|
+
* // wrangler.jsonc
|
|
448
|
+
* "vars": { "DECO_DATA_CACHE_ON_DEPLOY": "preserve" }
|
|
449
|
+
* ```
|
|
450
|
+
*/
|
|
451
|
+
dataCacheOnDeployEnv?: string | false;
|
|
452
|
+
|
|
426
453
|
/**
|
|
427
454
|
* Security headers appended to every SSR response (HTML pages).
|
|
428
455
|
* Pass `false` to disable entirely.
|
|
@@ -1186,6 +1213,7 @@ export function createDecoWorkerEntry(
|
|
|
1186
1213
|
redirects: redirectsOpt,
|
|
1187
1214
|
previewShell: customPreviewShell,
|
|
1188
1215
|
cacheVersionEnv = "BUILD_HASH",
|
|
1216
|
+
dataCacheOnDeployEnv = "DECO_DATA_CACHE_ON_DEPLOY",
|
|
1189
1217
|
securityHeaders: securityHeadersOpt,
|
|
1190
1218
|
csp: cspOpt,
|
|
1191
1219
|
cspMode = "report-only",
|
|
@@ -1826,7 +1854,8 @@ export function createDecoWorkerEntry(
|
|
|
1826
1854
|
// handlePurge (Bearer `purgeTokenEnv`). Per-isolate by nature — the isolate
|
|
1827
1855
|
// that serves this request clears its cache; other live isolates keep theirs
|
|
1828
1856
|
// until they're recycled or hit too. For a guaranteed global flush, redeploy
|
|
1829
|
-
// (loader cache keys are build-hash versioned
|
|
1857
|
+
// (loader cache keys are build-hash versioned; preserved `fetch:*` entries are
|
|
1858
|
+
// not — see `dataCacheOnDeployEnv`).
|
|
1830
1859
|
async function handlePurgeLoaders(
|
|
1831
1860
|
request: Request,
|
|
1832
1861
|
env: Record<string, unknown>,
|
|
@@ -2305,24 +2334,36 @@ export function createDecoWorkerEntry(
|
|
|
2305
2334
|
} catch {
|
|
2306
2335
|
/* Missing storage is a cache miss, never a storefront failure. */
|
|
2307
2336
|
}
|
|
2337
|
+
const preserveData = !!dataCacheOnDeployEnv && env[dataCacheOnDeployEnv] === "preserve";
|
|
2338
|
+
// Purge without a deploy: bump `cache:data-version` in DECO_KV.
|
|
2339
|
+
const kvDataVersion = preserveData && storage ? await getDataCacheVersion(env, ctx) : "";
|
|
2340
|
+
// Everything but the version is shared by the build and data scopes.
|
|
2341
|
+
// Revision is left out of `variant`: build-scoped stores add it below, while
|
|
2342
|
+
// preserved fetch data skips it — a raw upstream response does not depend on
|
|
2343
|
+
// the decofile (a publish that changes props changes the URL, hence the key).
|
|
2344
|
+
const variant = [
|
|
2345
|
+
segment
|
|
2346
|
+
? hashSegment(segment)
|
|
2347
|
+
: isMobileUA(request.headers.get("user-agent") ?? "")
|
|
2348
|
+
? "mobile"
|
|
2349
|
+
: "desktop",
|
|
2350
|
+
buildGeoCacheParam(
|
|
2351
|
+
(request as unknown as { cf?: Record<string, string> }).cf,
|
|
2352
|
+
effectiveGeoKey(),
|
|
2353
|
+
),
|
|
2354
|
+
privateRequest ? crypto.randomUUID() : null,
|
|
2355
|
+
];
|
|
2356
|
+
const scopeFor = (version: string, revision: string | null = getRevision()) =>
|
|
2357
|
+
JSON.stringify([url.origin, version, revision, ...variant]);
|
|
2308
2358
|
bindCacheStorage({
|
|
2309
2359
|
storage,
|
|
2310
2360
|
disabled: privateRequest || isDevMode(),
|
|
2311
|
-
scope:
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
: isMobileUA(request.headers.get("user-agent") ?? "")
|
|
2318
|
-
? "mobile"
|
|
2319
|
-
: "desktop",
|
|
2320
|
-
buildGeoCacheParam(
|
|
2321
|
-
(request as unknown as { cf?: Record<string, string> }).cf,
|
|
2322
|
-
effectiveGeoKey(),
|
|
2323
|
-
),
|
|
2324
|
-
privateRequest ? crypto.randomUUID() : null,
|
|
2325
|
-
]),
|
|
2361
|
+
scope: scopeFor(getBuildHash(env)),
|
|
2362
|
+
// Only `createFetchCache` (`fetch:*`) uses dataScope. Preserve keys it without
|
|
2363
|
+
// build or CMS revision; invalidate keeps today's per-build key.
|
|
2364
|
+
dataScope: preserveData
|
|
2365
|
+
? scopeFor(`data:${(env.DECO_DATA_CACHE_PURGE as string) ?? ""}:${kvDataVersion}`, null)
|
|
2366
|
+
: undefined,
|
|
2326
2367
|
waitUntil: (work) => ctx.waitUntil(work),
|
|
2327
2368
|
});
|
|
2328
2369
|
}
|