zuplo 6.74.14 → 7.0.0

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.
@@ -0,0 +1,405 @@
1
+ The CDN Cache Control policy sets the response headers a CDN in front of your
2
+ gateway reads to decide how long it may cache a response and which purge tags
3
+ that response belongs to. It keeps that policy separate from the `Cache-Control`
4
+ you send to the browser.
5
+
6
+ ## Why the two are separate
7
+
8
+ A single `Cache-Control` header has to serve two audiences with different needs.
9
+ If you write `Cache-Control: public, max-age=1800` so your CDN holds a response
10
+ for 30 minutes, you have also told every browser and mobile app to hold it for
11
+ 30 minutes. When the underlying data changes you can purge the edge in seconds —
12
+ but no purge reaches those clients, and they keep serving stale data until the
13
+ TTL runs out.
14
+
15
+ This policy writes the edge TTL into a header only the CDN reads, so the two can
16
+ differ:
17
+
18
+ ```
19
+ Cache-Control: public, max-age=60
20
+ Edge-Control: cache-maxage=30m
21
+ Edge-Cache-Tag: catalog,cities
22
+ ```
23
+
24
+ The CDN absorbs the traffic for half an hour, clients re-check every minute, and
25
+ a purge by the `cities` tag takes effect immediately for everyone.
26
+
27
+ ## Choosing the CDN
28
+
29
+ `cdn` is required and has no default. Each CDN reads a different header, so a
30
+ default would silently produce a well-formed response with no edge caching for
31
+ anyone using a different one.
32
+
33
+ | `cdn` | Edge TTL header | Purge tag header | Tag separator |
34
+ | ------------ | ------------------------------ | ---------------- | ------------- |
35
+ | `akamai` | `Edge-Control` | `Edge-Cache-Tag` | comma |
36
+ | `fastly` | `Surrogate-Control` | `Surrogate-Key` | space |
37
+ | `cloudflare` | `Cloudflare-CDN-Cache-Control` | `Cache-Tag` | comma |
38
+ | `cloudfront` | `Cache-Control: s-maxage` | none | — |
39
+ | `generic` | `CDN-Cache-Control` (RFC 9213) | none | — |
40
+
41
+ Use `generic` for a CDN that implements RFC 9213 targeted cache control but is
42
+ not listed above.
43
+
44
+ ## Example Configuration
45
+
46
+ ```json
47
+ {
48
+ "export": "CdnCacheControlOutboundPolicy",
49
+ "module": "$import(@zuplo/runtime)",
50
+ "options": {
51
+ "cdn": "akamai",
52
+ "edge": {
53
+ "maxAge": 1800
54
+ },
55
+ "client": {
56
+ "visibility": "public",
57
+ "maxAge": 60
58
+ },
59
+ "tags": ["catalog", "cities"]
60
+ }
61
+ }
62
+ ```
63
+
64
+ On Fastly the same options emit space-separated surrogate keys, which is the
65
+ format Fastly's purge API expects:
66
+
67
+ ```
68
+ Cache-Control: public, max-age=60
69
+ Surrogate-Control: max-age=1800
70
+ Surrogate-Key: catalog cities
71
+ ```
72
+
73
+ ## CDN-only caching
74
+
75
+ The most valuable configuration is one a single `Cache-Control` cannot express:
76
+ the response is identical for every caller but requires an `Authorization`
77
+ header, so you want the CDN to absorb the load while no browser stores it.
78
+
79
+ ```json
80
+ {
81
+ "export": "CdnCacheControlOutboundPolicy",
82
+ "module": "$import(@zuplo/runtime)",
83
+ "options": {
84
+ "cdn": "cloudflare",
85
+ "edge": { "maxAge": 300 },
86
+ "client": { "visibility": "private", "maxAge": 0 }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ```
92
+ Cache-Control: private, max-age=0
93
+ Cloudflare-CDN-Cache-Control: max-age=300
94
+ ```
95
+
96
+ This works because a CDN that honors a targeted cache header ignores
97
+ `Cache-Control` entirely for its own decision — it never sees the `private`.
98
+
99
+ ## edge
100
+
101
+ How long the CDN may serve the response.
102
+
103
+ - `maxAge` — seconds the CDN may serve the response without revalidating.
104
+ - `staleWhileRevalidate` — seconds the CDN may keep serving a stale response
105
+ while it revalidates in the background.
106
+ - `staleIfError` — seconds the CDN may serve a stale response when the gateway
107
+ returns an error. This is often the highest-value directive for an API: it
108
+ keeps reads working through a backend outage.
109
+
110
+ Akamai's `Edge-Control` header cannot express the two `stale-*` windows. If you
111
+ need them on Akamai, either configure stale serving in Property Manager or set
112
+ `strategy` to `s-maxage`. The policy rejects the combination rather than
113
+ dropping the directives silently.
114
+
115
+ ## client
116
+
117
+ The `Cache-Control` sent to the browser or app.
118
+
119
+ - `maxAge` — seconds the client may reuse the response.
120
+ - `visibility` — `public`, `private`, or `no-store`.
121
+ - `noCache` — adds `no-cache`, so the client revalidates before reusing the
122
+ response. Distinct from `visibility: no-store`, which forbids storing it at
123
+ all.
124
+ - `mustRevalidate` — adds `must-revalidate`, so the client may not serve the
125
+ response once stale.
126
+ - `mode` — how to combine this with a `Cache-Control` the upstream already sent.
127
+
128
+ ### client.mode
129
+
130
+ | Mode | Behavior |
131
+ | ----------- | ------------------------------------------------------------------------------------------------------------------- |
132
+ | `strictest` | **Default.** Emits the more conservative of the two — the lower `max-age`, and the union of restrictive directives. |
133
+ | `replace` | Overwrites whatever the upstream sent. |
134
+ | `preserve` | Leaves the upstream `Cache-Control` untouched and only adds the CDN headers. |
135
+
136
+ `strictest` is the default because loosening a cache directive is a data
137
+ disclosure risk, not only a performance one. If your application returns
138
+ `Cache-Control: private` for a per-user response and the policy widened that to
139
+ `public`, a shared cache could serve one user's data to the next. Use `replace`
140
+ when you intend the gateway to be the authority on client caching.
141
+
142
+ ## strategy
143
+
144
+ How the edge TTL reaches the CDN.
145
+
146
+ - `targeted` (default) — the CDN's own header. Required for CDN-only caching.
147
+ - `s-maxage` — folds the edge TTL into `Cache-Control` as `s-maxage`, for a
148
+ property that honors origin `Cache-Control` but not the targeted header.
149
+
150
+ **On CloudFront the default is `s-maxage`**, since it reads no targeted header
151
+ and there is nothing to choose between. You only need to set `strategy`
152
+ explicitly when you want `s-maxage` on a CDN that _does_ read a targeted header
153
+ — for example an Akamai property that honors origin `Cache-Control` but has not
154
+ enabled `Edge-Control`. Setting `strategy: "targeted"` on CloudFront is a
155
+ build-time error, because that is a contradiction rather than an omission.
156
+
157
+ Either way, the policy **removes any targeted header — or purge-tag header —
158
+ your backend already set that the configured CDN reads**, unless the policy is
159
+ writing that header itself. A targeted header outranks `Cache-Control`, so an
160
+ upstream `Surrogate-Control: max-age=99999` reaching Fastly would silently beat
161
+ the TTL configured here — particularly under `s-maxage`, where the policy's own
162
+ TTL is in `Cache-Control`. Only the configured vendor's headers are touched: on
163
+ Cloudflare that means both `Cloudflare-CDN-Cache-Control` and
164
+ `CDN-Cache-Control`, while on CloudFront (which reads neither) nothing is
165
+ removed.
166
+
167
+ That includes the tag header when you configure no `tags`: a backend-set
168
+ `Surrogate-Key` was never checked against the vendor's limits, so it is cleared
169
+ rather than passed through unvalidated.
170
+
171
+ If your backend is the one that should own the edge policy, **do not apply this
172
+ policy to that route.** There is no configuration that lets a backend-set
173
+ targeted header through: `edge` is required unless `cacheConfig` supplies it at
174
+ runtime, and the vendor's headers are stripped on every path — including when a
175
+ response is opted out with `cache: false`, which removes them and writes nothing
176
+ in their place. `client.mode: "preserve"` preserves the _client_
177
+ `Cache-Control` only; it has no bearing on the edge headers.
178
+
179
+ ## tags
180
+
181
+ Purge tags, rendered in the target CDN's format. Tags let you invalidate a slice
182
+ of the edge cache — every response tagged `cities` after a city sync — without
183
+ waiting for the TTL or purging everything.
184
+
185
+ Tags are validated against the CDN's limits before they are emitted:
186
+
187
+ | CDN | Max tag length | Max tags | Max header size |
188
+ | ---------- | -------------- | -------- | --------------- |
189
+ | Akamai | 128 chars | 128 | 8192 bytes |
190
+ | Fastly | 1024 bytes | — | 16384 bytes |
191
+ | Cloudflare | 1024 chars | — | 16384 bytes |
192
+
193
+ This validation matters because the failure mode is silent. Fastly ignores the
194
+ key it is parsing _and every key after it_ once a limit is hit, so an over-long
195
+ tag makes later tags quietly unpurgeable. Akamai additionally rejects spaces,
196
+ commas, colons and brackets in tag values.
197
+
198
+ A tag that violates a limit is dropped and logged as a warning rather than
199
+ failing the response — a 200 should not become a 500 over a purge tag — except
200
+ in static configuration, where it is a configuration error you can fix before
201
+ deploying.
202
+
203
+ CloudFront has no purge-by-tag support; use path invalidation instead.
204
+
205
+ ## respectUpstream
206
+
207
+ When `true` (the default), an upstream `Cache-Control` of `no-store`, `no-cache`
208
+ or `private`, or a `Set-Cookie` on the response, suppresses every edge header
209
+ and purge tag. Your application keeps the ability to mark a response
210
+ uncacheable, and that keeps working even if the policy is applied broadly.
211
+
212
+ Set it to `false` to override the upstream. On Akamai this emits
213
+ `Edge-Control: !no-store`, the vendor's own directive for caching at the edge
214
+ despite a `no-store`.
215
+
216
+ ## vary
217
+
218
+ Request headers that vary the response, emitted as `Vary`. This is **off by
219
+ default** for a reason specific to Akamai: Akamai skips caching any response
220
+ carrying a `Vary` header until Cache ID Modification is configured in Property
221
+ Manager. Turning this on can therefore disable edge caching entirely while the
222
+ response still looks correct. The policy logs a warning when `vary` is set
223
+ together with `cdn: akamai`.
224
+
225
+ The names you configure are **unioned** with any `Vary` the upstream already
226
+ sent, deduplicated case-insensitively; `Vary: *` from either side absorbs the
227
+ rest. Unlike the edge headers, `Vary` is still emitted when an upstream veto or
228
+ `cache: false` suppresses edge caching — a `private, max-age=300` response is
229
+ uncacheable at the edge but still cached by the browser, and it needs the variant
230
+ key just as much. The policy never narrows an upstream `Vary`, because doing so silently is
231
+ unsafe: if the origin sent `Vary: Authorization` and you configure
232
+ `vary: ["accept-language"]`, replacing it would stop a shared cache keying on
233
+ `Authorization` and let it serve one user's authorized response to another. To
234
+ deliberately drop a wasteful upstream `Vary` — `Vary: User-Agent` shreds hit
235
+ rates — remove it in a separate outbound policy before this one runs.
236
+
237
+ If your response genuinely varies by a request header, configure the cache key
238
+ at the CDN — Cache ID Modification on Akamai, Cache Keys on Cloudflare — rather
239
+ than relying on `Vary`.
240
+
241
+ ## cacheConfig
242
+
243
+ For rules that cannot be expressed statically, a function can return the cache
244
+ configuration per response.
245
+
246
+ The static options stay the enforced default: **a function that returns nothing
247
+ falls back to them.** So you configure the policy once for the route, and the
248
+ function only has to handle the exceptions.
249
+
250
+ ```ts
251
+ export default function cdnCache(response: Response) {
252
+ // An empty result set is usually a transient upstream problem.
253
+ if (response.headers.get("x-result-count") === "0") {
254
+ return { cache: false };
255
+ }
256
+ // Everything else: fall back to the configured edge/client/tags.
257
+ return undefined;
258
+ }
259
+ ```
260
+
261
+ When the function _does_ return a config, that config **replaces** the static
262
+ options rather than being merged into them field by field — so a returned
263
+ `{ edge: { maxAge: 60 } }` drops a statically configured `staleIfError` and the
264
+ static `tags`. Return the complete policy for the responses you override.
265
+
266
+ ### Reading the response body
267
+
268
+ The function receives the live response, not a copy. **Do not consume the body**
269
+ — the policy still has to forward it, and reading it first makes that
270
+ impossible. If you need to inspect the payload, clone it:
271
+
272
+ ```ts
273
+ const body = await response.clone().json();
274
+ ```
275
+
276
+ Cloning is left to you rather than done for every response: `clone()` tees the
277
+ stream, and a branch nobody reads keeps the whole body in memory. Most rules can
278
+ avoid the body entirely — a response header such as `x-result-count` is cheaper
279
+ than parsing JSON on every cacheable response.
280
+
281
+ If the body is consumed without cloning, the policy fails the request with a
282
+ message naming the cause, rather than letting the response go out truncated.
283
+
284
+ The three return values:
285
+
286
+ | Return | Effect |
287
+ | ------------------ | ----------------------------------------------------------------------------------- |
288
+ | `undefined`/`null` | Use the static options unchanged. |
289
+ | a config object | Use it in place of the static options. |
290
+ | `{ cache: false }` | Emit no edge headers or tags, **and** send `Cache-Control: no-store` to the client — except under `client.mode: "preserve"`, which is a trap; see below. |
291
+
292
+ `{ cache: false }` is a positive statement that the response must not be cached
293
+ anywhere, so it disables client caching as well as edge caching.
294
+
295
+ **Under `client.mode: "preserve"` it does not, and this is a sharp edge.** The
296
+ policy suppresses its own edge headers but leaves the upstream `Cache-Control`
297
+ alone — and that header is exactly what a CDN falls back to once the targeted
298
+ header is gone. An upstream `Cache-Control: public, max-age=300` therefore keeps
299
+ the response cacheable at the edge for five minutes despite the `cache: false`.
300
+ On CloudFront, which reads no targeted header at all, `cache: false` under
301
+ `preserve` changes nothing whatsoever.
302
+
303
+ To make the edge skip a response while clients still cache it normally, return
304
+ `{ edge: { maxAge: 0 } }` with `client.mode: "preserve"`. That states a zero
305
+ edge TTL in the CDN's own dialect — `Surrogate-Control: max-age=0` on Fastly,
306
+ `s-maxage=0` appended to the client's own directives on CloudFront — rather than
307
+ relying on the absence of a header to mean "do not cache".
308
+
309
+ ```json
310
+ {
311
+ "export": "CdnCacheControlOutboundPolicy",
312
+ "module": "$import(@zuplo/runtime)",
313
+ "options": {
314
+ "cdn": "fastly",
315
+ "cacheConfig": {
316
+ "module": "$import(./modules/cdn-cache)",
317
+ "export": "default"
318
+ }
319
+ }
320
+ }
321
+ ```
322
+
323
+ ```ts
324
+ import {
325
+ CdnCacheControlConfig,
326
+ ZuploContext,
327
+ ZuploRequest,
328
+ } from "@zuplo/runtime";
329
+
330
+ export default function cdnCache(
331
+ response: Response,
332
+ request: ZuploRequest,
333
+ context: ZuploContext
334
+ ): CdnCacheControlConfig {
335
+ // An empty result set is usually a transient upstream problem — don't cache it.
336
+ if (response.headers.get("x-result-count") === "0") {
337
+ return { cache: false };
338
+ }
339
+
340
+ const cityId = new URL(request.url).searchParams.get("cityId");
341
+
342
+ return {
343
+ edge: { maxAge: 1800, staleIfError: 86400 },
344
+ client: { maxAge: 60, visibility: "public" },
345
+ tags: ["cities", `city-${cityId}`],
346
+ };
347
+ }
348
+ ```
349
+
350
+ The function returns intent, not headers: the policy renders it into whichever
351
+ dialect `cdn` names, so switching CDNs is a one-line configuration change rather
352
+ than a rewrite. Per-entity tags like `city-42` are the main thing that cannot be
353
+ expressed statically, and they are what makes fine-grained purging possible.
354
+
355
+ Note that `{ cache: false }` and `undefined` are **not** the same:
356
+ `cache: false` suppresses caching for that response, while `undefined` falls
357
+ back to the static options.
358
+
359
+ ## Using this with the Caching policy
360
+
361
+ This policy sets headers for a CDN _outside_ Zuplo. The
362
+ [Caching](https://zuplo.com/docs/policies/caching-inbound) policy is a separate,
363
+ gateway-side response cache.
364
+
365
+ **Do not use the two on the same route.** They are alternative places to cache,
366
+ not layers that stack, and combining them gives you neither policy reliably.
367
+
368
+ The Caching policy serves a hit from an _inbound_ policy, which short-circuits
369
+ the pipeline before outbound policies run — so this policy does not execute on a
370
+ gateway cache hit. The CDN instead receives the copy the Caching policy stored,
371
+ which it sanitizes on the way in: several CDN headers are removed and
372
+ `Cache-Control` is overwritten with its own `s-maxage` (60s by default). Cache
373
+ misses behave correctly, so the problem is invisible until traffic warms the
374
+ cache.
375
+
376
+ Pick one:
377
+
378
+ - **Cache at the CDN** — use this policy alone. Purges are global and fast, and
379
+ the `edge`/`client` split works as configured.
380
+ - **Cache at the gateway** — use the Caching policy alone, and let it own the
381
+ `Cache-Control` it sends.
382
+
383
+ ## Best Practices
384
+
385
+ 1. **Make the client TTL much shorter than the edge TTL.** The edge is what you
386
+ can purge; clients are not. A short client `max-age` with a long
387
+ `edge.maxAge` gives you both offload and control.
388
+ 2. **Set `staleIfError`** on read endpoints where CDN support allows it. Serving
389
+ slightly stale data through a backend outage is almost always better than
390
+ serving an error.
391
+ 3. **Tag by entity, not just by route.** `city-42` lets you purge one city;
392
+ `cities` alone forces you to purge them all.
393
+ 4. **Leave `respectUpstream` on** so the application retains the final say.
394
+ 5. **Verify at the edge, not just at the gateway.** `curl -I` against your Zuplo
395
+ URL shows the headers; whether the CDN honors them depends on its
396
+ configuration (Akamai's "Honor origin Cache-Control", Cloudflare's Origin
397
+ Cache Control, a Cache Rule that makes the path cacheable at all).
398
+
399
+ ## Limitations
400
+
401
+ - Purge tags are emitted, not purged. Invalidation is an API call to your CDN
402
+ from your own tooling.
403
+ - Edge cache-key configuration is not expressible in response headers and
404
+ remains CDN configuration.
405
+ - CloudFront supports neither a targeted cache header nor purge tags.
@@ -0,0 +1,26 @@
1
+ The CDN Cache Control policy sets the response headers that tell a CDN in front
2
+ of your gateway how long it may cache a response, and which purge tags that
3
+ response belongs to — separately from what you tell the browser.
4
+
5
+ With this policy, you'll benefit from:
6
+
7
+ - **Two audiences, two policies**: let the CDN hold a response for 30 minutes
8
+ while browsers hold it for 60 seconds, so a purge at the edge actually takes
9
+ effect
10
+ - **CDN-only caching**: cache an authenticated response at the edge without any
11
+ browser or intermediary storing it
12
+ - **No dialect to learn**: Akamai's `Edge-Control` and `Edge-Cache-Tag`,
13
+ Fastly's `Surrogate-Control` and space-separated `Surrogate-Key`, Cloudflare's
14
+ `Cache-Tag` — you configure intent and the policy writes the right headers
15
+ - **Purge tags with guard rails**: tags are validated against each CDN's length,
16
+ count and character limits, because an over-long tag header is silently
17
+ truncated at the edge rather than rejected
18
+ - **Safe by default**: an upstream `no-store`, `private` or `Set-Cookie` keeps
19
+ its veto, and the default merge mode never loosens a policy your application
20
+ deliberately tightened
21
+ - **Dynamic configuration**: a function can return per-response TTLs and
22
+ per-entity purge tags for anything that can't be expressed statically
23
+
24
+ Set `cdn` to the CDN actually in front of your gateway. It has no default,
25
+ because every CDN reads a different header and a wrong guess produces a
26
+ well-formed response with no edge caching at all.