@decocms/blocks 7.55.1 → 7.56.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 +1 -1
- package/src/sdk/cacheHeaders.test.ts +90 -0
- package/src/sdk/cacheHeaders.ts +132 -10
package/package.json
CHANGED
|
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
canonicalizeServerFnPayloadForCacheKey,
|
|
4
4
|
detectCacheProfile,
|
|
5
|
+
getCacheProfile,
|
|
6
|
+
registerCachePattern,
|
|
7
|
+
registerPrivatePaths,
|
|
5
8
|
serverFnPagePath,
|
|
9
|
+
setCacheProfile,
|
|
6
10
|
} from "./cacheHeaders";
|
|
7
11
|
|
|
8
12
|
const sfn = (payload: unknown): URL => {
|
|
@@ -113,3 +117,89 @@ describe("canonicalizeServerFnPayloadForCacheKey — variant-param cache key", (
|
|
|
113
117
|
expect(canonicalizeServerFnPayloadForCacheKey("{not json")).toBe("{not json");
|
|
114
118
|
});
|
|
115
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("detectCacheProfile — private areas", () => {
|
|
122
|
+
it.each([
|
|
123
|
+
"/checkout",
|
|
124
|
+
"/checkout/cart",
|
|
125
|
+
"/cart",
|
|
126
|
+
"/carrinho",
|
|
127
|
+
"/minha-conta",
|
|
128
|
+
"/meus-pedidos",
|
|
129
|
+
"/login",
|
|
130
|
+
"/myaccount",
|
|
131
|
+
])("keeps the existing private route %s private", (path) => {
|
|
132
|
+
expect(detectCacheProfile(path)).toBe("private");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
// Every one of these used to fall through to the cacheable `listing`
|
|
137
|
+
// default. `/listadedesejos` is the one that bit a live store.
|
|
138
|
+
"/listadedesejos",
|
|
139
|
+
"/lista-de-desejos",
|
|
140
|
+
"/wishlist",
|
|
141
|
+
"/favoritos",
|
|
142
|
+
"/orders",
|
|
143
|
+
"/order-placed",
|
|
144
|
+
"/profile",
|
|
145
|
+
"/perfil",
|
|
146
|
+
"/logout",
|
|
147
|
+
"/sair",
|
|
148
|
+
"/cadastro",
|
|
149
|
+
"/signup",
|
|
150
|
+
"/register",
|
|
151
|
+
"/assinaturas",
|
|
152
|
+
"/trocas",
|
|
153
|
+
"/devolucao",
|
|
154
|
+
])("treats %s as private", (path) => {
|
|
155
|
+
expect(detectCacheProfile(path)).toBe("private");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("matches case-insensitively", () => {
|
|
159
|
+
expect(detectCacheProfile("/Checkout")).toBe("private");
|
|
160
|
+
expect(detectCacheProfile("/MINHA-CONTA")).toBe("private");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("matches behind a locale prefix", () => {
|
|
164
|
+
expect(detectCacheProfile("/pt/checkout")).toBe("private");
|
|
165
|
+
expect(detectCacheProfile("/pt-br/minha-conta")).toBe("private");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("does not swallow public routes that merely start with two letters", () => {
|
|
169
|
+
expect(detectCacheProfile("/pt/tenis")).toBe("listing");
|
|
170
|
+
expect(detectCacheProfile("/carteiras")).toBe("listing");
|
|
171
|
+
expect(detectCacheProfile("/cartoes-presente")).toBe("listing");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("registerPrivatePaths adds site-specific private routes", () => {
|
|
175
|
+
expect(detectCacheProfile("/clube-vip")).toBe("listing");
|
|
176
|
+
registerPrivatePaths(["/clube-vip", "sem-barra"]);
|
|
177
|
+
expect(detectCacheProfile("/clube-vip")).toBe("private");
|
|
178
|
+
expect(detectCacheProfile("/clube-vip/beneficios")).toBe("private");
|
|
179
|
+
expect(detectCacheProfile("/sem-barra")).toBe("private");
|
|
180
|
+
// prefix match must respect segment boundaries
|
|
181
|
+
expect(detectCacheProfile("/clube-vip-publico")).toBe("listing");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("cache configuration can tighten, not loosen", () => {
|
|
186
|
+
it("refuses to make a non-public profile public", () => {
|
|
187
|
+
setCacheProfile("private", { isPublic: true });
|
|
188
|
+
expect(getCacheProfile("private").isPublic).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("still allows ordinary tuning of a private profile", () => {
|
|
192
|
+
setCacheProfile("private", { loader: { fresh: 1_000 } });
|
|
193
|
+
expect(getCacheProfile("private").loader.fresh).toBe(1_000);
|
|
194
|
+
expect(getCacheProfile("private").isPublic).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("a custom pattern cannot make a private path public", () => {
|
|
198
|
+
// A catch-all site pattern used to win over the built-in private check,
|
|
199
|
+
// because custom patterns are evaluated first.
|
|
200
|
+
registerCachePattern({ test: () => true, profile: "static" });
|
|
201
|
+
expect(detectCacheProfile("/checkout")).toBe("private");
|
|
202
|
+
// ...but it still applies everywhere else.
|
|
203
|
+
expect(detectCacheProfile("/tenis")).toBe("static");
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/sdk/cacheHeaders.ts
CHANGED
|
@@ -149,15 +149,45 @@ export function setCacheProfile(
|
|
|
149
149
|
overrides: CacheProfileOverrides,
|
|
150
150
|
): void {
|
|
151
151
|
const current = PROFILES[profile];
|
|
152
|
+
|
|
153
|
+
// `private`/`cart`/`none` are the profiles that keep authenticated pages off
|
|
154
|
+
// the shared edge (and, once CDN caching is on, off the CDN). Flipping one to
|
|
155
|
+
// public is how a checkout page ends up served to another visitor, so it
|
|
156
|
+
// takes more than an `isPublic: true` in a props bag — see
|
|
157
|
+
// `allowPublicPrivateProfile`.
|
|
158
|
+
let isPublic = overrides.isPublic ?? current.isPublic;
|
|
159
|
+
if (isPublic && !current.isPublic && !publicPrivateProfilesAllowed) {
|
|
160
|
+
console.warn(
|
|
161
|
+
`[deco] setCacheProfile("${profile}", { isPublic: true }) ignored: making a ` +
|
|
162
|
+
`non-public profile public would let authenticated pages be shared between ` +
|
|
163
|
+
`visitors. Call allowPublicPrivateProfile() first if this is deliberate.`,
|
|
164
|
+
);
|
|
165
|
+
isPublic = current.isPublic;
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
PROFILES[profile] = {
|
|
153
169
|
edge: { ...current.edge, ...overrides.edge },
|
|
154
170
|
browser: { ...current.browser, ...overrides.browser },
|
|
155
171
|
loader: { ...current.loader, ...overrides.loader },
|
|
156
172
|
client: { ...current.client, ...overrides.client },
|
|
157
|
-
isPublic
|
|
173
|
+
isPublic,
|
|
158
174
|
};
|
|
159
175
|
}
|
|
160
176
|
|
|
177
|
+
let publicPrivateProfilesAllowed = false;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Opt out of the guard in `setCacheProfile` that refuses to turn a non-public
|
|
181
|
+
* profile (`private`, `cart`, `none`) public.
|
|
182
|
+
*
|
|
183
|
+
* There is no legitimate storefront reason to call this. It exists so the
|
|
184
|
+
* escape hatch has a name you have to type, rather than being a silent side
|
|
185
|
+
* effect of passing `isPublic: true`.
|
|
186
|
+
*/
|
|
187
|
+
export function allowPublicPrivateProfile(): void {
|
|
188
|
+
publicPrivateProfilesAllowed = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
// ---------------------------------------------------------------------------
|
|
162
192
|
// Derivation: Cache-Control headers (browser layer)
|
|
163
193
|
// ---------------------------------------------------------------------------
|
|
@@ -262,16 +292,95 @@ interface CachePattern {
|
|
|
262
292
|
profile: CacheProfileName;
|
|
263
293
|
}
|
|
264
294
|
|
|
265
|
-
// Authenticated / per-user areas that must never be edge-cached.
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
295
|
+
// Authenticated / per-user areas that must never be edge-cached. Anything not
|
|
296
|
+
// matched here falls through to the cacheable `listing` default, so a missing
|
|
297
|
+
// entry is a live content leak, not a missed optimization (see
|
|
298
|
+
// decocms/blocks#412, which added the hyphenless `myaccount` VTEX wrapper path
|
|
299
|
+
// and the pt-BR account routes).
|
|
300
|
+
//
|
|
301
|
+
// Three properties this regex must keep, each of which was a real hole:
|
|
302
|
+
// - case-insensitive: `/Checkout` used to fall through to `listing`.
|
|
303
|
+
// - optional locale prefix: `/pt/checkout`, `/br/minha-conta` likewise. Only
|
|
304
|
+
// matches when followed by a private segment, so a legitimate two-letter
|
|
305
|
+
// route can't be swallowed by it.
|
|
306
|
+
// - wishlist / profile / signup / returns: absent until the CDN work, which
|
|
307
|
+
// is what served a live store's `/listadedesejos` from the shared entry.
|
|
308
|
+
const PRIVATE_SEGMENTS = [
|
|
309
|
+
"cart",
|
|
310
|
+
"carrinho",
|
|
311
|
+
"checkout",
|
|
312
|
+
"account",
|
|
313
|
+
"myaccount",
|
|
314
|
+
"my-account",
|
|
315
|
+
"minha-conta",
|
|
316
|
+
"meus-pedidos",
|
|
317
|
+
"pedidos",
|
|
318
|
+
"orders",
|
|
319
|
+
"order-placed",
|
|
320
|
+
"login",
|
|
321
|
+
"logout",
|
|
322
|
+
"sair",
|
|
323
|
+
"cadastro",
|
|
324
|
+
"signup",
|
|
325
|
+
"register",
|
|
326
|
+
"profile",
|
|
327
|
+
"perfil",
|
|
328
|
+
"wishlist",
|
|
329
|
+
"favoritos",
|
|
330
|
+
"listadedesejos",
|
|
331
|
+
"lista-de-desejos",
|
|
332
|
+
"minha-lista",
|
|
333
|
+
"assinaturas",
|
|
334
|
+
"subscriptions",
|
|
335
|
+
"troca",
|
|
336
|
+
"trocas",
|
|
337
|
+
"devolucao",
|
|
338
|
+
"devolucoes",
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
const LOCALE_PREFIX = "(?:\\/[a-z]{2}(?:-[a-z]{2})?)?";
|
|
342
|
+
|
|
343
|
+
const PRIVATE_PREFIX_RE = new RegExp(
|
|
344
|
+
`^${LOCALE_PREFIX}\\/(?:${PRIVATE_SEGMENTS.join("|")})(?:\\/|$)`,
|
|
345
|
+
"i",
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Site-registered private prefixes (see `registerPrivatePaths`). Kept separate
|
|
349
|
+
// from `PRIVATE_SEGMENTS` so a site can only ever ADD to the private set.
|
|
350
|
+
const extraPrivatePaths: string[] = [];
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Mark additional path prefixes as private — never edge-cached, never served
|
|
354
|
+
* from the CDN.
|
|
355
|
+
*
|
|
356
|
+
* This is the safe half of cache configuration: it can only restrict, never
|
|
357
|
+
* relax. Prefer it over `registerCachePattern` (which can also make things
|
|
358
|
+
* public, and is evaluated before the built-in private check).
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* ```ts
|
|
362
|
+
* registerPrivatePaths(["/listadedesejos", "/trocas"]);
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
export function registerPrivatePaths(paths: string[]): void {
|
|
366
|
+
for (const path of paths) {
|
|
367
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
368
|
+
if (!extraPrivatePaths.includes(normalized)) extraPrivatePaths.push(normalized);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isPrivatePath(pathname: string): boolean {
|
|
373
|
+
if (PRIVATE_PREFIX_RE.test(pathname)) return true;
|
|
374
|
+
const lower = pathname.toLowerCase();
|
|
375
|
+
return extraPrivatePaths.some((prefix) => {
|
|
376
|
+
const p = prefix.toLowerCase();
|
|
377
|
+
return lower === p || lower.startsWith(`${p}/`);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
271
380
|
|
|
272
381
|
const builtinPatterns: CachePattern[] = [
|
|
273
382
|
{
|
|
274
|
-
test: (p) =>
|
|
383
|
+
test: (p) => isPrivatePath(p),
|
|
275
384
|
profile: "private",
|
|
276
385
|
},
|
|
277
386
|
{
|
|
@@ -299,7 +408,13 @@ const customPatterns: CachePattern[] = [];
|
|
|
299
408
|
|
|
300
409
|
/**
|
|
301
410
|
* Register additional URL-to-profile patterns. Custom patterns are evaluated
|
|
302
|
-
* before built-in ones, so they can override defaults
|
|
411
|
+
* before built-in ones, so they can override defaults — with one exception:
|
|
412
|
+
* a custom pattern resolving to a PUBLIC profile cannot override the built-in
|
|
413
|
+
* private check (see `detectCacheProfile`). A broad site pattern would
|
|
414
|
+
* otherwise silently capture `/checkout` and make it cacheable.
|
|
415
|
+
*
|
|
416
|
+
* To mark routes as private, prefer {@link registerPrivatePaths} — it can only
|
|
417
|
+
* restrict, and isn't subject to ordering rules.
|
|
303
418
|
*/
|
|
304
419
|
export function registerCachePattern(pattern: CachePattern): void {
|
|
305
420
|
customPatterns.push(pattern);
|
|
@@ -324,8 +439,15 @@ export function detectCacheProfile(pathnameOrUrl: string | URL): CacheProfileNam
|
|
|
324
439
|
searchParams = url.searchParams;
|
|
325
440
|
}
|
|
326
441
|
|
|
442
|
+
// A private path can never be talked into a public profile by a site pattern.
|
|
443
|
+
// Custom patterns still win for anything non-public (a site can always make a
|
|
444
|
+
// route MORE restricted), and for public-vs-public overrides on other paths.
|
|
445
|
+
const privatePath = isPrivatePath(pathname);
|
|
446
|
+
|
|
327
447
|
for (const pattern of customPatterns) {
|
|
328
|
-
if (pattern.test(pathname, searchParams))
|
|
448
|
+
if (!pattern.test(pathname, searchParams)) continue;
|
|
449
|
+
if (privatePath && PROFILES[pattern.profile].isPublic) return "private";
|
|
450
|
+
return pattern.profile;
|
|
329
451
|
}
|
|
330
452
|
for (const pattern of builtinPatterns) {
|
|
331
453
|
if (pattern.test(pathname, searchParams)) return pattern.profile;
|