diff --git a/README.md b/README.md index e8fb321..cb433a1 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,8 @@ Default: `false` First, try to send the brotli encoded asset (if supported by `Accept-Encoding` headers), then gzip, and finally the original `pathname`. Skip compression for smaller files that do not benefit from it. +When `preCompressed` is enabled the response includes `Vary: Accept-Encoding`, because the served variant is selected from the request `Accept-Encoding` header. This applies even when the uncompressed file is sent as a fallback, so that shared caches do not return the wrong variant to clients with a different `Accept-Encoding`. + > ⚠ Warning: `allowedPath` is evaluated against the requested path before pre-compressed variants are selected. A request for `/file.txt` can serve `/file.txt.gz` or `/file.txt.br` when the client accepts that encoding, even if direct requests to `.gz` or `.br` files are denied by `allowedPath`. Treat pre-compressed files as public alternate encodings of the same asset: keep restricted compressed files outside the served root, disable `preCompressed`, or deny the base path too. Assume this structure with the compressed asset as a sibling of the uncompressed counterpart: diff --git a/index.js b/index.js index a471e25..8cb0d2b 100644 --- a/index.js +++ b/index.js @@ -406,6 +406,14 @@ async function fastifyStatic (fastify, opts) { reply.code(newStatusCode) reply.headers(headers) setHeaders?.(reply, metadata.path, metadata.stat) + if (opts.preCompressed) { + // The response was selected based on the request `Accept-Encoding` + // header, so it must advertise that it varies on it. This holds even + // when the uncompressed file is served as a fallback, otherwise a + // shared cache could return that fallback to a client that does + // accept a compressed encoding (or the other way around). + addVaryAcceptEncoding(reply) + } if (encoding) { reply.header('content-type', getContentType(pathname)) reply.header('content-encoding', encoding) @@ -578,6 +586,38 @@ function getEncodingHeader (headers, checked) { ) } +/** + * Appends `accept-encoding` to the `Vary` response header without creating + * duplicates. Any header already set by a `setHeaders` callback is preserved. + * @param {import('fastify').FastifyReply} reply + */ +function addVaryAcceptEncoding (reply) { + const current = reply.getHeader('vary') + + if (current === undefined) { + reply.header('vary', 'accept-encoding') + return + } + + const value = Array.isArray(current) ? current.join(', ') : String(current) + + // Walk the comma-separated tokens without splitting the string. + const headerLength = value.length + let start = 0 + for (let i = 0; i <= headerLength; i++) { + if (i === headerLength || value[i] === ',') { + const token = value.slice(start, i).trim().toLowerCase() + // A `Vary: *` already covers every request header. + if (token === 'accept-encoding' || token === '*') { + return + } + start = i + 1 + } + } + + reply.header('vary', value + ', accept-encoding') +} + /** * @param {string} routePrefix * @returns {Array} diff --git a/test/static.test.js b/test/static.test.js index 1b97b3f..29152d5 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -2771,6 +2771,216 @@ test( } ) +test( + 'sends Vary: Accept-Encoding when serving a pre-compressed file', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/', + preCompressed: true + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/all-three.html', + headers: { + 'accept-encoding': 'gzip, deflate, br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers['content-encoding'], 'br') + t.assert.deepStrictEqual(response.headers.vary, 'accept-encoding') + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + +test( + 'sends Vary: Accept-Encoding when falling back to the uncompressed file', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/', + preCompressed: true + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/uncompressed.html', + headers: { + 'accept-encoding': 'gzip, deflate, br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers['content-encoding'], undefined) + t.assert.deepStrictEqual(response.headers.vary, 'accept-encoding') + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + +test( + 'appends to an existing Vary header set via setHeaders for pre-compressed files', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/', + preCompressed: true, + setHeaders: (reply) => { + reply.header('vary', 'Accept-Language') + } + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/all-three.html', + headers: { + 'accept-encoding': 'br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers['content-encoding'], 'br') + t.assert.deepStrictEqual(response.headers.vary, 'Accept-Language, accept-encoding') + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + +test( + 'does not duplicate Accept-Encoding in an existing Vary header', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/', + preCompressed: true, + setHeaders: (reply) => { + reply.header('vary', 'Accept-Encoding') + } + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/all-three.html', + headers: { + 'accept-encoding': 'br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers.vary, 'Accept-Encoding') + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + +test( + 'appends to an existing array-valued Vary header for pre-compressed files', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/', + preCompressed: true, + setHeaders: (reply) => { + reply.header('vary', ['Accept-Language', 'Cookie']) + } + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/all-three.html', + headers: { + 'accept-encoding': 'br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers.vary, 'Accept-Language, Cookie, accept-encoding') + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + +test( + 'does not append Accept-Encoding to an existing Vary: * header', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/', + preCompressed: true, + setHeaders: (reply) => { + reply.header('vary', '*') + } + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/all-three.html', + headers: { + 'accept-encoding': 'br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers.vary, '*') + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + +test( + 'does not send Vary: Accept-Encoding when preCompressed is disabled', + async (t) => { + const pluginOptions = { + root: path.join(__dirname, '/static-pre-compressed'), + prefix: '/static-pre-compressed/' + } + + const fastify = Fastify() + + fastify.register(fastifyStatic, pluginOptions) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/static-pre-compressed/uncompressed.html', + headers: { + 'accept-encoding': 'gzip, deflate, br' + } + }) + + genericResponseChecks(t, response) + t.assert.deepStrictEqual(response.headers.vary, undefined) + t.assert.deepStrictEqual(response.statusCode, 200) + } +) + test( 'will serve pre-compressed files and fallback to .gz if .br is not on disk', async (t) => {