Skip to content

Commit 8e8ba41

Browse files
committed
fix(cloudflare): enable browser caching for images
1 parent ca63ba8 commit 8e8ba41

7 files changed

Lines changed: 298 additions & 53 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,6 @@ package-lock.json
6868
.perf/
6969
tsconfig.tsbuildinfo
7070
webpack-internal:/
71+
72+
# Local Cloudflare Worker route configuration
73+
/cloudflare/notion-image-proxy/wrangler.toml

cloudflare/notion-image-proxy/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Cloudflare Worker proxy for NotionNext images.
44

5+
It keeps Notion attachment URLs in the browser and at Cloudflare's edge for one
6+
year. Fresh browser cache entries need no network request; explicit
7+
revalidation is answered with `304 Not Modified` when the validator matches.
8+
59
## Deploy
610

711
1. Copy `wrangler.toml.example` to `wrangler.toml`.
@@ -33,4 +37,23 @@ Expected headers after repeat requests:
3337
X-Notion-Image-Proxy: 1
3438
X-Notion-Image-Proxy-Cache: HIT
3539
CF-Cache-Status: HIT
40+
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
41+
ETag: W/"..."
42+
```
43+
44+
Verify conditional requests with the `Last-Modified` value returned above:
45+
46+
```bash
47+
curl -I -H "If-Modified-Since: <Last-Modified value>" "https://cdn.example.com/image/..."
3648
```
49+
50+
The expected status is `304 Not Modified` with no image body.
51+
52+
## Test
53+
54+
```bash
55+
node --test worker.test.mjs
56+
```
57+
58+
The test also checks that the copy-paste code in the VitePress tutorial stays
59+
identical to `worker.mjs`.

cloudflare/notion-image-proxy/worker.mjs

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
const NOTION_ORIGIN = 'https://www.notion.so'
2-
const EDGE_TTL_SECONDS = 60 * 60 * 24 * 7
3-
const BROWSER_TTL_SECONDS = 60 * 60 * 24
2+
const IMMUTABLE_TTL_SECONDS = 60 * 60 * 24 * 365
43
const USER_AGENT =
54
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'
65

@@ -21,7 +20,12 @@ export default {
2120
const cached = await cache.match(cacheKey)
2221
if (cached) {
2322
const hitHeaders = new Headers(cached.headers)
23+
setCacheHeaders(hitHeaders)
24+
setValidatorHeaders(hitHeaders)
2425
hitHeaders.set('X-Notion-Image-Proxy-Cache', 'HIT')
26+
if (isNotModified(request, hitHeaders)) {
27+
return notModifiedResponse(hitHeaders)
28+
}
2529
return new Response(request.method === 'HEAD' ? null : cached.body, {
2630
status: cached.status,
2731
statusText: cached.statusText,
@@ -35,20 +39,19 @@ export default {
3539
redirect: 'follow',
3640
cf: {
3741
cacheEverything: true,
38-
cacheTtl: EDGE_TTL_SECONDS,
42+
cacheTtl: IMMUTABLE_TTL_SECONDS,
3943
cacheKey: request.url
4044
},
4145
headers: {
4246
'User-Agent': USER_AGENT,
43-
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
47+
Accept:
48+
'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
4449
}
4550
})
4651

4752
const headers = new Headers(response.headers)
48-
headers.set(
49-
'Cache-Control',
50-
`public, max-age=${BROWSER_TTL_SECONDS}, s-maxage=${EDGE_TTL_SECONDS}`
51-
)
53+
setCacheHeaders(headers)
54+
setValidatorHeaders(headers)
5255
headers.set('X-Notion-Image-Proxy', '1')
5356
headers.set('X-Notion-Image-Proxy-Cache', 'MISS')
5457
headers.delete('set-cookie')
@@ -65,12 +68,64 @@ export default {
6568
await cache.put(cacheKey, proxied.clone())
6669
}
6770

68-
return request.method === 'HEAD'
69-
? new Response(null, proxied)
70-
: proxied
71+
if (isNotModified(request, headers)) {
72+
return notModifiedResponse(headers)
73+
}
74+
75+
return request.method === 'HEAD' ? new Response(null, proxied) : proxied
7176
}
7277
}
7378

7479
function isAllowedPath(pathname) {
7580
return pathname.startsWith('/image/') || pathname.startsWith('/images/')
7681
}
82+
83+
function setCacheHeaders(headers) {
84+
headers.set(
85+
'Cache-Control',
86+
`public, max-age=${IMMUTABLE_TTL_SECONDS}, s-maxage=${IMMUTABLE_TTL_SECONDS}, immutable`
87+
)
88+
}
89+
90+
function setValidatorHeaders(headers) {
91+
if (headers.has('etag')) return
92+
93+
const lastModified = Date.parse(headers.get('last-modified') || '')
94+
const contentLength = headers.get('content-length') || 'unknown'
95+
if (!Number.isNaN(lastModified)) {
96+
headers.set('ETag', `W/"${lastModified.toString(16)}-${contentLength}"`)
97+
}
98+
}
99+
100+
function isNotModified(request, headers) {
101+
const etag = headers.get('etag')
102+
const ifNoneMatch = request.headers.get('if-none-match')
103+
if (etag && ifNoneMatch) {
104+
return ifNoneMatch
105+
.split(',')
106+
.map(value => value.trim())
107+
.some(value => value === '*' || weakEtag(value) === weakEtag(etag))
108+
}
109+
110+
const lastModified = Date.parse(headers.get('last-modified') || '')
111+
const ifModifiedSince = Date.parse(
112+
request.headers.get('if-modified-since') || ''
113+
)
114+
return (
115+
!Number.isNaN(lastModified) &&
116+
!Number.isNaN(ifModifiedSince) &&
117+
lastModified <= ifModifiedSince
118+
)
119+
}
120+
121+
function weakEtag(value) {
122+
return value.replace(/^W\//, '')
123+
}
124+
125+
function notModifiedResponse(headers) {
126+
const notModifiedHeaders = new Headers(headers)
127+
notModifiedHeaders.delete('content-length')
128+
notModifiedHeaders.delete('content-encoding')
129+
notModifiedHeaders.delete('content-range')
130+
return new Response(null, { status: 304, headers: notModifiedHeaders })
131+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import assert from 'node:assert/strict'
2+
import { readFile } from 'node:fs/promises'
3+
import test from 'node:test'
4+
5+
import worker from './worker.mjs'
6+
7+
test('serves immutable images and returns an empty 304 on revalidation', async () => {
8+
const originalFetch = globalThis.fetch
9+
const originalCaches = globalThis.caches
10+
let stored
11+
12+
globalThis.fetch = async () =>
13+
new Response('image-bytes', {
14+
status: 200,
15+
headers: {
16+
'Content-Type': 'image/webp',
17+
'Content-Length': '11',
18+
'Last-Modified': 'Thu, 06 Aug 2026 11:27:52 GMT'
19+
}
20+
})
21+
globalThis.caches = {
22+
default: {
23+
match: async () => stored?.clone(),
24+
put: async (_key, response) => {
25+
stored = response.clone()
26+
}
27+
}
28+
}
29+
30+
try {
31+
const url = 'https://cdn.example.com/image/example.png?id=page-id'
32+
const first = await worker.fetch(new Request(url))
33+
const etag = first.headers.get('etag')
34+
35+
assert.equal(first.status, 200)
36+
assert.equal(
37+
first.headers.get('cache-control'),
38+
'public, max-age=31536000, s-maxage=31536000, immutable'
39+
)
40+
assert.match(etag, /^W\/"[a-f0-9]+-11"$/)
41+
42+
const byDate = await worker.fetch(
43+
new Request(url, {
44+
headers: {
45+
'If-Modified-Since': 'Thu, 06 Aug 2026 11:27:52 GMT'
46+
}
47+
})
48+
)
49+
const byEtag = await worker.fetch(
50+
new Request(url, { headers: { 'If-None-Match': etag } })
51+
)
52+
53+
assert.equal(byDate.status, 304)
54+
assert.equal(byEtag.status, 304)
55+
assert.equal((await byDate.arrayBuffer()).byteLength, 0)
56+
assert.equal((await byEtag.arrayBuffer()).byteLength, 0)
57+
} finally {
58+
globalThis.fetch = originalFetch
59+
globalThis.caches = originalCaches
60+
}
61+
})
62+
63+
test('keeps the VitePress copy-paste example in sync with worker.mjs', async () => {
64+
const workerSource = await readFile(
65+
new URL('./worker.mjs', import.meta.url),
66+
'utf8'
67+
)
68+
const tutorial = await readFile(
69+
new URL(
70+
'../../docs/user-guide/deploy/notion-image-proxy.md',
71+
import.meta.url
72+
),
73+
'utf8'
74+
)
75+
const example = tutorial.match(
76+
/```js\r?\n(const NOTION_ORIGIN = [\s\S]*?)\r?\n```/
77+
)
78+
79+
assert.ok(example, 'VitePress tutorial must include the Worker source')
80+
assert.equal(normalize(example[1]), normalize(workerSource))
81+
})
82+
83+
function normalize(value) {
84+
return value.replace(/\r\n/g, '\n').trim()
85+
}

cloudflare/notion-image-proxy/wrangler.toml

Lines changed: 0 additions & 7 deletions
This file was deleted.

docs/user-guide/deploy/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
| [vercel-redeploy.md](./vercel-redeploy.md) | 重新部署 |
2020
| [netlify.md](./netlify.md) | Netlify(4.0.9+) |
2121
| [cloudflare-pages.md](./cloudflare-pages.md) | Cloudflare 静态 |
22+
| [notion-image-proxy.md](./notion-image-proxy.md) | Cloudflare Worker 图片反代、浏览器长期缓存与 304 验证 |
2223
| [edgeone-pages.md](./edgeone-pages.md) | **腾讯云 EdgeOne**(Node 版本、ENOSPC、Next SSG 预设) |
2324
| [build-tuning.md](./build-tuning.md) | **构建超时 / Notion 预热与限流**(环境变量) |
2425
| [vps.md](./vps.md) | VPS / Docker(Node 22+) |

0 commit comments

Comments
 (0)