@decocms/blocks 7.63.1 → 7.64.1
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 +3 -1
- package/src/cms/index.ts +3 -2
- package/src/cms/resolve.ts +33 -23
- package/src/cms/sectionLoaders.ts +56 -43
- package/src/sdk/cacheStorage.test.ts +242 -0
- package/src/sdk/cacheStorage.ts +287 -0
- package/src/sdk/cachedLoader.ts +82 -63
- package/src/sdk/encoding.ts +2 -5
- package/src/sdk/fetchCache.ts +35 -32
- package/src/sdk/responseCache.ts +82 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { RequestContext } from "./requestContext";
|
|
2
|
+
|
|
3
|
+
/** Storage only. Values are serialized by the caller; expiration is absolute epoch ms. */
|
|
4
|
+
export interface CacheStorage {
|
|
5
|
+
get(key: string): Promise<string | null>;
|
|
6
|
+
set(key: string, value: string, expiresAt: number): Promise<void>;
|
|
7
|
+
delete(key: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CacheStorageContext {
|
|
11
|
+
storage: CacheStorage | null;
|
|
12
|
+
/** Include site, deployment and content revision. Never use a tenant-wide global binding. */
|
|
13
|
+
scope: string;
|
|
14
|
+
/** Bypass both memory and shared storage for previews or private requests. */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
waitUntil?: (work: Promise<unknown>) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BAG_KEY = "deco:cache-storage";
|
|
20
|
+
|
|
21
|
+
export function bindCacheStorage(context: CacheStorageContext): void {
|
|
22
|
+
RequestContext.setBag(BAG_KEY, context);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getCacheStorageContext(): CacheStorageContext | undefined {
|
|
26
|
+
return RequestContext.getBag<CacheStorageContext>(BAG_KEY);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep optional cache work alive on Workers, without letting storage errors fail a request. */
|
|
30
|
+
export function cacheBackground(work: Promise<unknown>): void {
|
|
31
|
+
const safe = work.catch(() => {});
|
|
32
|
+
getCacheStorageContext()?.waitUntil?.(safe);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function hashCacheKey(key: string): Promise<string> {
|
|
36
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
|
|
37
|
+
return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface StoredValue {
|
|
41
|
+
value: string;
|
|
42
|
+
expiresAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function decode(raw: string | null): string | null {
|
|
46
|
+
if (raw === null) return null;
|
|
47
|
+
try {
|
|
48
|
+
const entry = JSON.parse(raw) as StoredValue;
|
|
49
|
+
return typeof entry.value === "string" &&
|
|
50
|
+
Number.isFinite(entry.expiresAt) &&
|
|
51
|
+
Date.now() < entry.expiresAt
|
|
52
|
+
? entry.value
|
|
53
|
+
: null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Bounded LRU adapter, useful in Node, development and tests. */
|
|
60
|
+
export function createMemoryCacheStorage(maxBytes = 32 * 1024 * 1024): CacheStorage {
|
|
61
|
+
const entries = new Map<string, string>();
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
function remove(key: string) {
|
|
64
|
+
const old = entries.get(key);
|
|
65
|
+
if (old !== undefined) bytes -= (key.length + old.length) * 2;
|
|
66
|
+
entries.delete(key);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
async get(key) {
|
|
70
|
+
const raw = entries.get(key);
|
|
71
|
+
const value = decode(raw ?? null);
|
|
72
|
+
if (value === null) remove(key);
|
|
73
|
+
else {
|
|
74
|
+
entries.delete(key);
|
|
75
|
+
entries.set(key, raw!);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
},
|
|
79
|
+
async set(key, value, expiresAt) {
|
|
80
|
+
remove(key);
|
|
81
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
|
|
82
|
+
const raw = JSON.stringify({ value, expiresAt });
|
|
83
|
+
const size = (key.length + raw.length) * 2;
|
|
84
|
+
if (size > maxBytes) return;
|
|
85
|
+
entries.set(key, raw);
|
|
86
|
+
bytes += size;
|
|
87
|
+
while (bytes > maxBytes) remove(entries.keys().next().value!);
|
|
88
|
+
},
|
|
89
|
+
async delete(key) {
|
|
90
|
+
remove(key);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Structural binding type; the core has no Cloudflare package dependency. */
|
|
96
|
+
export interface CacheKVNamespace {
|
|
97
|
+
get(key: string): Promise<string | null>;
|
|
98
|
+
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
|
99
|
+
delete(key: string): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createKVCacheStorage(
|
|
103
|
+
kv: CacheKVNamespace,
|
|
104
|
+
prefix = "deco-cache:v1:",
|
|
105
|
+
): CacheStorage {
|
|
106
|
+
const keyFor = async (key: string) => prefix + (await hashCacheKey(key));
|
|
107
|
+
return {
|
|
108
|
+
async get(key) {
|
|
109
|
+
return decode(await kv.get(await keyFor(key)));
|
|
110
|
+
},
|
|
111
|
+
async set(key, value, expiresAt) {
|
|
112
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
|
|
113
|
+
const raw = JSON.stringify({ value, expiresAt });
|
|
114
|
+
// Leave space below KV's 25 MiB value limit. Oversized cache entries are optional.
|
|
115
|
+
if (new TextEncoder().encode(raw).byteLength > 25 * 1024 * 1024) return;
|
|
116
|
+
await kv.put(await keyFor(key), raw, {
|
|
117
|
+
expirationTtl: Math.max(60, Math.ceil((expiresAt - Date.now()) / 1000)),
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
async delete(key) {
|
|
121
|
+
await kv.delete(await keyFor(key));
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The app supplies the native cache and its origin; no global API detection in consumers. */
|
|
127
|
+
export function createWebCacheStorage(
|
|
128
|
+
cache: Pick<Cache, "match" | "put" | "delete">,
|
|
129
|
+
origin: string,
|
|
130
|
+
): CacheStorage {
|
|
131
|
+
const requestFor = async (key: string) =>
|
|
132
|
+
new Request(new URL(`/__deco_cache/${await hashCacheKey(key)}`, origin));
|
|
133
|
+
return {
|
|
134
|
+
async get(key) {
|
|
135
|
+
const response = await cache.match(await requestFor(key));
|
|
136
|
+
return decode(response ? await response.text() : null);
|
|
137
|
+
},
|
|
138
|
+
async set(key, value, expiresAt) {
|
|
139
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
|
|
140
|
+
await cache.put(
|
|
141
|
+
await requestFor(key),
|
|
142
|
+
new Response(JSON.stringify({ value, expiresAt }), {
|
|
143
|
+
headers: {
|
|
144
|
+
"Cache-Control": `public, max-age=${Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000))}`,
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
async delete(key) {
|
|
150
|
+
await cache.delete(await requestFor(key));
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Reject values JSON would silently change, including dates, functions and circular references. */
|
|
156
|
+
function serialize(value: unknown): string | undefined {
|
|
157
|
+
const seen = new Set<object>();
|
|
158
|
+
function check(v: unknown): void {
|
|
159
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") return;
|
|
160
|
+
if (typeof v === "number" && Number.isFinite(v)) return;
|
|
161
|
+
// Optional object fields are common in section props. JSON omits them safely.
|
|
162
|
+
if (v === undefined) return;
|
|
163
|
+
if (typeof v !== "object" || seen.has(v)) throw new Error("Unsupported cached value");
|
|
164
|
+
if (
|
|
165
|
+
!Array.isArray(v) &&
|
|
166
|
+
Object.getPrototypeOf(v) !== Object.prototype &&
|
|
167
|
+
Object.getPrototypeOf(v) !== null
|
|
168
|
+
) {
|
|
169
|
+
throw new Error("Unsupported cached object");
|
|
170
|
+
}
|
|
171
|
+
seen.add(v);
|
|
172
|
+
for (const item of Object.values(v)) check(item);
|
|
173
|
+
seen.delete(v);
|
|
174
|
+
}
|
|
175
|
+
check(value);
|
|
176
|
+
return JSON.stringify(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Typed cache with a bounded, decoded memory tier. All remote access uses CacheStorage.
|
|
181
|
+
* Call key() once per operation so a content change cannot redirect an in-flight write.
|
|
182
|
+
* In-flight promises remain in the calling isolate; only completed results are persisted.
|
|
183
|
+
*/
|
|
184
|
+
export function createCacheStore<T>(
|
|
185
|
+
namespace: string,
|
|
186
|
+
maxEntries = 200,
|
|
187
|
+
maxBytes = 4 * 1024 * 1024,
|
|
188
|
+
sizeOf?: (value: T) => number,
|
|
189
|
+
) {
|
|
190
|
+
const local = new Map<string, { value: T; expiresAt: number; bytes: number }>();
|
|
191
|
+
let bytes = 0;
|
|
192
|
+
let generation = 0;
|
|
193
|
+
function remove(key: string) {
|
|
194
|
+
const entry = local.get(key);
|
|
195
|
+
if (entry) bytes -= entry.bytes;
|
|
196
|
+
local.delete(key);
|
|
197
|
+
}
|
|
198
|
+
function remember(key: string, value: T, expiresAt: number) {
|
|
199
|
+
remove(key);
|
|
200
|
+
let size: number;
|
|
201
|
+
try {
|
|
202
|
+
size = sizeOf
|
|
203
|
+
? sizeOf(value)
|
|
204
|
+
: Math.max(512, (key.length + (JSON.stringify(value)?.length ?? 0)) * 2);
|
|
205
|
+
} catch {
|
|
206
|
+
size = 1024;
|
|
207
|
+
}
|
|
208
|
+
if (size > maxBytes) return;
|
|
209
|
+
local.set(key, { value, expiresAt, bytes: size });
|
|
210
|
+
bytes += size;
|
|
211
|
+
while (local.size > 0 && (local.size > maxEntries || bytes > maxBytes))
|
|
212
|
+
remove(local.keys().next().value!);
|
|
213
|
+
}
|
|
214
|
+
function get(key: string): T | undefined {
|
|
215
|
+
if (getCacheStorageContext()?.disabled) return undefined;
|
|
216
|
+
const entry = local.get(key);
|
|
217
|
+
if (!entry) return undefined;
|
|
218
|
+
if (Date.now() >= entry.expiresAt) {
|
|
219
|
+
remove(key);
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
local.delete(key);
|
|
223
|
+
local.set(key, entry);
|
|
224
|
+
return entry.value;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
key(raw: string): string {
|
|
228
|
+
return JSON.stringify([getCacheStorageContext()?.scope ?? "local", namespace, raw]);
|
|
229
|
+
},
|
|
230
|
+
get,
|
|
231
|
+
async read(key: string): Promise<T | undefined> {
|
|
232
|
+
const hit = get(key);
|
|
233
|
+
if (hit !== undefined) return hit;
|
|
234
|
+
const context = getCacheStorageContext();
|
|
235
|
+
if (!context?.storage || context.disabled) return undefined;
|
|
236
|
+
const gen = generation;
|
|
237
|
+
try {
|
|
238
|
+
const raw = await context.storage.get(key);
|
|
239
|
+
if (gen !== generation) return undefined;
|
|
240
|
+
const concurrent = get(key);
|
|
241
|
+
if (concurrent !== undefined) return concurrent;
|
|
242
|
+
if (raw === null) return undefined;
|
|
243
|
+
const entry = JSON.parse(raw) as { value: T; expiresAt: number };
|
|
244
|
+
if (!Number.isFinite(entry.expiresAt) || Date.now() >= entry.expiresAt) return undefined;
|
|
245
|
+
remember(key, entry.value, entry.expiresAt);
|
|
246
|
+
return entry.value;
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
set(key: string, value: T, expiresAt: number, persist = true): void {
|
|
252
|
+
if (
|
|
253
|
+
getCacheStorageContext()?.disabled ||
|
|
254
|
+
!Number.isFinite(expiresAt) ||
|
|
255
|
+
expiresAt <= Date.now()
|
|
256
|
+
)
|
|
257
|
+
return;
|
|
258
|
+
remember(key, value, expiresAt);
|
|
259
|
+
const storage = getCacheStorageContext()?.storage;
|
|
260
|
+
if (!persist || !storage || !Number.isFinite(expiresAt)) return;
|
|
261
|
+
try {
|
|
262
|
+
const raw = serialize({ value, expiresAt });
|
|
263
|
+
if (raw !== undefined) cacheBackground(storage.set(key, raw, expiresAt));
|
|
264
|
+
} catch {
|
|
265
|
+
/* Non-serializable results remain in the memory tier. */
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
delete(key: string): void {
|
|
269
|
+
remove(key);
|
|
270
|
+
},
|
|
271
|
+
/** Local eviction only. Shared invalidation uses a new scope/version or storage.delete(). */
|
|
272
|
+
clear(): void {
|
|
273
|
+
local.clear();
|
|
274
|
+
bytes = 0;
|
|
275
|
+
generation++;
|
|
276
|
+
},
|
|
277
|
+
get size(): number {
|
|
278
|
+
return local.size;
|
|
279
|
+
},
|
|
280
|
+
get estimatedBytes(): number {
|
|
281
|
+
return bytes;
|
|
282
|
+
},
|
|
283
|
+
*entries(): IterableIterator<[string, T]> {
|
|
284
|
+
for (const [key, entry] of local) yield [key, entry.value];
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server-side loader caching with stale-while-revalidate + stale-if-error.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Caches commerce loader results in memory and optional app-provided storage.
|
|
5
5
|
* Supports:
|
|
6
6
|
* - Single-flight dedup (identical concurrent requests share one fetch)
|
|
7
7
|
* - SWR: serve stale immediately, refresh in background
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
withTracing,
|
|
20
20
|
} from "../middleware/observability";
|
|
21
21
|
import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
|
|
22
|
+
import { cacheBackground, createCacheStore, getCacheStorageContext } from "./cacheStorage";
|
|
22
23
|
import { withInflightTimeout } from "./inflightTimeout";
|
|
23
24
|
import { RequestContext } from "./requestContext";
|
|
24
25
|
|
|
@@ -29,14 +30,8 @@ import { RequestContext } from "./requestContext";
|
|
|
29
30
|
// pattern already used in `../cms/blockSource.ts`.
|
|
30
31
|
declare const __DECO_BUILD_HASH__: string | undefined;
|
|
31
32
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// lever: the cache Map is per-isolate, so a fresh isolate spun for a new deploy
|
|
35
|
-
// already starts empty, and a warm isolate keeps running its old bundle (hence
|
|
36
|
-
// its old BUILD) until it is recycled — the prefix cannot flush that. It only
|
|
37
|
-
// matters if this store ever becomes shared across builds. Gated on truthiness
|
|
38
|
-
// (empty string ⇒ unversioned) to match getBuildHash()/getDeploymentId() in
|
|
39
|
-
// workerEntry.ts / blockSource.ts.
|
|
33
|
+
// Include the build version even when called outside the TanStack worker. The
|
|
34
|
+
// request-scoped store additionally separates sites, content revisions and segments.
|
|
40
35
|
const BUILD =
|
|
41
36
|
typeof __DECO_BUILD_HASH__ !== "undefined" && __DECO_BUILD_HASH__ ? __DECO_BUILD_HASH__ : "";
|
|
42
37
|
|
|
@@ -56,6 +51,8 @@ export interface CachedLoaderOptions {
|
|
|
56
51
|
maxAge?: number;
|
|
57
52
|
/** How long to serve stale on origin error, in ms. Default: 0 (no error fallback). */
|
|
58
53
|
staleIfError?: number;
|
|
54
|
+
/** Maximum stale-while-revalidate window in ms. Default: 5 minutes. */
|
|
55
|
+
staleWhileRevalidate?: number;
|
|
59
56
|
/** Key function to generate a cache key from loader props. Default: JSON.stringify. */
|
|
60
57
|
keyFn?: (props: unknown) => string;
|
|
61
58
|
}
|
|
@@ -138,8 +135,12 @@ export function getLoaderCacheMaxBytes(): number {
|
|
|
138
135
|
return maxCacheBytes;
|
|
139
136
|
}
|
|
140
137
|
|
|
141
|
-
const cache =
|
|
142
|
-
|
|
138
|
+
const cache = createCacheStore<CacheEntry>(
|
|
139
|
+
"loaders",
|
|
140
|
+
Infinity,
|
|
141
|
+
Infinity,
|
|
142
|
+
(entry) => entry.estimatedBytes,
|
|
143
|
+
);
|
|
143
144
|
|
|
144
145
|
// Bumped by clearLoaderCache(). A loader invocation captures this at entry and
|
|
145
146
|
// only writes its result back if the generation is unchanged when it settles —
|
|
@@ -177,26 +178,22 @@ function estimateBytes(value: unknown): number {
|
|
|
177
178
|
* background-refresh callers route through here, which is why the size metric
|
|
178
179
|
* is emitted at this chokepoint rather than at each call site.
|
|
179
180
|
*/
|
|
180
|
-
function setCacheEntry<T>(key: string, entry: CacheEntry<T>, name: string) {
|
|
181
|
+
function setCacheEntry<T>(key: string, entry: CacheEntry<T>, name: string, retention: number) {
|
|
181
182
|
recordCacheSizeMetric(entry.estimatedBytes, name);
|
|
182
|
-
|
|
183
|
-
if (prev) cacheBytes -= prev.estimatedBytes;
|
|
184
|
-
cacheBytes += entry.estimatedBytes;
|
|
185
|
-
cache.set(key, entry);
|
|
183
|
+
cache.set(key, entry, entry.createdAt + retention);
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
function deleteCacheEntry(key: string) {
|
|
189
|
-
const prev = cache.get(key);
|
|
190
|
-
if (!prev) return;
|
|
191
|
-
cacheBytes -= prev.estimatedBytes;
|
|
192
187
|
cache.delete(key);
|
|
193
188
|
}
|
|
194
189
|
|
|
195
190
|
function evictIfNeeded() {
|
|
191
|
+
let cacheBytes = cache.estimatedBytes;
|
|
196
192
|
if (cacheBytes <= maxCacheBytes) return;
|
|
197
193
|
const oldest = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
198
|
-
for (const [key] of oldest) {
|
|
194
|
+
for (const [key, entry] of oldest) {
|
|
199
195
|
deleteCacheEntry(key);
|
|
196
|
+
cacheBytes -= entry.estimatedBytes;
|
|
200
197
|
if (cacheBytes <= maxCacheBytes) break;
|
|
201
198
|
}
|
|
202
199
|
}
|
|
@@ -237,7 +234,13 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
237
234
|
optionsOrProfile: CachedLoaderOptions | CacheProfileName,
|
|
238
235
|
): (props: TProps) => Promise<TResult> {
|
|
239
236
|
const resolved = resolveOptions(optionsOrProfile);
|
|
240
|
-
const {
|
|
237
|
+
const {
|
|
238
|
+
policy,
|
|
239
|
+
maxAge = DEFAULT_MAX_AGE,
|
|
240
|
+
staleIfError = 0,
|
|
241
|
+
staleWhileRevalidate = 300_000,
|
|
242
|
+
keyFn = JSON.stringify,
|
|
243
|
+
} = resolved;
|
|
241
244
|
|
|
242
245
|
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
243
246
|
const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
|
|
@@ -247,14 +250,17 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
247
250
|
console.warn(
|
|
248
251
|
`[cachedLoader] ${name}: maxAge=${Math.round(maxAge / 1000)}s is very long — ` +
|
|
249
252
|
`upstream changes take that long to propagate. Prefer a short window and use ` +
|
|
250
|
-
`
|
|
253
|
+
`short TTLs for shared storage; the purge-loaders endpoint clears memory only.`,
|
|
251
254
|
);
|
|
252
255
|
}
|
|
253
256
|
|
|
254
257
|
if (policy === "no-store") return loaderFn;
|
|
255
258
|
|
|
256
259
|
return async (props: TProps): Promise<TResult> => {
|
|
257
|
-
const cacheKey = `${BUILD}::${name}::${keyFn(props)}
|
|
260
|
+
const cacheKey = cache.key(`${BUILD}::${name}::${keyFn(props)}`);
|
|
261
|
+
const retention =
|
|
262
|
+
maxAge +
|
|
263
|
+
Math.max(staleIfError, policy === "stale-while-revalidate" ? staleWhileRevalidate : 0);
|
|
258
264
|
// Snapshot the cache generation; a purge during this invocation bumps it,
|
|
259
265
|
// and the deferred writes below skip repopulating a just-cleared entry.
|
|
260
266
|
const gen = cacheGeneration;
|
|
@@ -295,7 +301,14 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
295
301
|
return promise;
|
|
296
302
|
}
|
|
297
303
|
|
|
298
|
-
|
|
304
|
+
let entry = cache.get(cacheKey) as CacheEntry<TResult> | undefined;
|
|
305
|
+
if (!entry && getCacheStorageContext()?.storage) {
|
|
306
|
+
entry = (await cache.read(cacheKey)) as CacheEntry<TResult> | undefined;
|
|
307
|
+
evictIfNeeded();
|
|
308
|
+
}
|
|
309
|
+
// Another caller may have started the origin fetch while storage was being read.
|
|
310
|
+
const pending = inflightRequests.get(cacheKey);
|
|
311
|
+
if (pending) return pending as Promise<TResult>;
|
|
299
312
|
const now = Date.now();
|
|
300
313
|
const isStale = entry ? now - entry.createdAt > maxAge : true;
|
|
301
314
|
|
|
@@ -314,45 +327,52 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
314
327
|
return entry.value;
|
|
315
328
|
}
|
|
316
329
|
|
|
317
|
-
if (
|
|
330
|
+
if (
|
|
331
|
+
entry &&
|
|
332
|
+
isStale &&
|
|
333
|
+
now - entry.createdAt <= maxAge + staleWhileRevalidate &&
|
|
334
|
+
!entry.refreshing
|
|
335
|
+
) {
|
|
318
336
|
// Stale-while-revalidate hit: serve stale, refresh in background.
|
|
319
337
|
recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
|
|
320
338
|
recordLoaderMetric(name, 0, "STALE-HIT");
|
|
321
339
|
entry.refreshing = true;
|
|
322
|
-
|
|
323
|
-
.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
340
|
+
cacheBackground(
|
|
341
|
+
Promise.resolve()
|
|
342
|
+
.then(() => loaderFn(props))
|
|
343
|
+
.then((result) => {
|
|
344
|
+
// Skip the write if a purge cleared the cache mid-refresh — otherwise
|
|
345
|
+
// we'd re-insert pre-purge data the purge was meant to drop.
|
|
346
|
+
if (gen !== cacheGeneration) return;
|
|
347
|
+
setCacheEntry(
|
|
348
|
+
cacheKey,
|
|
349
|
+
{
|
|
350
|
+
value: result,
|
|
351
|
+
createdAt: Date.now(),
|
|
352
|
+
refreshing: false,
|
|
353
|
+
estimatedBytes: estimateBytes(result),
|
|
354
|
+
},
|
|
355
|
+
name,
|
|
356
|
+
retention,
|
|
357
|
+
);
|
|
358
|
+
evictIfNeeded();
|
|
359
|
+
})
|
|
360
|
+
.catch(() => {
|
|
361
|
+
// Background refresh failed — entry stays stale.
|
|
362
|
+
// If past the SIE window, evict so we don't serve indefinitely stale data.
|
|
363
|
+
entry.refreshing = false;
|
|
364
|
+
if (staleIfError > 0 && now - entry.createdAt > maxAge + staleIfError) {
|
|
365
|
+
deleteCacheEntry(cacheKey);
|
|
366
|
+
}
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
347
369
|
return entry.value;
|
|
348
370
|
}
|
|
349
371
|
|
|
350
|
-
if (entry) {
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
recordCacheMetric(true, name, "STALE-ERROR", "cachedLoader");
|
|
355
|
-
recordLoaderMetric(name, 0, "STALE-ERROR");
|
|
372
|
+
if (entry && now - entry.createdAt <= maxAge + staleWhileRevalidate) {
|
|
373
|
+
// A background refresh is already running in this isolate.
|
|
374
|
+
recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
|
|
375
|
+
recordLoaderMetric(name, 0, "STALE-HIT");
|
|
356
376
|
return entry.value;
|
|
357
377
|
}
|
|
358
378
|
}
|
|
@@ -382,6 +402,7 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
382
402
|
estimatedBytes: estimateBytes(result),
|
|
383
403
|
},
|
|
384
404
|
name,
|
|
405
|
+
retention,
|
|
385
406
|
);
|
|
386
407
|
evictIfNeeded();
|
|
387
408
|
}
|
|
@@ -390,7 +411,7 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
390
411
|
.catch((err) => {
|
|
391
412
|
// SIE fallback: if we have a stale entry within the error window, return it
|
|
392
413
|
if (staleIfError > 0 && entry) {
|
|
393
|
-
const age = now - entry.createdAt;
|
|
414
|
+
const age = Date.now() - entry.createdAt;
|
|
394
415
|
if (age < maxAge + staleIfError) {
|
|
395
416
|
console.warn(
|
|
396
417
|
`[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
|
|
@@ -468,7 +489,7 @@ export function createCachedLoaderFromModule<TProps, TResult>(
|
|
|
468
489
|
// Explicit null → the loader declared this call uncacheable: run fresh.
|
|
469
490
|
if (keyPart === null) return mod.default(props, req);
|
|
470
491
|
|
|
471
|
-
const key = `${BUILD}::${name}::${keyPart}
|
|
492
|
+
const key = cache.key(`${BUILD}::${name}::${keyPart}`);
|
|
472
493
|
const existing = moduleInflight.get(key) as Promise<TResult> | undefined;
|
|
473
494
|
if (existing) return existing;
|
|
474
495
|
|
|
@@ -512,15 +533,13 @@ export function createLoaderEntry<TProps = any, TResult = any>(
|
|
|
512
533
|
/**
|
|
513
534
|
* Clear all cached entries in THIS isolate. Used both by decofile hot-reload
|
|
514
535
|
* (`@decocms/blocks-admin`) and by the `POST /_cache/purge-loaders` route — an
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* for every isolate; a redeploy replaces isolates wholesale. Bumps the cache
|
|
536
|
+
* memory eviction hook. Shared storage entries expire by TTL and may hydrate
|
|
537
|
+
* memory again. Deployment/content scopes prevent reuse across versions. Bumps the cache
|
|
518
538
|
* generation so any loader in flight at purge time won't repopulate a cleared
|
|
519
539
|
* entry with pre-purge data.
|
|
520
540
|
*/
|
|
521
541
|
export function clearLoaderCache() {
|
|
522
542
|
cache.clear();
|
|
523
|
-
cacheBytes = 0;
|
|
524
543
|
inflightRequests.clear();
|
|
525
544
|
moduleInflight.clear();
|
|
526
545
|
cacheGeneration++;
|
|
@@ -531,7 +550,7 @@ export function getLoaderCacheStats() {
|
|
|
531
550
|
return {
|
|
532
551
|
entries: cache.size,
|
|
533
552
|
inflight: inflightRequests.size,
|
|
534
|
-
estimatedBytes:
|
|
553
|
+
estimatedBytes: cache.estimatedBytes,
|
|
535
554
|
maxBytes: maxCacheBytes,
|
|
536
555
|
};
|
|
537
556
|
}
|
package/src/sdk/encoding.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function encodeBase64(data: ArrayBuffer | Uint8Array | string): string {
|
|
|
26
26
|
return btoa(bin);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function decodeBase64(b64: string): Uint8Array {
|
|
29
|
+
export function decodeBase64(b64: string): Uint8Array<ArrayBuffer> {
|
|
30
30
|
const bin = atob(b64);
|
|
31
31
|
const out = new Uint8Array(bin.length);
|
|
32
32
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
@@ -34,10 +34,7 @@ export function decodeBase64(b64: string): Uint8Array {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function encodeBase64Url(data: ArrayBuffer | Uint8Array | string): string {
|
|
37
|
-
return encodeBase64(data)
|
|
38
|
-
.replace(/\+/g, "-")
|
|
39
|
-
.replace(/\//g, "_")
|
|
40
|
-
.replace(/=+$/, "");
|
|
37
|
+
return encodeBase64(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
export function decodeBase64Url(b64url: string): Uint8Array {
|