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,397 @@
1
+ ---
2
+ title: Cache at the CDN
3
+ description:
4
+ Put a CDN in front of your Zuplo gateway and drive its cache from response
5
+ headers, with separate edge and client TTLs, per-entity purge tags, and stale
6
+ responses through a backend outage.
7
+ ---
8
+
9
+ A CDN in front of your gateway serves the cheapest request your API will ever
10
+ handle: it never reaches Zuplo, never reaches your backend, and returns from a
11
+ POP near the caller. The hard part is telling the CDN which responses it may
12
+ hold, and telling it something different from what you tell the browser.
13
+ `Cache-Control` serves two audiences, and only one of them can be purged.
14
+
15
+ The gateway is the right place to make that call, because only the gateway sees
16
+ whether _this_ response has an empty result set, a `Set-Cookie`, or an upstream
17
+ `no-store`. The CDN Cache Control policy puts the decision there, so making an
18
+ endpoint cacheable is an edit to `config/policies.json` and a deploy rather than
19
+ a rule per path in Akamai Property Manager or the Cloudflare dashboard.
20
+
21
+ <Diagram type="sequence" height="h-[520px]">
22
+ <DiagramActor id="client" variant="yellow">
23
+ Client
24
+ </DiagramActor>
25
+ <DiagramActor id="cdn" variant="blue">
26
+ CDN
27
+ </DiagramActor>
28
+ <DiagramActor id="gateway" variant="zuplo">
29
+ Zuplo
30
+ </DiagramActor>
31
+ <DiagramActor id="backend" variant="green">
32
+ Backend
33
+ </DiagramActor>
34
+ <DiagramMessage from="client" to="cdn">
35
+ GET /v1/catalog/products
36
+ </DiagramMessage>
37
+ <DiagramMessage from="cdn" to="gateway">
38
+ Forward to origin
39
+ </DiagramMessage>
40
+ <DiagramMessage from="gateway" to="backend">
41
+ Fetch catalog
42
+ </DiagramMessage>
43
+ <DiagramMessage from="backend" to="gateway">
44
+ 200 OK
45
+ </DiagramMessage>
46
+ <DiagramMessage from="gateway" to="cdn">
47
+ 200 + Edge-Control + tag
48
+ </DiagramMessage>
49
+ <DiagramMessage from="cdn" to="client">
50
+ 200 (stored at the edge)
51
+ </DiagramMessage>
52
+ <DiagramMessage from="client" to="cdn">
53
+ Same request again
54
+ </DiagramMessage>
55
+ <DiagramMessage from="cdn" to="client">
56
+ 200, origin never runs
57
+ </DiagramMessage>
58
+ </Diagram>
59
+
60
+ ## Add the policy
61
+
62
+ Add the policy to the `policies` array in `config/policies.json`. This example
63
+ caches at the edge for 30 minutes and in the client for 60 seconds.
64
+
65
+ ```json title="config/policies.json"
66
+ {
67
+ "policies": [
68
+ {
69
+ "name": "cdn-cache-control",
70
+ "policyType": "cdn-cache-control-outbound",
71
+ "handler": {
72
+ "export": "CdnCacheControlOutboundPolicy",
73
+ "module": "$import(@zuplo/runtime)",
74
+ "options": {
75
+ "cdn": "akamai",
76
+ "edge": {
77
+ "maxAge": 1800
78
+ },
79
+ "client": {
80
+ "visibility": "public",
81
+ "maxAge": 60
82
+ },
83
+ "tags": ["catalog"]
84
+ }
85
+ }
86
+ }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ This is an outbound policy, so attach it to the `policies.outbound` array of the
92
+ route in `config/routes.oas.json`:
93
+
94
+ ```json title="config/routes.oas.json (excerpt)"
95
+ {
96
+ "x-zuplo-route": {
97
+ "policies": {
98
+ "outbound": ["cdn-cache-control"]
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ The `"cdn-cache-control"` string must match the `name` field in
105
+ `config/policies.json`. Attach the same policy to every route that shares a
106
+ cache profile, and define a second policy with a different `name` for routes
107
+ that need different TTLs or tags.
108
+
109
+ ## Choose your CDN
110
+
111
+ `cdn` is required and has no default. Each CDN reads a different header, so a
112
+ default would silently produce a well-formed response with no edge caching at
113
+ all for anyone running a different one — a `curl` that looks correct and a hit
114
+ ratio of zero.
115
+
116
+ | `cdn` | Edge TTL header | Purge tag header | Tag separator |
117
+ | ------------ | ------------------------------ | ---------------- | ------------- |
118
+ | `akamai` | `Edge-Control` | `Edge-Cache-Tag` | comma |
119
+ | `fastly` | `Surrogate-Control` | `Surrogate-Key` | space |
120
+ | `cloudflare` | `Cloudflare-CDN-Cache-Control` | `Cache-Tag` | comma |
121
+ | `cloudfront` | `Cache-Control: s-maxage` | none | — |
122
+ | `generic` | `CDN-Cache-Control` (RFC 9213) | none | — |
123
+
124
+ Use `generic` for a CDN that implements RFC 9213 targeted cache control but is
125
+ not named above.
126
+
127
+ Naming the vendor also lets the policy reject configuration that vendor cannot
128
+ honor instead of dropping it silently: `tags` with `cloudfront` is a build-time
129
+ error pointing you at path invalidation, because CloudFront has no purge by tag.
130
+
131
+ The `strategy` option controls how the edge TTL travels. The default,
132
+ `targeted`, uses the CDN's own header. `s-maxage` folds the edge TTL into
133
+ `Cache-Control` instead, for a property that honors origin `Cache-Control` but
134
+ has not enabled the targeted header. CloudFront defaults to `s-maxage` because
135
+ it reads no targeted header, and `strategy: "targeted"` there is a build-time
136
+ error rather than a silent no-op.
137
+
138
+ Either way, the policy removes any targeted header your backend already set that
139
+ the configured CDN reads and that the policy is not writing itself. A targeted
140
+ header outranks `Cache-Control`, so an upstream `Surrogate-Control` with a large
141
+ `max-age` would otherwise beat the TTL configured here.
142
+
143
+ ## Split the edge TTL from the client TTL
144
+
145
+ Take a catalog endpoint on a busy storefront. The product list changes a few
146
+ times a day and every caller sees the same body, so it gets a 30-minute edge TTL
147
+ and a 60-second client TTL — the configuration in
148
+ [Add the policy](#add-the-policy) above. On Akamai that emits:
149
+
150
+ ```http
151
+ Cache-Control: public, max-age=60
152
+ Edge-Control: cache-maxage=30m
153
+ Edge-Cache-Tag: catalog
154
+ ```
155
+
156
+ At steady state the edge serves nearly all of the traffic: one request per POP
157
+ per 30 minutes reaches Zuplo. Clients revalidate once a minute, which costs a
158
+ conditional request to the edge, not to your backend.
159
+
160
+ Now merchandising updates a product and your catalog service purges the
161
+ `catalog` tag. The two audiences diverge:
162
+
163
+ - **The edge** drops every response tagged `catalog` immediately. The next
164
+ request repopulates it from Zuplo with fresh data.
165
+ - **Clients** keep their copy until their own 60-second `max-age` expires. No
166
+ purge reaches a handset.
167
+
168
+ Sixty seconds of client staleness buys the round trips a client cache saves. Had
169
+ both audiences shared one 30-minute `max-age`, that window would be half an hour
170
+ and entirely outside your control. Keep the client TTL far shorter than the edge
171
+ TTL: the edge is what you can purge, and a client is not.
172
+
173
+ ## Cache authenticated responses at the CDN only
174
+
175
+ The most valuable configuration is one a single `Cache-Control` cannot express
176
+ at all: the response is identical for every caller but requires an
177
+ `Authorization` header, so the CDN should absorb the load while no browser
178
+ stores the body.
179
+
180
+ ```json title="config/policies.json (options excerpt)"
181
+ {
182
+ "cdn": "cloudflare",
183
+ "edge": { "maxAge": 300 },
184
+ "client": { "visibility": "private", "maxAge": 0 }
185
+ }
186
+ ```
187
+
188
+ ```http
189
+ Cache-Control: private, max-age=0
190
+ Cloudflare-CDN-Cache-Control: max-age=300
191
+ ```
192
+
193
+ This works because a CDN that honors a targeted cache header ignores
194
+ `Cache-Control` entirely for its own decision — it never sees the `private`. A
195
+ `private` in `Cache-Control` alone would have suppressed edge caching too, which
196
+ is why the targeted header matters here. CDN-only caching needs
197
+ `strategy: "targeted"`; `s-maxage` cannot express it.
198
+
199
+ Before enabling this on a route, confirm the response really is identical for
200
+ every authenticated caller, and that the edge cache key includes whatever
201
+ distinguishes them if it is not.
202
+
203
+ ## Survive a backend outage with staleIfError
204
+
205
+ `edge.staleIfError` is often the highest-value directive on an API. It tells the
206
+ CDN how long it may keep serving the last good response while the gateway
207
+ returns errors, and a read endpoint that keeps working through a backend outage
208
+ beats one that returns 503.
209
+
210
+ ```json title="config/policies.json (options excerpt)"
211
+ {
212
+ "cdn": "fastly",
213
+ "edge": {
214
+ "maxAge": 1800,
215
+ "staleWhileRevalidate": 600,
216
+ "staleIfError": 86400
217
+ },
218
+ "client": { "visibility": "public", "maxAge": 60 }
219
+ }
220
+ ```
221
+
222
+ ```http
223
+ Cache-Control: public, max-age=60
224
+ Surrogate-Control: max-age=1800, stale-while-revalidate=600, stale-if-error=86400
225
+ ```
226
+
227
+ A backend that fails at 3 a.m. no longer takes reads down with it: the edge
228
+ answers from its last good copy for up to a day while you fix the cause.
229
+ `staleWhileRevalidate` covers the smaller case — once the TTL expires the edge
230
+ serves the stale copy immediately and refreshes in the background, so no caller
231
+ waits for the refill.
232
+
233
+ :::caution{title="Akamai cannot express the stale windows"}
234
+
235
+ Akamai's `Edge-Control` header has no equivalent of `stale-while-revalidate` or
236
+ `stale-if-error`. The policy rejects that combination rather than dropping the
237
+ directives and letting you believe stale serving is configured. On Akamai,
238
+ either configure stale serving in Property Manager or set `strategy: "s-maxage"`
239
+ so the directives travel in `Cache-Control`. See
240
+ [Akamai CDN caching](../dedicated/akamai/caching.mdx) for the property setup.
241
+
242
+ :::
243
+
244
+ ## Purge by tag
245
+
246
+ A tag labels a cached response so you can purge a slice of the edge cache later
247
+ — every response tagged `cities` after a city sync — without waiting out the TTL
248
+ or purging everything.
249
+
250
+ **Tag by entity, not just by route.** A `catalog` tag forces you to purge the
251
+ whole catalog when one product changes; adding `product-8891` lets the write
252
+ path purge exactly what it touched. Per-entity tags depend on the request, so
253
+ they come from a [per-response cache rule](./dynamic-cache-rules.mdx) rather
254
+ than the static `tags` array:
255
+
256
+ ```ts title="modules/cdn-cache.ts"
257
+ return {
258
+ edge: { maxAge: 1800, staleIfError: 86400 },
259
+ client: { maxAge: 60, visibility: "public" },
260
+ tags: ["catalog", `product-${productId}`],
261
+ };
262
+ ```
263
+
264
+ Tags are validated against the target CDN's limits before they are emitted:
265
+
266
+ | CDN | Max tag length | Max tags | Max header size |
267
+ | ---------- | -------------- | -------- | --------------- |
268
+ | Akamai | 128 chars | 128 | 8192 bytes |
269
+ | Fastly | 1024 bytes | — | 16384 bytes |
270
+ | Cloudflare | 1024 chars | — | 16384 bytes |
271
+
272
+ Validation matters because the failure mode is silent. Fastly ignores the key it
273
+ is parsing _and every key after it_ once a limit is hit, so one over-long tag
274
+ makes later tags quietly unpurgeable. Akamai additionally rejects spaces,
275
+ commas, colons and brackets. Static tags in `config/policies.json` fail as a
276
+ configuration error you fix before deploying; tags returned from a function are
277
+ dropped with a warning instead, because a 200 should not become a 500 over a
278
+ purge tag.
279
+
280
+ ### Calling the purge
281
+
282
+ The policy **emits** tags. It does not purge. Purging is an API call your own
283
+ tooling makes when the underlying data changes. On Fastly that is one request
284
+ per surrogate key:
285
+
286
+ ```bash
287
+ curl -X POST \
288
+ -H "Fastly-Key: $FASTLY_API_TOKEN" \
289
+ "https://api.fastly.com/service/$FASTLY_SERVICE_ID/purge/product-8891"
290
+ ```
291
+
292
+ Akamai's equivalent is a Fast Purge call to `/ccu/v3/invalidate/tag/production`
293
+ with the tags in the body, signed with EdgeGrid credentials; Cloudflare takes a
294
+ `tags` array on its purge endpoint. Wire whichever one you use into the service
295
+ that writes the data, so a product update purges `product-8891` as part of
296
+ saving it.
297
+
298
+ ## Safety defaults
299
+
300
+ Three defaults keep a broadly applied cache policy from causing an incident.
301
+
302
+ **`client.mode` defaults to `strictest`.** When the upstream already sent a
303
+ `Cache-Control`, the policy emits the more conservative of the two: the lower
304
+ `max-age` and the union of the restrictive directives. Loosening a cache
305
+ directive is a data-disclosure risk, not only a performance one — if your
306
+ application returns `Cache-Control: private` for a per-user response and the
307
+ policy widened it to `public`, a shared cache could serve one user's data to the
308
+ next. Use `replace` when the gateway should be the authority on client caching,
309
+ or `preserve` to leave the upstream header alone and add only the CDN headers.
310
+
311
+ **`respectUpstream` defaults to `true`.** An upstream `Cache-Control` of
312
+ `no-store`, `no-cache` or `private`, or a `Set-Cookie` on the response,
313
+ suppresses every edge header and purge tag. Your application keeps the final say
314
+ on what is cacheable, and that holds even when the policy is attached broadly.
315
+ Setting it to `false` is the deliberate override; on Akamai that emits
316
+ `Edge-Control: !no-store`.
317
+
318
+ **`vary` is off by default.** Akamai skips caching any response carrying a
319
+ `Vary` header until Cache ID Modification is configured in Property Manager, so
320
+ enabling `vary` can disable edge caching entirely while every response still
321
+ looks correct. The policy logs a warning when `vary` is set with `cdn: akamai`.
322
+ When a response genuinely varies by a request header, configure the cache key at
323
+ the CDN — Cache ID Modification on Akamai, Cache Keys on Cloudflare — rather
324
+ than relying on `Vary`.
325
+
326
+ ## Verify it works
327
+
328
+ Send a request to your Zuplo URL and inspect the response headers:
329
+
330
+ ```bash
331
+ curl -sI https://my-api.zuplo.app/v1/catalog/products
332
+ ```
333
+
334
+ For the Akamai configuration above, expect:
335
+
336
+ ```http
337
+ HTTP/2 200
338
+ cache-control: public, max-age=60
339
+ edge-control: cache-maxage=30m
340
+ edge-cache-tag: catalog
341
+ ```
342
+
343
+ A missing `edge-control` usually means the response carried a `Set-Cookie` or an
344
+ upstream `no-store`: with `respectUpstream: true`, either one suppresses the
345
+ edge headers by design.
346
+
347
+ That `curl` proves the gateway emits the right headers. Whether the CDN acts on
348
+ them is CDN configuration:
349
+
350
+ - **Akamai** — the property's caching behavior must be set to honor origin
351
+ `Cache-Control`, and Cache Tag Visibility controls whether `Edge-Cache-Tag`
352
+ reaches end users. See [Akamai CDN caching](../dedicated/akamai/caching.mdx).
353
+ - **Cloudflare** — setting `Cloudflare-CDN-Cache-Control` turns Origin Cache
354
+ Control on regardless of the dashboard setting, but a Cache Rule still has to
355
+ make the path eligible for caching.
356
+
357
+ Confirm the offload at the edge rather than at the gateway: check the CDN's hit
358
+ ratio for the path, or send the same request twice and look for the second one
359
+ missing from your Zuplo logs.
360
+
361
+ ## Don't stack this with gateway caching
362
+
363
+ :::caution{title="One cache per route"}
364
+
365
+ Do not use this policy and the [Caching](../policies/caching-inbound.mdx) policy
366
+ on the same route. They are alternative places to cache, not layers that stack,
367
+ and combining them gives you neither reliably.
368
+
369
+ The Caching policy serves a hit from an inbound policy, which short-circuits the
370
+ pipeline before outbound policies run, so this policy never executes on a
371
+ gateway cache hit. The CDN then receives the copy that the Caching policy
372
+ stored, and the Caching policy sanitizes every copy it stores: it drops several
373
+ CDN cache headers (exactly which ones depends on the CDN) and overwrites
374
+ `Cache-Control` with its own `s-maxage` (its TTL, 60 seconds by default). The
375
+ edge and client TTLs collapse into one number.
376
+
377
+ Cache misses behave correctly, which is what makes this dangerous. Misses run
378
+ the full outbound stack and produce exactly the headers you configured, so the
379
+ bug stays invisible while you test and appears only once traffic warms the
380
+ cache. Nothing logs it. Pick one: this policy on its own, or
381
+ [cache at the gateway](./gateway-caching.mdx) and let that policy own the
382
+ `Cache-Control` it sends.
383
+
384
+ :::
385
+
386
+ ## Next steps
387
+
388
+ - [Per-response cache rules](./dynamic-cache-rules.mdx) — a `cacheConfig`
389
+ function for anything the static options cannot express: per-entity purge
390
+ tags, a TTL that depends on a response header, or skipping an empty result
391
+ set.
392
+ - [Akamai CDN caching](../dedicated/akamai/caching.mdx) — the Property Manager
393
+ side of the setup.
394
+ - [Cache at the gateway](./gateway-caching.mdx) — when no CDN sits in front of
395
+ Zuplo, or the cache needs to sit closer to the backend.
396
+ - [CDN Cache Control policy reference](../policies/cdn-cache-control-outbound.mdx)
397
+ — every configuration option in detail.