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,426 @@
1
+ ---
2
+ title: Build a custom caching policy
3
+ description:
4
+ Write your own inbound and outbound policy pair with the Cache API when no
5
+ built-in policy fits your caching rules, from tenant-scoped keys and size and
6
+ status filters to upstream-driven TTLs and independent invalidation.
7
+ ---
8
+
9
+ Custom caching code is the most expensive caching you can ship. You own it, you
10
+ debug it, and the failure mode is never a slow API. It's one customer reading
11
+ another customer's data. Reach for it only after a built-in policy has failed to
12
+ express what you need.
13
+
14
+ ## Check the built-in policies first
15
+
16
+ Most caching requirements are already a configuration option somewhere. Find
17
+ your row before writing anything.
18
+
19
+ | You want | Use this instead |
20
+ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
21
+ | Cache responses at the gateway so repeats skip your backend | [Cache at the gateway](./gateway-caching.mdx) |
22
+ | A key that varies by `Accept` or `Accept-Language` | The Caching policy's `headers` option |
23
+ | A key that varies by the raw `Authorization` header | The Caching policy includes it by default |
24
+ | Flush every cached response at once | The [Caching policy's `cacheId`](./gateway-caching.mdx#invalidate-with-cacheid), bound to an environment variable |
25
+ | Cache at the CDN so requests never reach the gateway at all | [Cache at the CDN](./cdn-caching.mdx) |
26
+ | A different TTL per route, or per response | The [`cacheConfig` function](./dynamic-cache-rules.mdx) |
27
+ | Purge tags for targeted CDN invalidation | The CDN policy's `tags` option |
28
+ | Cache GraphQL query results by query and variables | [`graphql-cache-inbound`](../policies/graphql-cache-inbound.mdx) |
29
+ | Cache LLM completions by prompt similarity | [`semantic-cache-inbound`](../policies/semantic-cache-inbound.mdx) |
30
+ | Store an arbitrary JSON value with a TTL from your own code | [`ZoneCache`](../programmable-api/zone-cache.mdx) |
31
+
32
+ ## When custom code is the right answer
33
+
34
+ What survives that table is a short list. These are the cases where no
35
+ combination of built-in options gets you there:
36
+
37
+ - **The key depends on a decoded value, not a raw header.** Two JWTs for the
38
+ same tenant are different strings, so keying on `Authorization` gives a
39
+ near-zero hit rate. The `org_id` claim gives one entry per tenant.
40
+ - **You cache only some status codes, or only small responses.** A 5 MB export
41
+ isn't worth a cache slot; a 4 KB catalog page is.
42
+ - **The TTL comes from the upstream response.** The backend knows how volatile
43
+ each record is and says so in a header.
44
+ - **Each tenant needs its own namespace with independent invalidation.** One
45
+ tenant's write should drop that tenant's entries and leave every other
46
+ tenant's untouched.
47
+ - **You want soft purge or stale-while-revalidate behavior.** Serve the stale
48
+ copy now and refresh it in the background.
49
+
50
+ Everything else, which is most of it, belongs in a built-in policy.
51
+
52
+ ## An inbound and outbound policy pair
53
+
54
+ A gateway cache is two policies on one route. The inbound policy looks for a
55
+ stored copy and returns a `Response` to short-circuit the pipeline on a hit. The
56
+ outbound policy stores the response on the way back out.
57
+
58
+ <Diagram height="h-56">
59
+ <DiagramNode id="client">Client</DiagramNode>
60
+ <DiagramNode id="inbound" variant="zuplo">
61
+ Inbound: cache.match
62
+ </DiagramNode>
63
+ <DiagramNode id="backend">Backend</DiagramNode>
64
+ <DiagramNode id="outbound" variant="zuplo">
65
+ Outbound: cache.put
66
+ </DiagramNode>
67
+ <DiagramEdge from="client" to="inbound" />
68
+ <DiagramEdge from="inbound" to="backend" label="Miss" />
69
+ <DiagramEdge from="backend" to="outbound" />
70
+ <DiagramEdge from="inbound" to="client" label="Hit" lineStyle="dashed" />
71
+ </Diagram>
72
+
73
+ Both exports live in one module so they share the key function and the options
74
+ type. Register the read side:
75
+
76
+ ```json title="config/policies.json"
77
+ {
78
+ "policies": [
79
+ {
80
+ "name": "tenant-cache-read",
81
+ "policyType": "custom-code-inbound",
82
+ "handler": {
83
+ "export": "tenantCacheInbound",
84
+ "module": "$import(./modules/tenant-cache)",
85
+ "options": {
86
+ "namespace": "catalog",
87
+ "defaultTtlSeconds": 300,
88
+ "maxTtlSeconds": 3600,
89
+ "maxBytes": 262144,
90
+ "cacheableStatusCodes": [200, 203],
91
+ "varyByQuery": ["page", "pageSize", "sort"]
92
+ }
93
+ }
94
+ }
95
+ ]
96
+ }
97
+ ```
98
+
99
+ Add a second entry named `tenant-cache-write` with `policyType` set to
100
+ `custom-code-outbound`, `export` set to `tenantCacheOutbound`, and the same
101
+ `options` object. The two policies have to agree on the namespace and on every
102
+ input to the key, or the write lands somewhere the read never looks.
103
+
104
+ Order matters on the route: authentication runs before the cache read, because
105
+ the key depends on the authenticated identity.
106
+
107
+ ```json title="config/routes.oas.json"
108
+ {
109
+ "policies": {
110
+ "inbound": ["jwt-auth", "tenant-cache-read"],
111
+ "outbound": ["tenant-cache-write"]
112
+ }
113
+ }
114
+ ```
115
+
116
+ ## Build the cache key
117
+
118
+ The Cache API keys entries by `Request`, so a custom key is a synthetic URL you
119
+ build yourself. Keep the function pure: same request in, same string out.
120
+
121
+ ```ts title="modules/tenant-cache.ts"
122
+ import type { ZuploRequest } from "@zuplo/runtime";
123
+
124
+ export interface CacheOptions {
125
+ namespace: string;
126
+ defaultTtlSeconds: number;
127
+ maxTtlSeconds: number;
128
+ maxBytes: number;
129
+ cacheableStatusCodes: number[];
130
+ varyByQuery: string[];
131
+ }
132
+
133
+ export type TenantRequest = ZuploRequest<{ UserData: { org_id?: string } }>;
134
+
135
+ /** The tenant this request reads for, or undefined when unauthenticated. */
136
+ export function getTenantId(request: TenantRequest): string | undefined {
137
+ return request.user?.data?.org_id;
138
+ }
139
+
140
+ /** Stable, collision-free cache key. Pure: no clocks, no randomness. */
141
+ export function cacheKey(
142
+ request: TenantRequest,
143
+ tenantId: string,
144
+ options: CacheOptions,
145
+ ): string {
146
+ const url = new URL(request.url);
147
+
148
+ // Allowlist parameters, lowercase the names, and sort, so ?sort=name&page=2
149
+ // and ?page=2&sort=name produce one entry instead of two.
150
+ const params = [...url.searchParams]
151
+ .map(([name, value]) => [name.toLowerCase(), value] as [string, string])
152
+ .filter(([name]) => options.varyByQuery.includes(name))
153
+ .sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
154
+
155
+ const search = new URLSearchParams(params).toString();
156
+ const query = search ? `?${search}` : "";
157
+ const tenant = encodeURIComponent(tenantId);
158
+ const path = url.pathname.toLowerCase();
159
+
160
+ return `https://cache.invalid/${options.namespace}/${tenant}${path}${query}`;
161
+ }
162
+ ```
163
+
164
+ Three rules do the real work here.
165
+
166
+ **Determinism.** Sorting and lowercasing collapse the same logical request onto
167
+ one entry. An unsorted key turns one cacheable resource into a pile of
168
+ near-duplicates that each get read once.
169
+
170
+ **Bounded cardinality.** `varyByQuery` is an allowlist, not a blocklist. A
171
+ free-text `q=` parameter or a client-supplied timestamp has unbounded values, so
172
+ every request writes an entry nobody reads again — a 0% hit rate that still
173
+ costs a write per request.
174
+
175
+ **Namespacing.** The tenant id sits in a fixed segment, URL-encoded so a value
176
+ containing `/` cannot escape it. Dropping one tenant's entries becomes a prefix
177
+ operation, and no two tenants can produce the same string.
178
+
179
+ :::danger
180
+
181
+ A cache key that omits the caller identity will serve one tenant's data to
182
+ another. This is not a theoretical risk — it is the default outcome. If
183
+ `getTenantId` returns `undefined`, the inbound policy must skip the cache
184
+ entirely rather than fall back to a shared key. Never derive the tenant from a
185
+ client-supplied header such as `X-Tenant-Id`; derive it from the authenticated
186
+ `request.user` an authentication policy populated. Before shipping, add a test
187
+ that sends one URL with two tenants' tokens and asserts the bodies differ.
188
+
189
+ :::
190
+
191
+ ## Read the cache on the way in
192
+
193
+ The inbound policy returns a `Response` to short-circuit, or the original
194
+ `ZuploRequest` to continue to the backend.
195
+
196
+ ```ts title="modules/tenant-cache.ts"
197
+ import { ContextData, environment, type ZuploContext } from "@zuplo/runtime";
198
+
199
+ const CACHE_KEY = "tenant-cache-key";
200
+
201
+ const cacheEnabled = () => environment.CACHE_DISABLED !== "true";
202
+
203
+ export async function tenantCacheInbound(
204
+ request: TenantRequest,
205
+ context: ZuploContext,
206
+ options: CacheOptions,
207
+ ): Promise<ZuploRequest | Response> {
208
+ const tenantId = getTenantId(request);
209
+
210
+ // No identity, no shared cache. Never fall back to a tenant-less key.
211
+ if (!cacheEnabled() || request.method !== "GET" || !tenantId) {
212
+ return request;
213
+ }
214
+
215
+ const key = cacheKey(request, tenantId, options);
216
+ ContextData.set(context, CACHE_KEY, key);
217
+
218
+ const cache = await caches.open(options.namespace);
219
+ const stored = await cache.match(new Request(key));
220
+
221
+ if (!stored) {
222
+ context.log.debug("cache miss", { key: await keyDigest(key) });
223
+ return request;
224
+ }
225
+
226
+ // Copy into a fresh Response so the headers are mutable, then label the hit.
227
+ const storedAt = Number(stored.headers.get("x-cache-stored-at") ?? 0);
228
+ const age = Math.max(0, Math.floor((Date.now() - storedAt) / 1000));
229
+
230
+ const response = new Response(stored.body, {
231
+ status: stored.status,
232
+ headers: stored.headers,
233
+ });
234
+ response.headers.set("cache-status", "zuplo; hit");
235
+ response.headers.set("age", String(age));
236
+ response.headers.delete("x-cache-stored-at");
237
+
238
+ // The stored Cache-Control is the Cache API's expiry clock, not advice for
239
+ // the caller. A tenant-scoped body must not be shareable downstream.
240
+ response.headers.set("cache-control", "private, no-store");
241
+
242
+ context.log.debug("cache hit", { key: await keyDigest(key), age });
243
+ return response;
244
+ }
245
+ ```
246
+
247
+ Returning the stored response ends the pipeline: the backend is never called and
248
+ the outbound policies never run. That is the point, and it is also why the hit
249
+ path has to set its own headers — nothing downstream will do it for you.
250
+
251
+ ## Write the cache on the way out
252
+
253
+ The outbound policy decides whether the response is worth storing, then stores a
254
+ clone.
255
+
256
+ ```ts title="modules/tenant-cache.ts"
257
+ /** Clamp an upstream TTL so one bad header cannot pin a copy for a year. */
258
+ function resolveTtl(response: Response, options: CacheOptions): number {
259
+ const raw = response.headers.get("x-cache-ttl");
260
+ const upstream = raw === null ? Number.NaN : Number.parseInt(raw, 10);
261
+
262
+ return Number.isFinite(upstream) && upstream > 0
263
+ ? Math.min(upstream, options.maxTtlSeconds)
264
+ : options.defaultTtlSeconds;
265
+ }
266
+
267
+ /** Re-wrap a response so its headers are mutable, then label it. */
268
+ function label(response: Response, status: string): Response {
269
+ const copy = new Response(response.body, {
270
+ status: response.status,
271
+ headers: response.headers,
272
+ });
273
+ copy.headers.set("cache-status", status);
274
+ return copy;
275
+ }
276
+
277
+ export async function tenantCacheOutbound(
278
+ response: Response,
279
+ request: TenantRequest,
280
+ context: ZuploContext,
281
+ options: CacheOptions,
282
+ ): Promise<Response> {
283
+ const key = ContextData.get<string>(context, CACHE_KEY);
284
+ const size = Number(response.headers.get("content-length") ?? Number.NaN);
285
+
286
+ const storable =
287
+ cacheEnabled() &&
288
+ options.cacheableStatusCodes.includes(response.status) &&
289
+ Number.isFinite(size) &&
290
+ size <= options.maxBytes;
291
+
292
+ if (key === undefined || !storable) {
293
+ return label(response, "zuplo; fwd=miss");
294
+ }
295
+
296
+ // Clone before storing. The original body still has to reach the client.
297
+ const copy = response.clone();
298
+ const headers = new Headers(copy.headers);
299
+ headers.set("cache-control", `max-age=${resolveTtl(response, options)}`);
300
+ headers.set("x-cache-stored-at", Date.now().toString());
301
+
302
+ // Reusing upstream headers means inheriting upstream cache headers. Drop the
303
+ // ones that would compete with the Cache-Control just set.
304
+ headers.delete("expires");
305
+ headers.delete("etag");
306
+ headers.delete("last-modified");
307
+
308
+ const toStore = new Response(copy.body, { status: copy.status, headers });
309
+ const cache = await caches.open(options.namespace);
310
+ void cache
311
+ .put(new Request(key), toStore)
312
+ .catch((err) => context.log.error("cache write failed", { err }));
313
+
314
+ return label(response, "zuplo; fwd=miss; stored");
315
+ }
316
+ ```
317
+
318
+ `Cache-Control` on the stored copy carries the TTL — the Cache API reads it back
319
+ on `match`, so a stored response without it has no expiry you control. It is
320
+ internal machinery, not advice for the caller, which is why the hit path
321
+ overwrites it with `private, no-store` before returning: a tenant-scoped body
322
+ must never carry a header that invites a browser or a shared proxy to hold onto
323
+ it. A response with no `Content-Length`, such as a streamed or chunked body,
324
+ fails the size check and is skipped, which is correct: you cannot bound what you
325
+ cannot measure.
326
+
327
+ :::warning
328
+
329
+ Outbound policies run on every response, not only successful ones, so a `502`
330
+ from a failing backend reaches this code. The `cacheableStatusCodes` allowlist
331
+ is the only thing standing between an error and a cached error, so keep it
332
+ explicit and keep it narrow. It also stops a `204` or `206` from being stored by
333
+ accident.
334
+
335
+ :::
336
+
337
+ ## Pass state between the two policies
338
+
339
+ The outbound policy must not recompute the key. The request may have been
340
+ rewritten by then, and any drift between the two computations means you write to
341
+ a key nobody reads. Compute it once inbound and hand it forward with
342
+ [`ContextData`](../programmable-api/context-data.mdx), which is scoped to one
343
+ request and garbage collected when that request completes.
344
+
345
+ Setting the key inbound doubles as the signal for the outbound policy. When
346
+ `ContextData.get` returns `undefined`, the inbound policy skipped the cache —
347
+ unauthenticated request, non-`GET` method, or kill switch on — so the outbound
348
+ policy skips it too, with no second copy of that logic to keep in sync.
349
+
350
+ ## Do not block the response on the cache write
351
+
352
+ The `void cache.put(...)` above is deliberately not awaited. A client waiting on
353
+ a successful response should not also wait on a cache write; the write is an
354
+ optimization for the _next_ request.
355
+
356
+ The `.catch` on it is mandatory, not stylistic. A floating promise that rejects
357
+ becomes an unhandled rejection, and an unhandled rejection can fail the request
358
+ that was already on its way out the door — a cache write error turning into a
359
+ `500` for a response that was otherwise fine. Catching and logging keeps the
360
+ failure in your logs and invisible to the caller.
361
+
362
+ ## Correctness checklist
363
+
364
+ Walk this before the pull request goes up.
365
+
366
+ - **Never store a response whose body was already consumed.** `response.json()`
367
+ or `response.text()` drains the stream, and everything downstream gets a
368
+ `body-used` error. See
369
+ [Safely clone a request or response](../programmable-api/safely-clone-a-request-or-response.mdx).
370
+ - **Clone before storing.** `response.clone()` gives you a second readable body:
371
+ store the clone, return the original.
372
+ - **Never cache an error.** Check the status against an explicit allowlist. A
373
+ cached `502` outlives the outage that caused it.
374
+ - **Never let one tenant's key collide with another's.** URL-encode the tenant
375
+ id and keep it in a fixed position in the key.
376
+ - **Bound the key space.** Allowlist the query parameters that vary the response
377
+ and ignore everything else.
378
+ - **Always have a kill switch.** `CACHE_DISABLED=true` turns both policies into
379
+ pass-throughs. Flipping an
380
+ [environment variable](../articles/environment-variables.mdx) and redeploying
381
+ takes minutes and needs no code edit or review while the cache is serving
382
+ something wrong.
383
+
384
+ ## Measure the hit rate
385
+
386
+ The `Cache-Status` header set above
387
+ ([RFC 9211](https://www.rfc-editor.org/rfc/rfc9211.html)) makes every response
388
+ self-describing, so a developer chasing stale data reads the answer off their
389
+ network tab instead of asking you.
390
+
391
+ For aggregate hit rate, log a short digest of the key rather than the key itself
392
+ — the key holds the tenant id and the query string, and neither belongs in a log
393
+ line:
394
+
395
+ ```ts title="modules/tenant-cache.ts"
396
+ /** Short, stable, non-reversible label for a cache key. */
397
+ async function keyDigest(key: string): Promise<string> {
398
+ const bytes = new TextEncoder().encode(key);
399
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
400
+ return [...new Uint8Array(digest)]
401
+ .slice(0, 8)
402
+ .map((byte) => byte.toString(16).padStart(2, "0"))
403
+ .join("");
404
+ }
405
+ ```
406
+
407
+ Hits divided by hits plus misses gives the hit rate, and grouping by digest
408
+ surfaces the keys that never repeat — the ones to stop caching. The runtime
409
+ supports the [Web Crypto API](../programmable-api/web-crypto-apis.mdx) directly,
410
+ so this needs no dependency.
411
+
412
+ ## Related
413
+
414
+ If the reason you are here is that only part of each response is cacheable, read
415
+ [Cache part of a response](./partial-response-caching.mdx) before building any
416
+ of this. Caching a shared fragment and fetching only the caller-specific
417
+ remainder is the same machinery one level down, and it usually beats caching the
418
+ whole payload.
419
+
420
+ - [Caching in Zuplo](./overview.mdx) — which layer to reach for, and why.
421
+ - [Cache at the gateway](./gateway-caching.mdx) — the built-in policy this page
422
+ replaces, and the options to exhaust before writing any code.
423
+ - [Cache API](../programmable-api/cache.mdx) — the `caches.open`, `match`, and
424
+ `put` reference behind the code above.
425
+ - [ContextData](../programmable-api/context-data.mdx) — how the cache key
426
+ travels from the inbound policy to the outbound one.