Skip to content

Commit 53e7d9b

Browse files
authored
Improve defaults and docs (#4)
1 parent 12d8dad commit 53e7d9b

File tree

5 files changed

+143
-91
lines changed

5 files changed

+143
-91
lines changed

README.md

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Easy, opinionated CDN cache header handling.
44

5-
This package provides a subclass of the `Headers` class that makes it easier to set cache control headers for content served through a modern CDN. It provides a simple, chainable API with sensible defaults for common use cases. It works by setting the `Cache-Control` and `CDN-Cache-Control` headers to the appropriate values.
5+
Modern CDNs allow very fine-grained control over the cache. This is particularly useful for server-side rendering of web content, as it allows you to manually handle the invalidation of content, ensuring it stays fast and fresh. This package provides a subclass of the `Headers` class that makes it easier to set cache control headers for content served through a modern CDN. It provides a simple, chainable API with sensible defaults for common use cases. It works by setting the `Cache-Control` and `CDN-Cache-Control` headers to the appropriate values. If run on a supported platform it will use the more specific header for that CDN. e.g. on Netlify it will use the `Netlify-CDN-Cache-Control` header.
66

77
e.g.
88

@@ -25,38 +25,86 @@ import { CacheHeaders } from "jsr:@ascorbic/cdn-cache-control";
2525

2626
## Usage
2727

28-
The module exports a single class, `CacheHeaders`, which is a subclass of the fetch [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) class. It provides a chainable API for setting headers.
28+
The module exports a single class, `CacheHeaders`, which is a subclass of the fetch [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) class. It provides a chainable API for setting cache headers. By default it sets the `Cache-Control` and `CDN-Cache-Control` headers to sensible defaults for content that should be cached by the CDN and revalidated by the browser.
29+
30+
It can be instantiated with a `HeadersInit` value, which lets you base it on an existing `Headers` object, or an object or array with existing header values. In that case it will default to using existing `s-maxage` directives if present.
2931

3032
### Use cases
3133

32-
If you have content that you want to have the CDN cache until it is manually revalidated or purged with a new deploy, you can use the `revalidatable` method. This is sometimes called "on-demand ISR":
34+
If you have content that you want to have the CDN cache until it is manually revalidated or purged with a new deploy, you can use the default values:
3335

3436
```javascript
3537
import { CacheHeaders } from "cdn-cache-control";
3638

37-
const headers = new CacheHeaders().revalidatable();
39+
const headers = new CacheHeaders();
3840
```
3941

40-
This sets the `CDN-Cache-Control` header to `public,s-maxage=31536000,must-revalidate`, which tells the CDN to cache the content for a year, but to revalidate it after that time. It sets `Cache-Control` to `public, max-age=0, must-revalidate`, which tells the browser to always check with the CDN for a fresh version. You should combine this with an `ETag` header to allow the CDN to serve a `304 Not Modified` response when the content hasn't changed.
42+
This sets the `CDN-Cache-Control` header to `public,s-maxage=31536000,must-revalidate`, which tells the CDN to cache the content for a year. It sets `Cache-Control` to `public,max-age=0,must-revalidate`, which tells the browser to always check with the CDN for a fresh version. You should combine this with an `ETag` or `Last-Modified` header to allow the CDN to serve a `304 Not Modified` response when the content hasn't changed.
43+
44+
#### stale-while-revalidate
4145

42-
You can enable `stale-while-revalidate` with the `swr` method:
46+
You can enable `stale-while-revalidate` with the `swr` method, optionally passing a value for the time to serve stale content (defaults to one week):
4347

4448
```javascript
4549
import { CacheHeaders } from "cdn-cache-control";
4650

4751
const headers = new CacheHeaders().swr();
4852
```
4953

50-
This tells the CDN to serve stale content for up to a year while revalidating the content in the background. It calls `revalidatable` internally, so you don't need to call both.
51-
52-
You can set the time-to-live either by passing a value to `revalidatable`, or with the `ttl` method:
54+
This tells the CDN to serve stale content while revalidating the content in the background. Combine with the `ttl` method to set the time for which the content will be considered fresh (default is zero, meaning the CDN will always revalidate):
5355

5456
```javascript
5557
import { CacheHeaders, ONE_HOUR } from "cdn-cache-control";
5658

57-
// These are equivalent:
58-
const headers = new CacheHeaders().revalidatable(ONE_HOUR);
59-
const headers = new CacheHeaders().revalidatable().ttl(ONE_HOUR);
59+
const headers = new CacheHeaders().swr().ttl(ONE_HOUR);
60+
```
61+
62+
#### Immutable content
63+
64+
If you are serving content that is guaranteed to never change then you can set it as immutable. You should only do this for responses with unique URLs, because there will be no way to invalidate it from the browser cache if it ever changes.
65+
66+
```javascript
67+
import { CacheHeaders } from "cdn-cache-control";
68+
const headers = new CacheHeaders().immutable();
69+
```
70+
71+
This will set the CDN and browser caches to expire in 1 year, and add the immutable directive.
72+
73+
#### Cache tags
74+
75+
Some CDNs support the use of cache tags, which allow you to purge content from the cache in bulk. The `tag()` function makes it simple to add tags. You can call it with a string or array of strings.
76+
77+
```javascript
78+
import { CacheHeaders } from "cdn-cache-control";
79+
const headers = new CacheHeaders().tag(["blog", "blog:1"]);
80+
```
81+
82+
You can then purge the tagged items from the cache using the CDN API. e.g. for Netlify the API is:
83+
84+
```typescript
85+
import { purgeCache } from "@netlify/functions";
86+
87+
export default async function handler(req: Request) => {
88+
await purgeCache({
89+
tags: ["blog", "blog:1", "blog:2"],
90+
});
91+
return new Response("Purged!", { status: 202 })
92+
};
93+
94+
```
95+
96+
#### Using the generated headers
97+
98+
The headers object can be used anywhere that accepts a `fetch` `Headers` object. This includes most serverless hosts. It can also be used directly in many framework SSR functions. Some APIs need a plain object rather than a `Headers` object. For these you can use the `toObject()` method, which returns a plain object with the header names and values.
99+
100+
```typescript
101+
import { CacheHeaders } from "cdn-cache-control";
102+
103+
export default async function handler(request: Request): Promise<Response> {
104+
const headers = new CacheHeaders().swr();
105+
// The `Response` constructor accepts the object directly
106+
return new Response("Hello", { headers });
107+
}
60108
```
61109

62110
## API
@@ -117,7 +165,6 @@ Number of seconds in one year
117165

118166
- [tag](#gear-tag)
119167
- [swr](#gear-swr)
120-
- [revalidatable](#gear-revalidatable)
121168
- [immutable](#gear-immutable)
122169
- [ttl](#gear-ttl)
123170
- [toObject](#gear-toobject)
@@ -142,7 +189,7 @@ Parameters:
142189

143190
#### :gear: swr
144191

145-
Sets stale-while-revalidate directive for the CDN cache. T=By default the browser is sent a must-revalidate
192+
Sets stale-while-revalidate directive for the CDN cache. By default the browser is sent a must-revalidate
146193
directive to ensure that the browser always revalidates the cache with the server.
147194

148195
| Method | Type |
@@ -153,20 +200,6 @@ Parameters:
153200

154201
- `value`: The number of seconds to set the stale-while-revalidate directive to. Defaults to 1 week.
155202

156-
#### :gear: revalidatable
157-
158-
Sets cache headers for content that should be cached for a long time, but can be revalidated.
159-
The CDN cache will cache the content for the specified time, but the browser will always revalidate
160-
the cache with the server to ensure that the content is up to date.
161-
162-
| Method | Type |
163-
| --------------- | ------------------------ |
164-
| `revalidatable` | `(ttl?: number) => this` |
165-
166-
Parameters:
167-
168-
- `ttl`: The number of seconds to set the CDN cache-control s-maxage directive to. Defaults to 1 year.
169-
170203
#### :gear: immutable
171204

172205
Sets cache headers for content that should be cached for a long time and never revalidated.
@@ -243,3 +276,11 @@ The parsed content of the cache tags header.
243276
| `setCacheTags` | `(tags: string[]) => void` |
244277

245278
<!-- TSDOC_END -->
279+
280+
```
281+
282+
```
283+
284+
```
285+
286+
```

index.test.js

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,6 @@ import { describe, it } from "node:test";
44
import { CacheHeaders, ONE_DAY } from "./dist/index.js";
55

66
describe("CacheHeaders", () => {
7-
it("should detect Netlify CDN", () => {
8-
process.env.NETLIFY = "true";
9-
const headers = new CacheHeaders();
10-
assert.strictEqual(
11-
headers.cdnCacheControlHeaderName,
12-
"Netlify-CDN-Cache-Control",
13-
);
14-
delete process.env.NETLIFY;
15-
});
16-
177
it("should append cache tags", () => {
188
const headers = new CacheHeaders({
199
"Cache-Tag": "tag1",
@@ -63,26 +53,26 @@ describe("CacheHeaders", () => {
6353
);
6454
});
6555

66-
it("should set revalidatable headers", () => {
67-
const headers = new CacheHeaders().revalidatable();
56+
it("should merge default headers", () => {
57+
const headers = new CacheHeaders({
58+
"Cache-Control": "s-maxage=3600",
59+
"Content-Type": "application/json",
60+
});
6861
assert.strictEqual(
6962
headers.get("Cache-Control"),
7063
"public,max-age=0,must-revalidate",
64+
"should remove s-maxage and set defaults",
7165
);
7266
assert.strictEqual(
7367
headers.get("CDN-Cache-Control"),
74-
"public,s-maxage=31536000,must-revalidate",
68+
"public,s-maxage=3600,must-revalidate",
69+
"should use s-maxage from Cache-Control if present",
7570
);
76-
});
77-
78-
it("sets tiered header on Netlify", () => {
79-
process.env.NETLIFY = "true";
80-
const headers = new CacheHeaders().swr();
8171
assert.strictEqual(
82-
headers.get("Netlify-CDN-Cache-Control"),
83-
"public,s-maxage=0,tiered,stale-while-revalidate=604800",
72+
headers.get("Content-Type"),
73+
"application/json",
74+
"should preserve other headers",
8475
);
85-
delete process.env.NETLIFY;
8676
});
8777

8878
it("should chain methods", () => {

netlify.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import assert from "node:assert";
2+
import { before, describe, it } from "node:test";
3+
import { CacheHeaders } from "./dist/index.js";
4+
5+
describe("Netlify", () => {
6+
before(() => {
7+
process.env.NETLIFY = "true";
8+
});
9+
it("sets tiered header on Netlify", () => {
10+
const headers = new CacheHeaders().swr();
11+
assert.strictEqual(
12+
headers.get("Netlify-CDN-Cache-Control"),
13+
"public,s-maxage=0,tiered,stale-while-revalidate=604800",
14+
);
15+
});
16+
17+
it("should detect Netlify CDN", () => {
18+
const headers = new CacheHeaders().immutable();
19+
assert(headers.has("Netlify-CDN-Cache-Control"));
20+
});
21+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"lint:package": "publint",
4242
"lint:prettier": "prettier --check src",
4343
"lint": "pnpm run '/^lint:.*/'",
44-
"test": "pnpm build && node --test index.test.js",
44+
"test": "pnpm build && node --test",
4545
"tsdoc": "tsdoc --src=src/index.ts && prettier --write README.md"
4646
},
4747
"keywords": [],

src/index.ts

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ export class CacheHeaders extends Headers {
6060
public constructor(init?: HeadersInit) {
6161
super(init);
6262
this.#cdn = detectCDN();
63+
const cdnDirectives = parseCacheControlHeader(
64+
this.get(this.cdnCacheControlHeaderName),
65+
);
66+
const directives = parseCacheControlHeader(this.get("Cache-Control"));
67+
68+
const sMaxAge =
69+
cdnDirectives["s-maxage"] ??
70+
cdnDirectives["max-age"] ??
71+
directives["s-maxage"] ??
72+
ONE_YEAR.toString();
73+
74+
cdnDirectives.public = "";
75+
cdnDirectives["s-maxage"] = sMaxAge;
76+
delete cdnDirectives["max-age"];
77+
cdnDirectives["must-revalidate"] = "";
78+
if (this.#cdn === "netlify") {
79+
cdnDirectives[tieredDirective] = "";
80+
}
81+
this.setCdnCacheControl(cdnDirectives);
82+
83+
directives.public = "";
84+
delete directives["s-maxage"];
85+
86+
if (!directives["max-age"]) {
87+
directives["max-age"] = "0";
88+
directives["must-revalidate"] = "";
89+
}
90+
this.setCacheControl(directives);
6391
}
6492

6593
/**
@@ -76,50 +104,17 @@ export class CacheHeaders extends Headers {
76104
}
77105

78106
/**
79-
* Sets stale-while-revalidate directive for the CDN cache. T=By default the browser is sent a must-revalidate
107+
* Sets stale-while-revalidate directive for the CDN cache. By default the browser is sent a must-revalidate
80108
* directive to ensure that the browser always revalidates the cache with the server.
81109
* @param value The number of seconds to set the stale-while-revalidate directive to. Defaults to 1 week.
82110
*/
83111

84112
swr(value: number = ONE_WEEK): this {
85-
const currentSMaxAge = this.getCdnCacheControl()["s-maxage"];
86-
this.revalidatable(Number(currentSMaxAge) || 0);
87113
const cdnDirectives = this.getCdnCacheControl();
88114
cdnDirectives["stale-while-revalidate"] = value.toString();
89-
if (this.#cdn === "netlify") {
90-
cdnDirectives[tieredDirective] = "";
91-
}
92115
delete cdnDirectives["must-revalidate"];
93116
this.setCdnCacheControl(cdnDirectives);
94-
return this;
95-
}
96-
97-
/**
98-
* Sets cache headers for content that should be cached for a long time, but can be revalidated.
99-
* The CDN cache will cache the content for the specified time, but the browser will always revalidate
100-
* the cache with the server to ensure that the content is up to date.
101-
* @param ttl The number of seconds to set the CDN cache-control s-maxage directive to. Defaults to 1 year.
102-
*/
103-
104-
revalidatable(ttl: number = ONE_YEAR): this {
105-
const cdnDirectives = parseCacheControlHeader(
106-
this.get(this.cdnCacheControlHeaderName),
107-
);
108-
cdnDirectives.public = "";
109-
cdnDirectives["s-maxage"] = ttl.toString();
110-
cdnDirectives["must-revalidate"] = "";
111-
if (this.#cdn === "netlify") {
112-
cdnDirectives[tieredDirective] = "";
113-
}
114-
this.setCdnCacheControl(cdnDirectives);
115-
116-
const directives = parseCacheControlHeader(this.get("Cache-Control"));
117-
directives.public = "";
118-
if (!directives["max-age"]) {
119-
directives["max-age"] = "0";
120-
directives["must-revalidate"] = "";
121-
}
122-
this.setCacheControl(directives);
117+
this.ttl(0);
123118
return this;
124119
}
125120

@@ -135,15 +130,15 @@ export class CacheHeaders extends Headers {
135130
const cdnDirectives = this.getCdnCacheControl();
136131
cdnDirectives.public = "";
137132
cdnDirectives["s-maxage"] = value.toString();
138-
if (this.#cdn === "netlify") {
139-
cdnDirectives[tieredDirective] = "";
140-
}
141133
cdnDirectives.immutable = "";
134+
delete cdnDirectives["must-revalidate"];
142135
this.setCdnCacheControl(cdnDirectives);
143136

144137
const directives = this.getCacheControl();
145138
directives.public = "";
146139
directives["max-age"] = value.toString();
140+
delete directives["must-revalidate"];
141+
147142
directives.immutable = "";
148143
this.setCacheControl(directives);
149144
return this;
@@ -158,10 +153,15 @@ export class CacheHeaders extends Headers {
158153
ttl(value: number): this {
159154
const cdnDirectives = this.getCdnCacheControl();
160155
cdnDirectives["s-maxage"] = value.toString();
161-
if (this.#cdn === "netlify") {
162-
cdnDirectives[tieredDirective] = "";
163-
}
164156
this.setCdnCacheControl(cdnDirectives);
157+
158+
if (cdnDirectives.immutable) {
159+
const directives = this.getCacheControl();
160+
directives.immutable = "";
161+
directives["max-age"] = value.toString();
162+
this.setCacheControl(directives);
163+
}
164+
165165
return this;
166166
}
167167

0 commit comments

Comments
 (0)