@decocms/blocks 7.62.3 → 7.62.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.62.3",
3
+ "version": "7.62.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -78,7 +78,6 @@ export interface LoaderModule<TProps = any, TResult = any> {
78
78
  cacheKey?: (props: TProps, req?: Request) => string | null;
79
79
  }
80
80
 
81
-
82
81
  interface CacheEntry<T = unknown> {
83
82
  value: T;
84
83
  createdAt: number;
@@ -102,16 +101,41 @@ const DEFAULT_MAX_AGE = 60_000;
102
101
  const DEFAULT_MAX_CACHE_BYTES = 32 * 1024 * 1024;
103
102
 
104
103
  function resolveMaxBytes(): number {
105
- const env = typeof globalThis.process !== "undefined"
106
- ? globalThis.process.env
107
- : undefined;
104
+ const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
108
105
  const raw = env?.DECO_LOADER_CACHE_MAX_BYTES;
109
106
  if (!raw) return DEFAULT_MAX_CACHE_BYTES;
110
107
  const parsed = Number.parseInt(raw, 10);
111
108
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CACHE_BYTES;
112
109
  }
113
110
 
114
- const MAX_CACHE_BYTES = resolveMaxBytes();
111
+ let maxCacheBytes = resolveMaxBytes();
112
+
113
+ /**
114
+ * Override the loader cache byte cap at runtime.
115
+ *
116
+ * The `DECO_LOADER_CACHE_MAX_BYTES` env override above only lands when the
117
+ * Worker's `compatibility_date` is new enough for `nodejs_compat` to populate
118
+ * `process.env`. Verified in workerd: with `2024-09-23` it is empty both at
119
+ * module scope and inside the handler; with `2025-06-01` it is populated. Sites
120
+ * pinned to an older date — a common thing to do, since the date also gates
121
+ * behaviour flags they may have tuned deliberately — had a documented knob that
122
+ * silently did nothing.
123
+ *
124
+ * Call this from the site's `setup.ts`, before any loader is wrapped.
125
+ *
126
+ * Sizing note: the cap is per ISOLATE, not per site, and a Worker isolate has
127
+ * 128 MB total, shared with the bundle, the V8 heap and the render working set.
128
+ * Setting it at or near 128 MB does not give headroom — it removes the ceiling.
129
+ */
130
+ export function setLoaderCacheMaxBytes(bytes: number): void {
131
+ if (!Number.isFinite(bytes) || bytes <= 0) return;
132
+ maxCacheBytes = bytes;
133
+ }
134
+
135
+ /** Current byte cap — exported for diagnostics and tests. */
136
+ export function getLoaderCacheMaxBytes(): number {
137
+ return maxCacheBytes;
138
+ }
115
139
 
116
140
  const cache = new Map<string, CacheEntry>();
117
141
  let cacheBytes = 0;
@@ -162,13 +186,11 @@ function deleteCacheEntry(key: string) {
162
186
  }
163
187
 
164
188
  function evictIfNeeded() {
165
- if (cacheBytes <= MAX_CACHE_BYTES) return;
166
- const oldest = [...cache.entries()].sort(
167
- (a, b) => a[1].createdAt - b[1].createdAt,
168
- );
189
+ if (cacheBytes <= maxCacheBytes) return;
190
+ const oldest = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
169
191
  for (const [key] of oldest) {
170
192
  deleteCacheEntry(key);
171
- if (cacheBytes <= MAX_CACHE_BYTES) break;
193
+ if (cacheBytes <= maxCacheBytes) break;
172
194
  }
173
195
  }
174
196
 
@@ -246,11 +268,10 @@ export function createCachedLoader<TProps, TResult>(
246
268
  recordCacheMetric(false, name, undefined, "cachedLoader");
247
269
  const devStart = performance.now();
248
270
  const promise = withInflightTimeout(
249
- withTracing(
250
- "deco.cachedLoader",
251
- () => loaderFn(props),
252
- { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
253
- ),
271
+ withTracing("deco.cachedLoader", () => loaderFn(props), {
272
+ "deco.loader": name,
273
+ "deco.cache.policy": "no-cache-dev",
274
+ }),
254
275
  `cachedLoader:dev ${cacheKey}`,
255
276
  )
256
277
  .then((r) => {
@@ -374,7 +395,6 @@ export function createCachedLoader<TProps, TResult>(
374
395
  };
375
396
  }
376
397
 
377
-
378
398
  // ---------------------------------------------------------------------------
379
399
  // Module loader dedup (the `@decocms/apps` cache/cacheKey convention)
380
400
  //
@@ -404,11 +424,12 @@ export function createCachedLoaderFromModule<TProps, TResult>(
404
424
  name: string,
405
425
  mod: LoaderModule<TProps, TResult>,
406
426
  ): (props: TProps, req?: Request) => Promise<TResult> {
407
- const policy: CachePolicy = typeof mod.cache === "string"
408
- ? mod.cache
409
- : mod.cache && typeof mod.cache === "object"
410
- ? "stale-while-revalidate"
411
- : "no-store";
427
+ const policy: CachePolicy =
428
+ typeof mod.cache === "string"
429
+ ? mod.cache
430
+ : mod.cache && typeof mod.cache === "object"
431
+ ? "stale-while-revalidate"
432
+ : "no-store";
412
433
 
413
434
  // Only `stale-while-revalidate` (or `{ maxAge }`) opts into dedup — matching
414
435
  // `@decocms/apps`/deco, where `no-store` (the default) and `no-cache` both
@@ -459,9 +480,7 @@ export function createLoaderEntry<TProps = any, TResult = any>(
459
480
  // first-calls — exactly the N-sections-per-render case — share one import
460
481
  // instead of racing into N. Reset on failure so a transient import error can
461
482
  // retry rather than poisoning the entry forever.
462
- let wrappedPromise:
463
- | Promise<(props: TProps, req?: Request) => Promise<TResult>>
464
- | undefined;
483
+ let wrappedPromise: Promise<(props: TProps, req?: Request) => Promise<TResult>> | undefined;
465
484
  return (props: TProps, req?: Request): Promise<TResult> => {
466
485
  if (!wrappedPromise) {
467
486
  wrappedPromise = importFn()
@@ -498,6 +517,6 @@ export function getLoaderCacheStats() {
498
517
  entries: cache.size,
499
518
  inflight: inflightRequests.size,
500
519
  estimatedBytes: cacheBytes,
501
- maxBytes: MAX_CACHE_BYTES,
520
+ maxBytes: maxCacheBytes,
502
521
  };
503
522
  }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getLoaderCacheMaxBytes, setLoaderCacheMaxBytes } from "./cachedLoader";
3
+
4
+ describe("loader cache byte cap", () => {
5
+ it("defaults to 32 MB", () => {
6
+ // process.env is empty in workerd unless compatibility_date is new enough,
7
+ // which is exactly why the setter below exists.
8
+ expect(getLoaderCacheMaxBytes()).toBe(32 * 1024 * 1024);
9
+ });
10
+
11
+ it("can be raised programmatically", () => {
12
+ setLoaderCacheMaxBytes(64 * 1024 * 1024);
13
+ expect(getLoaderCacheMaxBytes()).toBe(64 * 1024 * 1024);
14
+ });
15
+
16
+ it("ignores nonsense instead of disabling eviction", () => {
17
+ setLoaderCacheMaxBytes(64 * 1024 * 1024);
18
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
19
+ setLoaderCacheMaxBytes(bad);
20
+ expect(getLoaderCacheMaxBytes()).toBe(64 * 1024 * 1024);
21
+ }
22
+ });
23
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cleanPathForCacheKey, isTrackingParam } from "./urlUtils";
3
+
4
+ describe("tracking params that used to fragment the cache key", () => {
5
+ // Each of these was measured missing the edge cache on every route probed on a
6
+ // production storefront, because it was absent from UTM_PARAMS.
7
+ const cases = [
8
+ ["GA4 campaign id", "utm_id"],
9
+ ["GA4 source platform", "utm_source_platform"],
10
+ ["Google Ads source", "gad_source"],
11
+ ["Google Ads iOS click id", "gbraid"],
12
+ ["Google Ads web-to-app click id", "wbraid"],
13
+ ["GA cross-domain linker", "_gl"],
14
+ ["Instagram", "igshid"],
15
+ ["Pinterest", "epik"],
16
+ ["Marketo", "mkt_tok"],
17
+ ["Yandex", "yclid"],
18
+ ] as const;
19
+
20
+ for (const [label, param] of cases) {
21
+ it(`strips ${label} (${param})`, () => {
22
+ expect(isTrackingParam(param)).toBe(true);
23
+ expect(cleanPathForCacheKey(`https://x.com/aneis?color=ouro&${param}=abc`)).toBe(
24
+ "/aneis?color=ouro",
25
+ );
26
+ });
27
+ }
28
+
29
+ it("still keeps functional params", () => {
30
+ for (const p of ["page", "order", "map", "PS", "filter.color", "skuId", "q"]) {
31
+ expect(isTrackingParam(p)).toBe(false);
32
+ }
33
+ });
34
+ });
@@ -25,6 +25,27 @@ const UTM_PARAMS = new Set([
25
25
  "mc_eid",
26
26
  "ttclid",
27
27
  "srsltid",
28
+ // GA4's newer campaign params. The five classic `utm_*` above predate them,
29
+ // so a GA4-tagged campaign was still fragmenting the cache key.
30
+ "utm_id",
31
+ "utm_source_platform",
32
+ "utm_creative_format",
33
+ "utm_marketing_tactic",
34
+ // Google Ads click ids that replaced bare `gclid` for privacy-restricted
35
+ // traffic (iOS/Safari), plus the source tag now appended to essentially every
36
+ // Google Ads click. Measured on a production storefront: each of these missed
37
+ // the edge cache on 3/3 routes probed, deterministically.
38
+ "gad_source",
39
+ "gad_campaignid",
40
+ "gbraid",
41
+ "wbraid",
42
+ // Google Analytics cross-domain linker. Sites already strip this by hand in
43
+ // client code, which is the tell that it shows up in real URLs.
44
+ "_gl",
45
+ "igshid",
46
+ "epik",
47
+ "mkt_tok",
48
+ "yclid",
28
49
  ]);
29
50
 
30
51
  /**