@decocms/tanstack 7.20.10 → 7.21.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/sdk/workerEntry.test.ts +45 -0
- package/src/sdk/workerEntry.ts +45 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"lint:unused": "knip"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@decocms/blocks": "7.
|
|
28
|
-
"@decocms/blocks-admin": "7.
|
|
29
|
-
"@decocms/blocks-cli": "7.
|
|
27
|
+
"@decocms/blocks": "7.21.0",
|
|
28
|
+
"@decocms/blocks-admin": "7.21.0",
|
|
29
|
+
"@decocms/blocks-cli": "7.21.0",
|
|
30
30
|
"@deco-cx/warp-node": "^0.3.16",
|
|
31
31
|
"fast-json-patch": "^3.1.0",
|
|
32
32
|
"ws": "^8.18.0"
|
|
@@ -385,3 +385,48 @@ describe("security headers — frame-ancestors default", () => {
|
|
|
385
385
|
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
|
+
|
|
389
|
+
describe("POST /_cache/purge-loaders", () => {
|
|
390
|
+
afterEach(() => {
|
|
391
|
+
__resetKvHydrationStateForTests();
|
|
392
|
+
setBlocks({});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const purge = (env: Record<string, unknown>, headers: Record<string, string> = {}) => {
|
|
396
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
|
|
397
|
+
return worker.fetch(
|
|
398
|
+
new Request("https://example.com/_cache/purge-loaders", { method: "POST", headers }),
|
|
399
|
+
env,
|
|
400
|
+
MOCK_CTX,
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
it("401s without a valid Bearer token", async () => {
|
|
405
|
+
const res = await purge({ PURGE_TOKEN: "secret" });
|
|
406
|
+
expect(res.status).toBe(401);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("401s with the wrong token", async () => {
|
|
410
|
+
const res = await purge({ PURGE_TOKEN: "secret" }, { Authorization: "Bearer nope" });
|
|
411
|
+
expect(res.status).toBe(401);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("clears the loader cache with a valid token", async () => {
|
|
415
|
+
const res = await purge({ PURGE_TOKEN: "secret" }, { Authorization: "Bearer secret" });
|
|
416
|
+
expect(res.status).toBe(200);
|
|
417
|
+
expect(await res.json()).toEqual({ cleared: true, scope: "isolate" });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("404s when purge is disabled (purgeTokenEnv: false)", async () => {
|
|
421
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
422
|
+
observability: false,
|
|
423
|
+
purgeTokenEnv: false,
|
|
424
|
+
});
|
|
425
|
+
const res = await worker.fetch(
|
|
426
|
+
new Request("https://example.com/_cache/purge-loaders", { method: "POST" }),
|
|
427
|
+
EMPTY_ENV,
|
|
428
|
+
MOCK_CTX,
|
|
429
|
+
);
|
|
430
|
+
expect(res.status).toBe(404);
|
|
431
|
+
});
|
|
432
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
runSingleSectionLoader,
|
|
39
39
|
} from "@decocms/blocks/cms";
|
|
40
40
|
import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
|
|
41
|
+
import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
|
|
41
42
|
import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
|
|
42
43
|
import {
|
|
43
44
|
type CacheProfileName,
|
|
@@ -1152,15 +1153,33 @@ export function createDecoWorkerEntry(
|
|
|
1152
1153
|
return segments;
|
|
1153
1154
|
}
|
|
1154
1155
|
|
|
1155
|
-
|
|
1156
|
+
// Constant-time string compare — avoids a byte-wise timing side-channel on the
|
|
1157
|
+
// purge token. Length is not secret here, so a fast length-mismatch reject is fine.
|
|
1158
|
+
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
1159
|
+
if (a.length !== b.length) return false;
|
|
1160
|
+
let diff = 0;
|
|
1161
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1162
|
+
return diff === 0;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Shared auth for the cache purge endpoints. Returns a Response when the request
|
|
1166
|
+
// is unauthorized or purge is disabled, or null when authorized. Single source
|
|
1167
|
+
// of truth so /_cache/purge and /_cache/purge-loaders can't drift.
|
|
1168
|
+
function authorizePurge(request: Request, env: Record<string, unknown>): Response | null {
|
|
1156
1169
|
if (purgeTokenEnv === false) {
|
|
1157
1170
|
return new Response("Purge disabled", { status: 404 });
|
|
1158
1171
|
}
|
|
1159
|
-
|
|
1160
1172
|
const token = (env[purgeTokenEnv] as string) || "";
|
|
1161
|
-
|
|
1173
|
+
const auth = request.headers.get("Authorization") ?? "";
|
|
1174
|
+
if (!token || !timingSafeEqualStr(auth, `Bearer ${token}`)) {
|
|
1162
1175
|
return new Response("Unauthorized", { status: 401 });
|
|
1163
1176
|
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
|
|
1181
|
+
const denied = authorizePurge(request, env);
|
|
1182
|
+
if (denied) return denied;
|
|
1164
1183
|
|
|
1165
1184
|
let body: PurgeRequestBody;
|
|
1166
1185
|
try {
|
|
@@ -1268,6 +1287,21 @@ export function createDecoWorkerEntry(
|
|
|
1268
1287
|
return Response.json({ purged, total: purged.length });
|
|
1269
1288
|
}
|
|
1270
1289
|
|
|
1290
|
+
// Purge the in-memory loader/commerce cache for THIS isolate. Same auth as
|
|
1291
|
+
// handlePurge (Bearer `purgeTokenEnv`). Per-isolate by nature — the isolate
|
|
1292
|
+
// that serves this request clears its cache; other live isolates keep theirs
|
|
1293
|
+
// until they're recycled or hit too. For a guaranteed global flush, redeploy
|
|
1294
|
+
// (loader cache keys are build-hash versioned).
|
|
1295
|
+
async function handlePurgeLoaders(
|
|
1296
|
+
request: Request,
|
|
1297
|
+
env: Record<string, unknown>,
|
|
1298
|
+
): Promise<Response> {
|
|
1299
|
+
const denied = authorizePurge(request, env);
|
|
1300
|
+
if (denied) return denied;
|
|
1301
|
+
clearLoaderCache();
|
|
1302
|
+
return Response.json({ cleared: true, scope: "isolate" });
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1271
1305
|
// -- Admin route handler ---------------------------------------------------
|
|
1272
1306
|
|
|
1273
1307
|
const ADMIN_NO_CACHE: Record<string, string> = {
|
|
@@ -1593,6 +1627,14 @@ export function createDecoWorkerEntry(
|
|
|
1593
1627
|
return handlePurge(request, env);
|
|
1594
1628
|
}
|
|
1595
1629
|
|
|
1630
|
+
// Loader/commerce cache purge (in-memory, this isolate only). Escape hatch
|
|
1631
|
+
// to invalidate loader results immediately after an out-of-band upstream
|
|
1632
|
+
// change (e.g. a Magento catalog sync) without waiting out the TTL. A
|
|
1633
|
+
// redeploy is the global lever — loader cache keys are build-hash versioned.
|
|
1634
|
+
if (url.pathname === "/_cache/purge-loaders" && request.method === "POST") {
|
|
1635
|
+
return handlePurgeLoaders(request, env);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1596
1638
|
// CMS redirects (website/loaders/redirect.ts blocks) — checked before
|
|
1597
1639
|
// ?asJson and commerce proxy so every request path respects redirects.
|
|
1598
1640
|
// Revision-keyed so cross-bundle setBlocks() calls are detected even when
|