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,427 @@
1
+ ---
2
+ title: Per-response cache rules
3
+ description:
4
+ Use the `cacheConfig` function on the CDN Cache Control policy to set edge
5
+ TTLs and purge tags that depend on the individual request or response,
6
+ including per-entity tags, route TTL matrices, and refusing to cache a bad
7
+ payload.
8
+ ---
9
+
10
+ Static policy options answer one question: how should this route cache? That's
11
+ enough for a catalog endpoint that always caches for 30 minutes. It runs out
12
+ when the answer depends on the request or the response in front of you:
13
+
14
+ - This response is about city 42 and cinema 7, so it needs the purge tags
15
+ `city-42` and `cinema-7`. A static `tags` array can't know the identifiers.
16
+ - This list endpoint returned zero rows because a backend dependency timed out.
17
+ A 30 minute edge TTL turns a two second blip into a 30 minute outage.
18
+ - These screening times change in four minutes. Fifteen minutes at the edge is
19
+ wrong for this response and right for the next one.
20
+
21
+ The `cacheConfig` option on the
22
+ [CDN Cache Control](../policies/cdn-cache-control-outbound.mdx) policy points at
23
+ a function that runs on the outbound response and decides those cases. Read
24
+ [Cache at the CDN](./cdn-caching.mdx) first for the static options and the
25
+ `edge`/`client` split; this page assumes them.
26
+
27
+ ## The function returns intent, not headers
28
+
29
+ The function never writes a header. It returns an edge TTL, a client TTL, and a
30
+ list of purge tags, and the policy renders that intent into whichever dialect
31
+ the `cdn` option names.
32
+
33
+ <Diagram height="h-72">
34
+ <DiagramNode id="fn" variant="zuplo">
35
+ Function returns intent
36
+ </DiagramNode>
37
+ <DiagramNode id="akamai">Edge-Control</DiagramNode>
38
+ <DiagramNode id="fastly">Surrogate-Control</DiagramNode>
39
+ <DiagramNode id="cf">Cloudflare-CDN-Cache-Control</DiagramNode>
40
+ <DiagramEdge from="fn" to="akamai" label="akamai" />
41
+ <DiagramEdge from="fn" to="fastly" label="fastly" />
42
+ <DiagramEdge from="fn" to="cf" label="cloudflare" />
43
+ </Diagram>
44
+
45
+ That separation keeps a CDN migration cheap. Tags joined by commas on Akamai are
46
+ joined by spaces on Fastly, and the header names differ on all three. A function
47
+ returning raw headers would need a rewrite; one returning
48
+ `tags: ["catalog", "city-42"]` keeps working when `cdn` changes, with the vendor
49
+ limits on tag length and charset still enforced for you.
50
+
51
+ ```json title="config/policies.json"
52
+ {
53
+ "name": "cdn-cache-control",
54
+ "policyType": "cdn-cache-control-outbound",
55
+ "handler": {
56
+ "export": "CdnCacheControlOutboundPolicy",
57
+ "module": "$import(@zuplo/runtime)",
58
+ "options": {
59
+ "cdn": "akamai",
60
+ "strategy": "s-maxage",
61
+ "edge": { "maxAge": 300, "staleIfError": 86400 },
62
+ "client": { "maxAge": 60, "visibility": "public" },
63
+ "tags": ["catalog"],
64
+ "cacheConfig": {
65
+ "module": "$import(./modules/cdn-cache)",
66
+ "export": "default"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ The signature is
74
+ `(response: Response, request: ZuploRequest, context: ZuploContext)`, and the
75
+ function may be synchronous or async.
76
+
77
+ ## What the return value means
78
+
79
+ | Return | Effect |
80
+ | ------------------ | ----------------------------------------------------------------------------------- |
81
+ | `undefined`/`null` | Use the static `edge`, `client`, and `tags` options unchanged. |
82
+ | a config object | Use it **in place of** the static options. |
83
+ | `{ cache: false }` | Emit no edge headers or tags, **and** send `Cache-Control: no-store` to the client. |
84
+
85
+ Returning nothing is what makes this design work. The static options stay the
86
+ enforced default for the route and the function handles only exceptions, so a
87
+ gap in the function degrades to the configured behavior rather than to no
88
+ caching at all.
89
+
90
+ `{ cache: false }` is a positive statement that a response must not be stored
91
+ anywhere, so it disables client caching too. The exception is
92
+ `client.mode: "preserve"`, which leaves the upstream `Cache-Control` alone and
93
+ suppresses only the edge headers. To skip the edge while clients keep caching
94
+ normally, use `preserve`, or return a config with a `client` policy and no
95
+ `edge`.
96
+
97
+ :::caution{title="A returned config replaces the static options wholesale"}
98
+
99
+ There is no field-by-field merge. With the configuration above, a function that
100
+ returns `{ edge: { maxAge: 60 } }` sends a 60 second edge TTL and nothing else:
101
+ **no** `staleIfError`, **no** client `max-age`, and **no** `catalog` tag.
102
+ Restate every directive the response needs, every time the function returns a
103
+ config.
104
+
105
+ :::
106
+
107
+ ## Derive purge tags from the request
108
+
109
+ Per-entity tags are the main thing static configuration cannot express, and they
110
+ turn purging from a blunt instrument into a precise one. A tag of `cities`
111
+ forces a city sync to purge every city. A tag of `city-42` purges one.
112
+
113
+ ```ts title="modules/cdn-cache.ts"
114
+ import type {
115
+ CdnCacheControlConfig,
116
+ ZuploContext,
117
+ ZuploRequest,
118
+ } from "@zuplo/runtime";
119
+
120
+ /** Akamai rejects spaces, commas, colons, and brackets in a tag value. */
121
+ const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
122
+ /** Akamai allows 128 tags per object. Stay well clear of the ceiling. */
123
+ const MAX_ENTITY_TAGS = 8;
124
+ const ENTITY_PARAMS = { cityId: "city", cinemaId: "cinema" } as const;
125
+
126
+ function entityTags(url: URL, context: ZuploContext): string[] {
127
+ const tags = new Set<string>();
128
+ for (const [param, prefix] of Object.entries(ENTITY_PARAMS)) {
129
+ for (const value of url.searchParams.getAll(param)) {
130
+ if (TAG_PATTERN.test(value)) {
131
+ tags.add(`${prefix}-${value}`);
132
+ } else {
133
+ context.log.warn(`Unusable ${param} left out of the purge tags`);
134
+ }
135
+ if (tags.size >= MAX_ENTITY_TAGS) {
136
+ return [...tags];
137
+ }
138
+ }
139
+ }
140
+ return [...tags];
141
+ }
142
+
143
+ export default function cdnCacheConfig(
144
+ response: Response,
145
+ request: ZuploRequest,
146
+ context: ZuploContext,
147
+ ): CdnCacheControlConfig | undefined {
148
+ // Only GET is safe for a shared cache to replay.
149
+ if (request.method !== "GET") {
150
+ return { cache: false };
151
+ }
152
+ const url = new URL(request.url);
153
+ return {
154
+ edge: { maxAge: 1800, staleIfError: 86400 },
155
+ client: { maxAge: 60, visibility: "public" },
156
+ tags: ["catalog", "cities", ...entityTags(url, context)],
157
+ };
158
+ }
159
+ ```
160
+
161
+ Both guards exist because the failure mode is silent.
162
+
163
+ **The charset filter.** Query parameters carry whatever a caller sends.
164
+ `?cityId=not a city` produces the tag `city-not a city`, which Akamai rejects
165
+ and Fastly reads as three separate keys. The policy validates tags and drops a
166
+ bad one with a warning rather than failing a `200`, but filtering at the source
167
+ keeps the log clean and makes the rule explicit.
168
+
169
+ **The cap.** A caller can repeat `?cityId=` fifty times, and once the tag header
170
+ passes the CDN's size limit the overflow is dropped — on Fastly, along with
171
+ every key after it. Capping entity tags bounds that and leaves room for the
172
+ route tags you always want present.
173
+
174
+ ## Keep route TTLs in one file
175
+
176
+ Past a handful of routes, TTLs scattered across separate policy instances stop
177
+ being reviewable. Put the matrix in its own module and look up the current path.
178
+
179
+ ```ts title="modules/cache-routes.ts"
180
+ export interface CacheRouteConfig {
181
+ /** Seconds the CDN may serve this response. A purge cuts the window short. */
182
+ edgeTtl: number;
183
+ /** Seconds a client may reuse it. Not purgeable, so keep it far shorter. */
184
+ clientTtl: number;
185
+ tags: string[];
186
+ }
187
+
188
+ const CACHE_ROUTES: Record<string, CacheRouteConfig> = {
189
+ "/v1/cities": { edgeTtl: 1800, clientTtl: 300, tags: ["cities"] },
190
+ "/v1/movies/now-showing": { edgeTtl: 900, clientTtl: 60, tags: ["movies"] },
191
+ "/v1/cinemas": { edgeTtl: 2400, clientTtl: 300, tags: ["cinemas"] },
192
+ };
193
+
194
+ export function getCacheRouteConfig(
195
+ pathname: string,
196
+ ): CacheRouteConfig | undefined {
197
+ // Strip a trailing slash so `/v1/cities` and `/v1/cities/` resolve the same.
198
+ return CACHE_ROUTES[pathname.replace(/(.)\/$/, "$1")];
199
+ }
200
+ ```
201
+
202
+ ```ts title="modules/cdn-cache.ts"
203
+ import type {
204
+ CdnCacheControlConfig,
205
+ ZuploContext,
206
+ ZuploRequest,
207
+ } from "@zuplo/runtime";
208
+ import { getCacheRouteConfig } from "./cache-routes.ts";
209
+
210
+ /** Seconds the edge may serve a stale copy while the backend is failing. */
211
+ const STALE_IF_ERROR = 24 * 60 * 60;
212
+
213
+ export default function cdnCacheConfig(
214
+ response: Response,
215
+ request: ZuploRequest,
216
+ context: ZuploContext,
217
+ ): CdnCacheControlConfig | undefined {
218
+ const url = new URL(request.url);
219
+ const route = getCacheRouteConfig(url.pathname);
220
+ if (!route) {
221
+ // Refuse rather than let a static default cache something nobody reviewed.
222
+ context.log.warn(`No cache entry for ${url.pathname}.`);
223
+ return { cache: false };
224
+ }
225
+ return {
226
+ edge: { maxAge: route.edgeTtl, staleIfError: STALE_IF_ERROR },
227
+ client: { maxAge: route.clientTtl, visibility: "public" },
228
+ tags: route.tags,
229
+ };
230
+ }
231
+ ```
232
+
233
+ One file now holds every TTL, one diff shows every change to them, and a unit
234
+ test can assert on the matrix without a running gateway. Refusing an unlisted
235
+ path makes adding a route a deliberate act rather than an accident.
236
+
237
+ :::note
238
+
239
+ `staleIfError` is not expressible in Akamai's `Edge-Control`. On Akamai, either
240
+ set `strategy` to `s-maxage` — as the
241
+ [`config/policies.json` example above](#the-function-returns-intent-not-headers)
242
+ does — or configure stale serving in Property Manager. The policy rejects the
243
+ combination rather than dropping the directive silently.
244
+
245
+ :::
246
+
247
+ ## Refuse to cache a suspicious response
248
+
249
+ A `200` with an empty collection is ambiguous. The city may genuinely have no
250
+ cinemas, or a backend dependency may have timed out and degraded to an empty
251
+ list. Caching the second case for 30 minutes across every edge node stretches a
252
+ brief incident into a long one, and backend recovery does not clear it.
253
+
254
+ ```ts title="modules/cdn-cache.ts"
255
+ import type { CdnCacheControlConfig } from "@zuplo/runtime";
256
+
257
+ export default function cdnCacheConfig(
258
+ response: Response,
259
+ ): CdnCacheControlConfig | undefined {
260
+ // The backend sets x-result-count on every list response.
261
+ if (response.headers.get("x-result-count") === "0") {
262
+ return { cache: false };
263
+ }
264
+ // Everything else: fall back to the configured edge, client, and tags.
265
+ return undefined;
266
+ }
267
+ ```
268
+
269
+ Prefer a header over parsing the body. A header check costs a map lookup;
270
+ parsing JSON costs a full deserialization of every cacheable response on the
271
+ gateway's hottest path. Backends often already expose something usable: a result
272
+ count, a circuit breaker's `x-degraded` flag, a partial-content marker. When
273
+ none exists, adding one beats paying for the parse forever.
274
+
275
+ ## Read the body only when you must
276
+
277
+ When no header will do, clone the response before reading it.
278
+
279
+ ```ts title="modules/cdn-cache.ts"
280
+ import type { CdnCacheControlConfig } from "@zuplo/runtime";
281
+
282
+ export default async function cdnCacheConfig(
283
+ response: Response,
284
+ ): Promise<CdnCacheControlConfig | undefined> {
285
+ if (!response.headers.get("content-type")?.includes("application/json")) {
286
+ return undefined;
287
+ }
288
+ const payload = (await response.clone().json()) as { showtimes?: unknown[] };
289
+ if (!Array.isArray(payload.showtimes) || payload.showtimes.length === 0) {
290
+ return { cache: false };
291
+ }
292
+ return {
293
+ edge: { maxAge: 300, staleIfError: 3600 },
294
+ client: { maxAge: 30, visibility: "public" },
295
+ tags: ["movies", "showtimes"],
296
+ };
297
+ }
298
+ ```
299
+
300
+ :::warning
301
+
302
+ The function receives the live response, not a copy, and the policy still has to
303
+ forward that body to the caller. Consuming it without cloning makes that
304
+ impossible, so the policy fails the request with an error naming the cause
305
+ rather than sending a truncated response.
306
+
307
+ :::
308
+
309
+ The policy does not clone for you, on purpose. `clone()` tees the stream, and
310
+ the branch nobody reads holds the whole body in memory until both branches
311
+ drain. Cloning every response would charge that cost to the majority of rules
312
+ that never look at a payload. See
313
+ [Safely clone a request or response](../programmable-api/safely-clone-a-request-or-response.mdx)
314
+ for the underlying behavior.
315
+
316
+ ## Shorten the TTL as data approaches a change
317
+
318
+ When the backend knows when data next changes, the edge TTL should not outlive
319
+ it. A schedule rolling over in four minutes should not sit at the edge for
320
+ fifteen.
321
+
322
+ ```ts title="modules/cdn-cache.ts"
323
+ import type { CdnCacheControlConfig } from "@zuplo/runtime";
324
+
325
+ const DEFAULT_EDGE_TTL = 900;
326
+ const MIN_EDGE_TTL = 30;
327
+ const MAX_CLIENT_TTL = 60;
328
+
329
+ export default function cdnCacheConfig(
330
+ response: Response,
331
+ ): CdnCacheControlConfig | undefined {
332
+ // x-next-change is the ISO timestamp of the next scheduled change to this
333
+ // data: the next showtime start, the next price rollover.
334
+ const nextChange = response.headers.get("x-next-change");
335
+ if (!nextChange) {
336
+ return undefined;
337
+ }
338
+ const secondsToChange = Math.floor(
339
+ (Date.parse(nextChange) - Date.now()) / 1000,
340
+ );
341
+ if (Number.isNaN(secondsToChange)) {
342
+ return undefined;
343
+ }
344
+ const edgeMaxAge = Math.max(
345
+ MIN_EDGE_TTL,
346
+ Math.min(DEFAULT_EDGE_TTL, secondsToChange),
347
+ );
348
+ return {
349
+ edge: { maxAge: edgeMaxAge, staleIfError: 3600 },
350
+ client: {
351
+ maxAge: Math.min(MAX_CLIENT_TTL, edgeMaxAge),
352
+ visibility: "public",
353
+ },
354
+ tags: ["movies", "showtimes"],
355
+ };
356
+ }
357
+ ```
358
+
359
+ Both clamps earn their place. Without the ceiling, a backend reporting a change
360
+ eight hours out pins a response at the edge for eight hours. Without the floor,
361
+ a timestamp seconds away — or already past — produces a TTL so short the edge
362
+ stops absorbing anything.
363
+
364
+ The TTL is computed when the response leaves the gateway, and the policy does
365
+ not decrement `max-age` by the response's `Age`. Treat these values as a hint
366
+ that keeps the edge roughly in step with the data, not as a scheduled expiry.
367
+
368
+ ## Test the function
369
+
370
+ Assert on the headers the gateway sends, not on the function's return value. The
371
+ return value is intent; the header is the contract with the CDN, and it is where
372
+ a wrong `cdn` setting or a dropped tag shows up.
373
+
374
+ | What to assert | Akamai | Fastly | Cloudflare |
375
+ | ------------------------------ | ------------------------- | ------------------- | ------------------------------ |
376
+ | Edge TTL, `strategy: targeted` | `Edge-Control` | `Surrogate-Control` | `Cloudflare-CDN-Cache-Control` |
377
+ | Edge TTL, `strategy: s-maxage` | `Cache-Control: s-maxage` | same | same |
378
+ | Purge tags | `Edge-Cache-Tag` | `Surrogate-Key` | `Cache-Tag` |
379
+ | Client TTL | `Cache-Control: max-age` | same | same |
380
+
381
+ Start the dev server with `npx zuplo dev`, then run the suite against it with
382
+ `npx zuplo test --endpoint http://localhost:9000`.
383
+
384
+ ```ts title="tests/cdn-cache.test.ts"
385
+ import { describe, it, TestHelper } from "@zuplo/test";
386
+ import { expect } from "chai";
387
+
388
+ describe("cdn cache headers", () => {
389
+ it("adds a per-city purge tag", async () => {
390
+ const url = new URL("/v1/cities?cityId=42", TestHelper.TEST_URL);
391
+ const response = await fetch(url);
392
+ const tags = (response.headers.get("edge-cache-tag") ?? "").split(",");
393
+ expect(response.status).to.equal(200);
394
+ expect(tags).to.include("cities");
395
+ expect(tags).to.include("city-42");
396
+ });
397
+
398
+ it("drops an identifier the CDN charset rejects", async () => {
399
+ const path = "/v1/cities?cityId=not%20a%20city";
400
+ const response = await fetch(new URL(path, TestHelper.TEST_URL));
401
+ const tags = (response.headers.get("edge-cache-tag") ?? "").split(",");
402
+ expect(tags).to.include("cities");
403
+ expect(tags.some((tag) => tag.startsWith("city-"))).to.equal(false);
404
+ });
405
+ });
406
+ ```
407
+
408
+ Two negative assertions are worth adding: that a `{ cache: false }` branch
409
+ really produced `no-store` with no tag header, and that no `Vary` slipped in,
410
+ since on Akamai a `Vary` suppresses caching entirely until Cache ID Modification
411
+ is configured.
412
+
413
+ The same tests run against a preview environment or production; only
414
+ `--endpoint` changes. See [Testing your API](../articles/testing.mdx) for the
415
+ full workflow. Whether the CDN honors these headers is a separate question that
416
+ lives in its configuration, so verify at the edge as well as at the gateway.
417
+
418
+ ## Next steps
419
+
420
+ - [Cache at the CDN](./cdn-caching.mdx) — the static options this function
421
+ overrides, and the `edge`/`client` split it returns.
422
+ - [Cache part of a response](./partial-response-caching.mdx) — when no TTL is
423
+ right for the whole payload because only part of it is shared.
424
+ - [Akamai CDN caching](../dedicated/akamai/caching.mdx) — the Property Manager
425
+ side, where you configure the stale serving `Edge-Control` cannot express.
426
+ - [CDN Cache Control policy reference](../policies/cdn-cache-control-outbound.mdx)
427
+ — every configuration option in detail.