zuplo 7.0.0 → 7.0.2

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.
Files changed (57) hide show
  1. package/docs/articles/ci-cd-azure/basic-deployment.mdx +2 -2
  2. package/docs/articles/ci-cd-azure/deploy-and-test.mdx +1 -1
  3. package/docs/articles/ci-cd-azure/local-testing.mdx +2 -2
  4. package/docs/articles/ci-cd-azure/multi-stage-deployment.mdx +3 -3
  5. package/docs/articles/ci-cd-azure/pr-preview-environments.mdx +1 -1
  6. package/docs/articles/ci-cd-azure/tag-based-releases.mdx +1 -1
  7. package/docs/articles/ci-cd-bitbucket/basic-deployment.mdx +1 -1
  8. package/docs/articles/ci-cd-bitbucket/deploy-and-test.mdx +1 -1
  9. package/docs/articles/ci-cd-bitbucket/local-testing.mdx +1 -1
  10. package/docs/articles/ci-cd-bitbucket/multi-stage-deployment.mdx +1 -1
  11. package/docs/articles/ci-cd-bitbucket/pr-preview-environments.mdx +1 -1
  12. package/docs/articles/ci-cd-bitbucket/tag-based-releases.mdx +1 -1
  13. package/docs/articles/ci-cd-circleci/basic-deployment.mdx +1 -1
  14. package/docs/articles/ci-cd-circleci/deploy-and-test.mdx +1 -1
  15. package/docs/articles/ci-cd-circleci/local-testing.mdx +2 -2
  16. package/docs/articles/ci-cd-circleci/multi-stage-deployment.mdx +3 -3
  17. package/docs/articles/ci-cd-circleci/pr-preview-environments.mdx +1 -1
  18. package/docs/articles/ci-cd-circleci/tag-based-releases.mdx +1 -1
  19. package/docs/articles/ci-cd-github/basic-deployment.mdx +1 -1
  20. package/docs/articles/ci-cd-github/cleanup-on-branch-delete.mdx +2 -2
  21. package/docs/articles/ci-cd-github/deploy-and-test.mdx +1 -1
  22. package/docs/articles/ci-cd-github/local-testing.mdx +2 -2
  23. package/docs/articles/ci-cd-github/multi-stage-deployment.mdx +3 -3
  24. package/docs/articles/ci-cd-github/pr-preview-environments.mdx +2 -2
  25. package/docs/articles/ci-cd-github/tag-based-releases.mdx +1 -1
  26. package/docs/articles/ci-cd-gitlab/basic-deployment.mdx +1 -1
  27. package/docs/articles/ci-cd-gitlab/deploy-and-test.mdx +1 -1
  28. package/docs/articles/ci-cd-gitlab/local-testing.mdx +1 -1
  29. package/docs/articles/ci-cd-gitlab/mr-preview-environments.mdx +1 -1
  30. package/docs/articles/ci-cd-gitlab/multi-stage-deployment.mdx +1 -1
  31. package/docs/articles/ci-cd-gitlab/tag-based-releases.mdx +1 -1
  32. package/docs/articles/github-deployment-testing.mdx +1 -1
  33. package/docs/articles/graphql-caching.mdx +2 -0
  34. package/docs/articles/local-development.mdx +4 -4
  35. package/docs/articles/monorepo-deployment.mdx +3 -3
  36. package/docs/articles/sharing-code-across-projects.mdx +2 -2
  37. package/docs/articles/step-1-setup-basic-gateway-local.mdx +1 -1
  38. package/docs/articles/testing.mdx +5 -5
  39. package/docs/articles/troubleshooting-slow-responses.mdx +5 -0
  40. package/docs/articles/update-zup-in-github-action.mdx +1 -1
  41. package/docs/caching/cdn-caching.mdx +397 -0
  42. package/docs/caching/custom-caching-policy.mdx +426 -0
  43. package/docs/caching/dynamic-cache-rules.mdx +427 -0
  44. package/docs/caching/gateway-caching.mdx +308 -0
  45. package/docs/caching/overview.mdx +150 -0
  46. package/docs/caching/partial-response-caching.mdx +641 -0
  47. package/docs/cli/overview.mdx +2 -3
  48. package/docs/dedicated/akamai/caching.mdx +245 -140
  49. package/docs/dev-portal/local-development.mdx +1 -1
  50. package/docs/dev-portal/migration.mdx +1 -1
  51. package/docs/mcp-gateway/quickstart-local.mdx +1 -1
  52. package/docs/policies/api-key-inbound/schema.json +4 -2
  53. package/docs/programmable-api/cache.mdx +7 -0
  54. package/docs/programmable-api/memory-zone-read-through-cache.mdx +2 -0
  55. package/docs/programmable-api/streaming-zone-cache.mdx +2 -0
  56. package/docs/programmable-api/zone-cache.mdx +7 -0
  57. package/package.json +5 -5
@@ -0,0 +1,641 @@
1
+ ---
2
+ title: Cache part of a response
3
+ description:
4
+ Cache the shared portion of a response body in the programmable cache and
5
+ fetch only the small caller-specific slice from your backend on every request.
6
+ A worked example that turns a thousand backend calls a minute into six.
7
+ ---
8
+
9
+ A cinema-ticketing API serves `GET /v1/showtimes/board`. Every response contains
10
+ two very different kinds of data:
11
+
12
+ - **Citywide state.** Every showtime for the next seven days across 300 screens,
13
+ plus the cinema and movie metadata each one points at. Roughly 180 KB, and it
14
+ takes the backend about 340 ms to assemble. Every caller receives exactly the
15
+ same bytes.
16
+ - **Caller-specific state.** The requesting account's seat holds and its loyalty
17
+ balance. Roughly 4 KB, and it takes the backend about 35 ms.
18
+
19
+ The shared part is 90% of the backend's work and 98% of the bytes, and it's
20
+ identical for every one of the thousand callers who hit the endpoint each
21
+ minute. The backend builds it a thousand times a minute anyway, because it's
22
+ glued to a 4 KB slice that differs per caller.
23
+
24
+ Caching the shared fragment for 10 seconds fixes that. A 10-second freshness
25
+ window means the snapshot is fetched at most six times a minute, no matter
26
+ whether a thousand or a hundred thousand requests arrive. Backend calls for the
27
+ shared fragment stop scaling with traffic and start scaling with the clock.
28
+
29
+ | Per minute, at 1,000 requests | Before | After |
30
+ | -------------------------------- | ------- | ------- |
31
+ | Backend calls for the snapshot | 1,000 | 6 |
32
+ | Backend calls for account data | 1,000 | 1,000 |
33
+ | Bytes read from the backend | ~184 MB | ~5.1 MB |
34
+ | Backend time on the request path | ~375 ms | ~35 ms |
35
+
36
+ At that rate the backend sends 265 GB a day before the change and 7.3 GB a day
37
+ after it: a 97% cut in egress, and a 99.4% cut in the expensive call. The
38
+ gateway still makes a thousand backend calls a minute, but they're the cheap
39
+ ones.
40
+
41
+ ## Why the coarser cache layers can't help
42
+
43
+ A [CDN](./cdn-caching.mdx) can't cache this response. Caching at the edge
44
+ requires that two callers holding the same cache key receive identical bytes,
45
+ and no two callers ever do, because one account's seat holds are in the body.
46
+ Adding the API key to the cache key technically works, and it's the wrong shape:
47
+ it stores a thousand near-duplicate 184 KB objects instead of one shared 180 KB
48
+ object, each expires on its own schedule, and each miss pays the full backend
49
+ cost. For the long tail of callers who show up once every few minutes, the hit
50
+ rate rounds to zero.
51
+
52
+ The [gateway response cache](./gateway-caching.mdx) has the same problem, plus a
53
+ sharper edge. If the cache key omits the caller's identity, one account receives
54
+ another account's seat holds. That's a correctness bug, not a tuning problem.
55
+
56
+ Both layers cache **responses**. The unit actually cacheable here is a
57
+ **fragment** of a response, and nothing outside the request can see fragments.
58
+ Splitting a payload into a shared part and a personal part requires code that
59
+ understands what the payload means, which means code running inside the request,
60
+ with a cache it can address directly.
61
+
62
+ ## The two request paths
63
+
64
+ Before, every request pulls the entire payload from the backend:
65
+
66
+ <Diagram height="h-48">
67
+ <DiagramNode id="clients">1,000 requests/min</DiagramNode>
68
+ <DiagramNode id="gateway" variant="zuplo">
69
+ Zuplo gateway
70
+ </DiagramNode>
71
+ <DiagramNode id="backend" variant="orange">
72
+ Backend
73
+ </DiagramNode>
74
+ <DiagramEdge from="clients" to="gateway" />
75
+ <DiagramEdge
76
+ from="gateway"
77
+ to="backend"
78
+ label="1,000 full payloads/min"
79
+ variant="orange"
80
+ />
81
+ </Diagram>
82
+
83
+ After, the shared fragment comes from the cache and only the small per-caller
84
+ call reaches the backend:
85
+
86
+ <Diagram height="h-72">
87
+ <DiagramNode id="clients">1,000 requests/min</DiagramNode>
88
+ <DiagramNode id="handler" variant="zuplo">
89
+ Custom handler
90
+ </DiagramNode>
91
+ <DiagramNode id="cache" variant="green">
92
+ ZoneCache
93
+ </DiagramNode>
94
+ <DiagramNode id="backend" variant="orange">
95
+ Backend
96
+ </DiagramNode>
97
+ <DiagramEdge from="clients" to="handler" />
98
+ <DiagramEdge
99
+ from="handler"
100
+ to="cache"
101
+ label="snapshot: 1,000 hits/min"
102
+ variant="green"
103
+ />
104
+ <DiagramEdge
105
+ from="cache"
106
+ to="backend"
107
+ label="refresh: 6/min"
108
+ lineStyle="dashed"
109
+ variant="orange"
110
+ />
111
+ <DiagramEdge
112
+ from="handler"
113
+ to="backend"
114
+ label="account: 1,000/min"
115
+ variant="orange"
116
+ fromSide="bottom"
117
+ toSide="bottom"
118
+ />
119
+ </Diagram>
120
+
121
+ ## Split the backend call in two
122
+
123
+ Start from the handler that doesn't split anything. It forwards one request and
124
+ returns one response, and there is nothing in it a cache can grab hold of.
125
+
126
+ ```ts title="modules/showtime-board.ts"
127
+ import { environment, ZuploContext, ZuploRequest } from "@zuplo/runtime";
128
+
129
+ export default async function handler(
130
+ request: ZuploRequest,
131
+ context: ZuploContext,
132
+ ) {
133
+ const accountId = request.user?.data.accountId;
134
+
135
+ return fetch(
136
+ `${environment.TICKETING_API_URL}/v1/showtimes/board/${accountId}`,
137
+ {
138
+ headers: { authorization: `Bearer ${environment.TICKETING_API_KEY}` },
139
+ },
140
+ );
141
+ }
142
+ ```
143
+
144
+ The backend needs to expose the two halves separately before anything can be
145
+ cached: one endpoint for the citywide snapshot, one for a single account. Most
146
+ APIs shaped like this already have both, because the composite endpoint was
147
+ built on top of them.
148
+
149
+ ```ts title="modules/showtime-board.ts"
150
+ import { environment } from "@zuplo/runtime";
151
+
152
+ interface ShowtimeSnapshot {
153
+ asOf: string;
154
+ cinemas: { id: string; name: string; screens: number }[];
155
+ showtimes: {
156
+ id: string;
157
+ cinemaId: string;
158
+ movieId: string;
159
+ startsAt: string;
160
+ seatsAvailable: number;
161
+ }[];
162
+ }
163
+
164
+ interface AccountState {
165
+ holds: { showtimeId: string; seats: string[]; expiresAt: string }[];
166
+ loyalty: { points: number; tier: string };
167
+ }
168
+
169
+ async function fetchJson<T>(path: string): Promise<T> {
170
+ const response = await fetch(`${environment.TICKETING_API_URL}${path}`, {
171
+ headers: { authorization: `Bearer ${environment.TICKETING_API_KEY}` },
172
+ });
173
+ if (!response.ok) {
174
+ throw new Error(`Backend request to ${path} failed: ${response.status}`);
175
+ }
176
+ return (await response.json()) as T;
177
+ }
178
+
179
+ const fetchShowtimeSnapshot = () =>
180
+ fetchJson<ShowtimeSnapshot>("/v1/showtimes/snapshot");
181
+
182
+ const fetchAccountState = (accountId: string) =>
183
+ fetchJson<AccountState>(`/v1/accounts/${accountId}`);
184
+ ```
185
+
186
+ Two calls where there was one looks like a step backwards, and on a cache miss
187
+ it is: the handler pays both round trips. The point is that one of the two is
188
+ now addressable on its own.
189
+
190
+ `fetchAccountState` never gets cached. It runs on every request, for every
191
+ caller, and it is the reason the composed response carries
192
+ `Cache-Control: no-store` at the end.
193
+
194
+ ## Cache the shared fragment
195
+
196
+ [ZoneCache](../programmable-api/zone-cache.mdx) stores JSON-serializable values
197
+ under a string key with a per-item TTL, shared across the isolates in one zone.
198
+ The snapshot is JSON and every caller wants the same copy, so it fits exactly.
199
+
200
+ ```ts title="modules/showtime-board.ts"
201
+ import { ZoneCache, ZuploContext } from "@zuplo/runtime";
202
+
203
+ const SNAPSHOT_KEY = "showtime-snapshot:v1";
204
+ const FRESH_FOR_SECONDS = 10;
205
+
206
+ async function getShowtimeSnapshot(
207
+ context: ZuploContext,
208
+ ): Promise<ShowtimeSnapshot> {
209
+ const cache = new ZoneCache<ShowtimeSnapshot>("showtimes", context);
210
+
211
+ const cached = await cache.get(SNAPSHOT_KEY);
212
+ if (cached) {
213
+ return cached;
214
+ }
215
+
216
+ const snapshot = await fetchShowtimeSnapshot();
217
+ await cache.put(SNAPSHOT_KEY, snapshot, FRESH_FOR_SECONDS);
218
+ return snapshot;
219
+ }
220
+ ```
221
+
222
+ Ten seconds is a freshness budget, not a round number. Work it out from the
223
+ product, in this order:
224
+
225
+ 1. **How fast does the source change?** The seat-inventory feed publishes every
226
+ two seconds. Caching for less than that buys nothing, because the backend
227
+ returns the same numbers.
228
+ 2. **What has the API promised?** If the documented contract says seat counts
229
+ are no more than 15 seconds old, that is the hard ceiling.
230
+ 3. **Leave room for the refresh itself.** A refresh that takes 340 ms against a
231
+ 15-second ceiling wants a TTL comfortably under it. Ten seconds leaves five
232
+ seconds of margin.
233
+
234
+ The `:v1` suffix on the key is worth the four characters. When
235
+ `ShowtimeSnapshot` gains or renames a field, bump it to `:v2` and the deploy
236
+ reads a cold key instead of deserializing old-shaped objects into new-shaped
237
+ types.
238
+
239
+ ## Compose the response
240
+
241
+ The handler fetches both halves in parallel and merges them into the body the
242
+ client already expects. The response shape does not change, so no consumer has
243
+ to be told about any of this.
244
+
245
+ ```ts title="modules/showtime-board.ts"
246
+ import { HttpProblems, ZuploContext, ZuploRequest } from "@zuplo/runtime";
247
+
248
+ export default async function handler(
249
+ request: ZuploRequest,
250
+ context: ZuploContext,
251
+ ) {
252
+ const accountId = request.user?.data.accountId;
253
+ if (!accountId) {
254
+ return HttpProblems.unauthorized(request, context);
255
+ }
256
+
257
+ const [snapshot, account] = await Promise.all([
258
+ getShowtimeSnapshot(context),
259
+ fetchAccountState(accountId),
260
+ ]);
261
+
262
+ return new Response(
263
+ JSON.stringify({
264
+ asOf: snapshot.asOf,
265
+ cinemas: snapshot.cinemas,
266
+ showtimes: snapshot.showtimes,
267
+ account,
268
+ }),
269
+ {
270
+ status: 200,
271
+ headers: {
272
+ "content-type": "application/json",
273
+ "cache-control": "no-store",
274
+ },
275
+ },
276
+ );
277
+ }
278
+ ```
279
+
280
+ `Promise.all` matters here. On a cache miss the handler pays
281
+ `max(340 ms, 35 ms)` instead of `340 ms + 35 ms`, and on a hit the account call
282
+ is the only thing left on the critical path.
283
+
284
+ :::caution{title="The composed response is not cacheable"}
285
+
286
+ `Cache-Control: no-store` is deliberate. The body now contains one account's
287
+ seat holds, so it must not land in a CDN, a browser, or the gateway response
288
+ cache. Caching happens strictly inside the handler, at the fragment level.
289
+
290
+ :::
291
+
292
+ ## Collapse concurrent misses
293
+
294
+ The snapshot expires ten seconds after it is written. At a thousand requests a
295
+ minute (call it 17 a second), every request in flight at that instant sees an
296
+ empty cache and calls the backend. The backend gets a burst of simultaneous
297
+ snapshot requests, each one taking 340 ms, and during those 340 ms more requests
298
+ arrive and pile onto the same burst. That is a cache stampede, and it lands
299
+ hardest on exactly the backend the cache was supposed to protect.
300
+
301
+ A module-scoped promise map fixes it. The first miss starts the fetch and stores
302
+ the promise; every other miss for the same key awaits that promise instead of
303
+ starting its own.
304
+
305
+ ```ts title="modules/showtime-board.ts"
306
+ const inFlight = new Map<string, Promise<ShowtimeSnapshot>>();
307
+
308
+ function loadOnce(
309
+ key: string,
310
+ load: () => Promise<ShowtimeSnapshot>,
311
+ ): Promise<ShowtimeSnapshot> {
312
+ const existing = inFlight.get(key);
313
+ if (existing) {
314
+ return existing;
315
+ }
316
+
317
+ const pending = load().finally(() => inFlight.delete(key));
318
+ inFlight.set(key, pending);
319
+ return pending;
320
+ }
321
+ ```
322
+
323
+ Module scope in a Zuplo project means isolate scope. The map lives as long as
324
+ the isolate that loaded the module, and every request served by that isolate
325
+ shares it.
326
+
327
+ :::note
328
+
329
+ This guard collapses concurrent misses **within one isolate**, not globally. Two
330
+ isolates that miss at the same moment still make two backend calls, and a zone
331
+ running twenty isolates can make twenty. That's the honest ceiling, and it's a
332
+ fixed, small number set by your isolate count, rather than a number that grows
333
+ with request volume.
334
+
335
+ :::
336
+
337
+ ## Serve stale on backend failure
338
+
339
+ Once a shared fragment is cached, a failed refresh does not have to become a
340
+ failed request. Keep the cached copy alive well past its freshness window and
341
+ fall back to it when the backend is down.
342
+
343
+ Store the snapshot with a timestamp under a long TTL, and treat freshness as
344
+ something the code decides rather than something the cache enforces. The cache
345
+ now holds an entry rather than a bare snapshot, and it holds that entry far
346
+ longer than the snapshot stays fresh:
347
+
348
+ ```ts title="modules/showtime-board.ts"
349
+ interface SnapshotEntry {
350
+ snapshot: ShowtimeSnapshot;
351
+ storedAt: number;
352
+ }
353
+
354
+ const FRESH_FOR_SECONDS = 10;
355
+ const SERVE_STALE_FOR_SECONDS = 300;
356
+ ```
357
+
358
+ `getShowtimeSnapshot` now opens the cache as `ZoneCache<SnapshotEntry>`,
359
+ compares `storedAt` against `FRESH_FOR_SECONDS`, returns the age alongside the
360
+ snapshot, and logs a warning when it falls back so that stale serves show up in
361
+ your logs. The full function is in [The complete handler](#the-complete-handler)
362
+ below; these are the lines that carry the behavior:
363
+
364
+ ```ts title="modules/showtime-board.ts"
365
+ const entry = await cache.get(SNAPSHOT_KEY);
366
+ const ageSeconds = entry ? (Date.now() - entry.storedAt) / 1000 : Infinity;
367
+
368
+ if (entry && ageSeconds < FRESH_FOR_SECONDS) {
369
+ return { snapshot: entry.snapshot, ageSeconds };
370
+ }
371
+
372
+ try {
373
+ const snapshot = await loadOnce(SNAPSHOT_KEY, async () => {
374
+ const fresh = await fetchShowtimeSnapshot();
375
+ await cache.put(
376
+ SNAPSHOT_KEY,
377
+ { snapshot: fresh, storedAt: Date.now() },
378
+ SERVE_STALE_FOR_SECONDS,
379
+ );
380
+ return fresh;
381
+ });
382
+ return { snapshot, ageSeconds: 0 };
383
+ } catch (error) {
384
+ if (entry) {
385
+ return { snapshot: entry.snapshot, ageSeconds };
386
+ }
387
+ throw error;
388
+ }
389
+ ```
390
+
391
+ The cache TTL is now 300 seconds and the freshness window is 10. Between those
392
+ two numbers sits a copy nobody is served under normal conditions and everybody
393
+ is served during an outage. A five-minute backend failure degrades the endpoint
394
+ from "seat counts at most 10 seconds old" to "seat counts up to 5 minutes old"
395
+ instead of returning `502` to every caller.
396
+
397
+ For a lot of APIs that availability win is worth more than the cost saving that
398
+ motivated the change. Surface the age so clients can decide for themselves:
399
+ `x-snapshot-age` in the complete handler below is an integer count of seconds.
400
+
401
+ ## The complete handler
402
+
403
+ Everything above, in one file you can drop into `modules/` and adapt.
404
+
405
+ ```ts title="modules/showtime-board.ts"
406
+ import {
407
+ environment,
408
+ HttpProblems,
409
+ ZoneCache,
410
+ ZuploContext,
411
+ ZuploRequest,
412
+ } from "@zuplo/runtime";
413
+
414
+ interface ShowtimeSnapshot {
415
+ asOf: string;
416
+ cinemas: { id: string; name: string; screens: number }[];
417
+ showtimes: {
418
+ id: string;
419
+ cinemaId: string;
420
+ movieId: string;
421
+ startsAt: string;
422
+ seatsAvailable: number;
423
+ }[];
424
+ }
425
+
426
+ interface AccountState {
427
+ holds: { showtimeId: string; seats: string[]; expiresAt: string }[];
428
+ loyalty: { points: number; tier: string };
429
+ }
430
+
431
+ interface SnapshotEntry {
432
+ snapshot: ShowtimeSnapshot;
433
+ storedAt: number;
434
+ }
435
+
436
+ const SNAPSHOT_KEY = "showtime-snapshot:v1";
437
+ const FRESH_FOR_SECONDS = 10;
438
+ const SERVE_STALE_FOR_SECONDS = 300;
439
+
440
+ const inFlight = new Map<string, Promise<ShowtimeSnapshot>>();
441
+
442
+ function loadOnce(
443
+ key: string,
444
+ load: () => Promise<ShowtimeSnapshot>,
445
+ ): Promise<ShowtimeSnapshot> {
446
+ const existing = inFlight.get(key);
447
+ if (existing) {
448
+ return existing;
449
+ }
450
+
451
+ const pending = load().finally(() => inFlight.delete(key));
452
+ inFlight.set(key, pending);
453
+ return pending;
454
+ }
455
+
456
+ async function fetchJson<T>(path: string): Promise<T> {
457
+ const response = await fetch(`${environment.TICKETING_API_URL}${path}`, {
458
+ headers: { authorization: `Bearer ${environment.TICKETING_API_KEY}` },
459
+ });
460
+ if (!response.ok) {
461
+ throw new Error(`Backend request to ${path} failed: ${response.status}`);
462
+ }
463
+ return (await response.json()) as T;
464
+ }
465
+
466
+ const fetchShowtimeSnapshot = () =>
467
+ fetchJson<ShowtimeSnapshot>("/v1/showtimes/snapshot");
468
+
469
+ const fetchAccountState = (accountId: string) =>
470
+ fetchJson<AccountState>(`/v1/accounts/${accountId}`);
471
+
472
+ async function getShowtimeSnapshot(
473
+ context: ZuploContext,
474
+ ): Promise<{ snapshot: ShowtimeSnapshot; ageSeconds: number }> {
475
+ const cache = new ZoneCache<SnapshotEntry>("showtimes", context);
476
+ const entry = await cache.get(SNAPSHOT_KEY);
477
+ const ageSeconds = entry ? (Date.now() - entry.storedAt) / 1000 : Infinity;
478
+
479
+ if (entry && ageSeconds < FRESH_FOR_SECONDS) {
480
+ return { snapshot: entry.snapshot, ageSeconds };
481
+ }
482
+
483
+ try {
484
+ const snapshot = await loadOnce(SNAPSHOT_KEY, async () => {
485
+ const fresh = await fetchShowtimeSnapshot();
486
+ await cache.put(
487
+ SNAPSHOT_KEY,
488
+ { snapshot: fresh, storedAt: Date.now() },
489
+ SERVE_STALE_FOR_SECONDS,
490
+ );
491
+ return fresh;
492
+ });
493
+ return { snapshot, ageSeconds: 0 };
494
+ } catch (error) {
495
+ if (entry) {
496
+ context.log.warn("Snapshot refresh failed, serving stale copy", {
497
+ ageSeconds,
498
+ error: String(error),
499
+ });
500
+ return { snapshot: entry.snapshot, ageSeconds };
501
+ }
502
+ throw error;
503
+ }
504
+ }
505
+
506
+ export default async function handler(
507
+ request: ZuploRequest,
508
+ context: ZuploContext,
509
+ ) {
510
+ const accountId = request.user?.data.accountId;
511
+ if (!accountId) {
512
+ return HttpProblems.unauthorized(request, context);
513
+ }
514
+
515
+ try {
516
+ const [shared, account] = await Promise.all([
517
+ getShowtimeSnapshot(context),
518
+ fetchAccountState(accountId),
519
+ ]);
520
+
521
+ return new Response(
522
+ JSON.stringify({
523
+ asOf: shared.snapshot.asOf,
524
+ cinemas: shared.snapshot.cinemas,
525
+ showtimes: shared.snapshot.showtimes,
526
+ account,
527
+ }),
528
+ {
529
+ status: 200,
530
+ headers: {
531
+ "content-type": "application/json",
532
+ "cache-control": "no-store",
533
+ "x-snapshot-age": String(Math.round(shared.ageSeconds)),
534
+ },
535
+ },
536
+ );
537
+ } catch (error) {
538
+ context.log.error("Showtime board composition failed", {
539
+ error: String(error),
540
+ });
541
+ return HttpProblems.badGateway(request, context, {
542
+ detail: "The showtimes service is unavailable.",
543
+ });
544
+ }
545
+ }
546
+ ```
547
+
548
+ Point a route at it. The handler reads `request.user`, so keep an authentication
549
+ policy ahead of it in the inbound pipeline.
550
+
551
+ ```json title="config/routes.oas.json"
552
+ {
553
+ "paths": {
554
+ "/v1/showtimes/board": {
555
+ "get": {
556
+ "summary": "Showtime board",
557
+ "x-zuplo-route": {
558
+ "corsPolicy": "none",
559
+ "handler": {
560
+ "export": "default",
561
+ "module": "$import(./modules/showtime-board)"
562
+ },
563
+ "policies": {
564
+ "inbound": ["api-key-inbound"]
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+ }
571
+ ```
572
+
573
+ ## Choose the cache primitive
574
+
575
+ Three primitives can hold a fragment. They differ in scope, in what they can
576
+ store, and in how long a value survives.
577
+
578
+ | Primitive | Scope | Stores | Reach for it when |
579
+ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
580
+ | [ZoneCache](../programmable-api/zone-cache.mdx) | Shared across the isolates in a zone; survives isolate recycling | JSON-serializable values, up to 512 MB per object | The shared fragment. This is the default for the pattern on this page. |
581
+ | [MemoryZoneReadThroughCache](../programmable-api/memory-zone-read-through-cache.mdx) | One isolate; dies with it | Any in-memory value | A very hot, very small value where a zone round trip is measurable, such as a feature flag map or a decoded configuration blob. |
582
+ | [Cache API](../programmable-api/cache.mdx) (`caches.open`) | Long-lived memory, keyed by `Request` | Full `Request`/`Response` pairs | The fragment is literally an upstream HTTP response you want to keep verbatim, with its `Cache-Control` and `ETag` respected. |
583
+
584
+ When the fragment is a parsed object that code composes into a larger body,
585
+ ZoneCache wins: a recycled isolate warms from the zone rather than from the
586
+ backend. Reach for the Cache API instead when the fragment is a response you
587
+ want kept verbatim (an upstream document, an image, a signed payload) and
588
+ re-parsing it on every request would be wasted work.
589
+
590
+ ## Measure the result
591
+
592
+ Measure before and after, over the same window and the same routes. Expect tail
593
+ latency to move more than the median, because a hit removes the slower of the
594
+ two backend calls.
595
+
596
+ 1. **Fragment hit rate.** The cache itself does not report this, so count it:
597
+ log a structured field on hit and on miss, or increment a counter. A healthy
598
+ hit rate for a 10-second TTL at a thousand requests a minute is above 99%. A
599
+ number far below that means traffic is spread thinner across zones than
600
+ expected, or the TTL is shorter than the gap between requests.
601
+ 2. **Backend call volume.** Watch the request count against the snapshot
602
+ endpoint in the **Origins** section of Analytics, which breaks out volume,
603
+ error rate, and latency per upstream host. See
604
+ [Origins](../analytics/tabs/origins.md).
605
+ 3. **p95 on the route.** Compare against the same window a week earlier, not
606
+ against the ten minutes before the deploy. See
607
+ [Metrics glossary](../analytics/reference/metrics-glossary.md) for how p95 is
608
+ computed.
609
+
610
+ ## Limitations you own
611
+
612
+ - **Hit rate depends on traffic distribution.** ZoneCache is zone-local, and a
613
+ write in one data center does nothing for any other. Spread the same thousand
614
+ requests a minute across six zones and the backend sees up to 36 snapshot
615
+ refreshes a minute rather than six. That is still a 96% reduction, but not the
616
+ 99.4% a single-zone calculation predicts. Low-volume routes with globally
617
+ scattered traffic may miss more often than they hit.
618
+ - **A stale fragment is stale for everybody at once.** With a per-caller cache,
619
+ one unlucky client sees old data. With a shared fragment, every caller sees
620
+ the same old data simultaneously, and they can compare notes. The TTL is a
621
+ product decision about what the API promises, not a knob to tune for hit rate.
622
+ - **You own invalidation.** ZoneCache has no global purge and no tag-based
623
+ eviction. An entry disappears when its TTL expires or when code calls `delete`
624
+ in the zone it lives in. If a fragment must drop within a bounded time of an
625
+ upstream change, the TTL is the only mechanism that guarantees it. Choose it
626
+ accordingly, and version the key so a schema change does not depend on
627
+ invalidation at all.
628
+ - **Two calls cost more on a miss.** A cold cache pays for both round trips.
629
+ `Promise.all` keeps that concurrent, but the first request after a deploy into
630
+ a fresh zone is slower than the unsplit version was. Weigh that against how
631
+ often it happens.
632
+
633
+ ## Related
634
+
635
+ - [Caching in Zuplo](./overview.mdx) — which layer to reach for, and why.
636
+ - [Cache at the gateway](./gateway-caching.mdx) — whole-response caching when
637
+ responses really are identical across callers.
638
+ - [Build a custom caching policy](./custom-caching-policy.mdx) — the same
639
+ primitives packaged as a reusable policy instead of a handler.
640
+ - [Function Handler](../handlers/custom-handler.mdx) — the handler contract used
641
+ throughout this page.
@@ -11,10 +11,9 @@ directly, if you want to create your own tooling.
11
11
 
12
12
  ## Installing
13
13
 
14
- The Zuplo CLI is built using Node.js. It requires a minimum version of Node.js
15
- 20.0.0 (Node.js 22 is recommended).
14
+ The Zuplo CLI is built using Node.js. It requires Node.js 24.0.0 or greater.
16
15
 
17
- 1. Install Node.js 20.0.0 or later. You can download it from
16
+ 1. Install Node.js 24.0.0 or later. You can download it from
18
17
  [nodejs.org](https://nodejs.org/en/download/).
19
18
  1. Install the Zuplo CLI globally by running the following command:
20
19