From e500c85b1094945e2bd5ea3382c4cfc2a9c8e296 Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Wed, 3 Jun 2026 22:57:44 -0700 Subject: [PATCH 1/2] fix: set Vary: Accept-Encoding for preCompressed responses When preCompressed is enabled the served file is chosen from the request Accept-Encoding header, but the response did not advertise that it varies on that header. A shared cache could then return a compressed variant to a client that does not accept it, or the uncompressed fallback to a client that does. Add Vary: Accept-Encoding to every preCompressed response, including the uncompressed fallback, and append to any Vary header already set through setHeaders without creating duplicates. Closes #230 --- README.md | 2 + index.js | 37 ++++++++ test/static.test.js | 210 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+) diff --git a/README.md b/README.md index 770cf68..bf0a5ee 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,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`. + Assume this structure with the compressed asset as a sibling of the uncompressed counterpart: ``` diff --git a/index.js b/index.js index 7adaa99..17fa30f 100644 --- a/index.js +++ b/index.js @@ -400,6 +400,14 @@ async function fastifyStatic (fastify, opts) { reply.code(newStatusCode) setHeaders?.(reply.raw, metadata.path, metadata.stat) reply.headers(headers) + 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) @@ -572,6 +580,35 @@ function getEncodingHeader (headers, checked) { ) } +/** + * Appends `accept-encoding` to the `Vary` response header without creating + * duplicates. Reads the current value from the raw response so any header + * already set by a `setHeaders` callback is preserved. + * @param {import('fastify').FastifyReply} reply + */ +function addVaryAcceptEncoding (reply) { + const current = reply.raw.getHeader('vary') + + if (current === undefined) { + reply.header('vary', 'accept-encoding') + return + } + + const value = Array.isArray(current) ? current.join(', ') : String(current) + + const exists = value + .split(',') + .some((token) => { + const normalized = token.trim().toLowerCase() + // A `Vary: *` already covers every request header. + return normalized === 'accept-encoding' || normalized === '*' + }) + + if (!exists) { + reply.header('vary', value + ', accept-encoding') + } +} + /** * @param {string} routePrefix * @returns {Array} diff --git a/test/static.test.js b/test/static.test.js index de2d413..c1d5b46 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -2766,6 +2766,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: (res) => { + res.setHeader('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: (res) => { + res.setHeader('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: (res) => { + res.setHeader('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: (res) => { + res.setHeader('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) => { From 53126817dfd062ff56e941dc33b73ab3466210ad Mon Sep 17 00:00:00 2001 From: Shaurya Singh Date: Fri, 5 Jun 2026 12:45:06 -0700 Subject: [PATCH 2/2] Use reply API and a plain loop for Vary header per review Switch from reply.raw.getHeader to reply.getHeader to stay on the fastify reply API. Replace the split/some pattern with a plain for loop that walks comma-separated tokens without allocating an array. Behavior is identical: accept-encoding is appended only when not already present, and a wildcard Vary skips the append too. --- index.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 17fa30f..b581925 100644 --- a/index.js +++ b/index.js @@ -582,12 +582,11 @@ function getEncodingHeader (headers, checked) { /** * Appends `accept-encoding` to the `Vary` response header without creating - * duplicates. Reads the current value from the raw response so any header - * already set by a `setHeaders` callback is preserved. + * duplicates. Any header already set by a `setHeaders` callback is preserved. * @param {import('fastify').FastifyReply} reply */ function addVaryAcceptEncoding (reply) { - const current = reply.raw.getHeader('vary') + const current = reply.getHeader('vary') if (current === undefined) { reply.header('vary', 'accept-encoding') @@ -596,17 +595,20 @@ function addVaryAcceptEncoding (reply) { const value = Array.isArray(current) ? current.join(', ') : String(current) - const exists = value - .split(',') - .some((token) => { - const normalized = token.trim().toLowerCase() + // Walk the comma-separated tokens without splitting the string. + let start = 0 + for (let i = 0; i <= value.length; i++) { + if (i === value.length || value[i] === ',') { + const token = value.slice(start, i).trim().toLowerCase() // A `Vary: *` already covers every request header. - return normalized === 'accept-encoding' || normalized === '*' - }) - - if (!exists) { - reply.header('vary', value + ', accept-encoding') + if (token === 'accept-encoding' || token === '*') { + return + } + start = i + 1 + } } + + reply.header('vary', value + ', accept-encoding') } /**