Skip to content

Commit 830ea71

Browse files
authored
feat: stale-while-revalidate (#416)
1 parent 610b4f3 commit 830ea71

File tree

4 files changed

+87
-5
lines changed

4 files changed

+87
-5
lines changed

.env.sample

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ SERVER_HEADERS_TIMEOUT=65
99
SERVER_REGION=region-of-where-your-service-is-running
1010

1111

12+
#######################################
13+
# Request / Response
14+
#######################################
15+
REQUEST_URL_LENGTH_LIMIT=7500
16+
REQUEST_TRACE_HEADER=trace-id
17+
REQUEST_ETAG_HEADERS=if-none-match
18+
RESPONSE_S_MAXAGE=0
19+
20+
1221
#######################################
1322
# Auth
1423
#######################################

src/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type StorageConfigType = {
3737
databaseConnectionTimeout: number
3838
region: string
3939
requestTraceHeader?: string
40+
requestEtagHeaders: string[]
41+
responseSMaxAge: number
4042
serviceKey: string
4143
storageBackendType: StorageBackendType
4244
tenantId: string
@@ -153,6 +155,10 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
153155
requestUrlLengthLimit:
154156
Number(getOptionalConfigFromEnv('REQUEST_URL_LENGTH_LIMIT', 'URL_LENGTH_LIMIT')) || 7_500,
155157
requestTraceHeader: getOptionalConfigFromEnv('REQUEST_TRACE_HEADER', 'REQUEST_ID_HEADER'),
158+
requestEtagHeaders: getOptionalConfigFromEnv('REQUEST_ETAG_HEADERS')?.trim().split(',') || [
159+
'if-none-match',
160+
],
161+
responseSMaxAge: parseInt(getOptionalConfigFromEnv('RESPONSE_S_MAXAGE') || '0', 10),
156162

157163
// Admin
158164
adminApiKeys: getOptionalConfigFromEnv('SERVER_ADMIN_API_KEYS', 'ADMIN_API_KEYS') || '',

src/storage/renderer/head.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AssetResponse, Renderer, RenderOptions } from './renderer'
2-
import { FastifyRequest } from 'fastify'
3-
import { StorageBackendAdapter } from '../backend'
2+
import { FastifyReply, FastifyRequest } from 'fastify'
3+
import { ObjectMetadata, StorageBackendAdapter } from '../backend'
44
import { ImageRenderer, TransformOptions } from './image'
55

66
/**
@@ -20,4 +20,27 @@ export class HeadRenderer extends Renderer {
2020
transformations: ImageRenderer.applyTransformation(request.query as TransformOptions),
2121
}
2222
}
23+
24+
protected handleCacheControl(
25+
request: FastifyRequest<any>,
26+
response: FastifyReply<any>,
27+
metadata: ObjectMetadata
28+
) {
29+
const etag = this.findEtagHeader(request)
30+
31+
const cacheControl = [metadata.cacheControl]
32+
33+
if (!etag) {
34+
response.header('Cache-Control', cacheControl.join(', '))
35+
return
36+
}
37+
38+
if (etag !== metadata.eTag) {
39+
cacheControl.push('must-revalidate')
40+
} else if (this.sMaxAge > 0) {
41+
cacheControl.push(`s-maxage=${this.sMaxAge}`)
42+
}
43+
44+
response.header('Cache-Control', cacheControl.join(', '))
45+
}
2346
}

src/storage/renderer/renderer.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FastifyReply, FastifyRequest } from 'fastify'
22
import { ObjectMetadata } from '../backend'
33
import { Readable } from 'stream'
4+
import { getConfig } from '../../config'
45

56
export interface RenderOptions {
67
bucket: string
@@ -16,12 +17,16 @@ export interface AssetResponse {
1617
transformations?: string[]
1718
}
1819

20+
const { requestEtagHeaders, responseSMaxAge } = getConfig()
21+
1922
/**
2023
* Renderer
2124
* a generic renderer that respond to a request with an asset content
2225
* and all the important headers
2326
*/
2427
export abstract class Renderer {
28+
protected sMaxAge = responseSMaxAge
29+
2530
abstract getAsset(request: FastifyRequest, options: RenderOptions): Promise<AssetResponse>
2631

2732
/**
@@ -34,7 +39,7 @@ export abstract class Renderer {
3439
try {
3540
const data = await this.getAsset(request, options)
3641

37-
await this.setHeaders(response, data, options)
42+
this.setHeaders(request, response, data, options)
3843

3944
return response.send(data.body)
4045
} catch (err: any) {
@@ -55,7 +60,12 @@ export abstract class Renderer {
5560
}
5661
}
5762

58-
protected setHeaders(response: FastifyReply<any>, data: AssetResponse, options: RenderOptions) {
63+
protected setHeaders(
64+
request: FastifyRequest<any>,
65+
response: FastifyReply<any>,
66+
data: AssetResponse,
67+
options: RenderOptions
68+
) {
5969
response
6070
.status(data.metadata.httpStatusCode ?? 200)
6171
.header('Accept-Ranges', 'bytes')
@@ -67,7 +77,7 @@ export abstract class Renderer {
6777
if (options.expires) {
6878
response.header('Expires', options.expires)
6979
} else {
70-
response.header('Cache-Control', data.metadata.cacheControl)
80+
this.handleCacheControl(request, response, data.metadata)
7181
}
7282

7383
if (data.metadata.contentRange) {
@@ -95,6 +105,40 @@ export abstract class Renderer {
95105
}
96106
}
97107
}
108+
109+
protected handleCacheControl(
110+
request: FastifyRequest<any>,
111+
response: FastifyReply<any>,
112+
metadata: ObjectMetadata
113+
) {
114+
const etag = this.findEtagHeader(request)
115+
116+
const cacheControl = [metadata.cacheControl]
117+
118+
if (!etag) {
119+
response.header('Cache-Control', cacheControl.join(', '))
120+
return
121+
}
122+
123+
if (this.sMaxAge > 0) {
124+
cacheControl.push(`s-maxage=${this.sMaxAge}`)
125+
}
126+
127+
if (etag !== metadata.eTag) {
128+
cacheControl.push('stale-while-revalidate=30')
129+
}
130+
131+
response.header('Cache-Control', cacheControl.join(', '))
132+
}
133+
134+
protected findEtagHeader(request: FastifyRequest<any>) {
135+
for (const header of requestEtagHeaders) {
136+
const etag = request.headers[header]
137+
if (etag) {
138+
return etag
139+
}
140+
}
141+
}
98142
}
99143

100144
function normalizeContentType(contentType: string | undefined): string | undefined {

0 commit comments

Comments
 (0)