From 15a9eba562255638fd7e8d90127a675c92e8c56c Mon Sep 17 00:00:00 2001 From: agent_coder Date: Fri, 10 Jul 2026 07:30:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(delivery):=20immutable-=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=20=D0=B0=D1=81=D1=81=D0=B5=D1=82=D0=BE=D0=B2=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D0=BD=D1=8B=D0=B9=20cacheControl?= =?UTF-8?q?=20@fastify/static=20(#452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хэшированные ассеты отдавали 'cache-control: public, max-age=0' вместо 'public, max-age=31536000, immutable' → повторные заходы ревалидировали каждый ассет (десятки 304 × мобильный RTT), главный выигрыш #346 не реализовывался. Причина: у @fastify/static опция cacheControl:true по умолчанию пишет свой Cache-Control (из maxAge, дефолт 0) ПОСЛЕ setHeaders-колбэка, затирая immutable- заголовок из resolveStaticAssetHeaders. Фикс — cacheControl:false, колбэк владеет заголовком. preCompressed не конфликтовал, потому баг был только в заголовках. Крайние случаи проверены: locales/vad/иконки получают только vary (без cache-control → браузер ревалидирует по etag — ок); index.html отдаётся отдельным wildcard-роутом со своим no-cache (не затронут); preCompressed .br получает путь с /assets/ → маппинг матчит, immutable ставится. Тест: bare-fastify + inject() — /assets/.js содержит immutable+ max-age=31536000, /locales/en.json — нет. Мутационно: cacheControl:true роняет ассерт immutable. jest static.module → 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integrations/static/static.module.spec.ts | 71 +++++++++++++++++++ .../src/integrations/static/static.module.ts | 5 ++ 2 files changed, 76 insertions(+) diff --git a/apps/server/src/integrations/static/static.module.spec.ts b/apps/server/src/integrations/static/static.module.spec.ts index ff2ce2fb..643c6ece 100644 --- a/apps/server/src/integrations/static/static.module.spec.ts +++ b/apps/server/src/integrations/static/static.module.spec.ts @@ -1,3 +1,8 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { join } from 'node:path'; +import Fastify, { FastifyInstance } from 'fastify'; +import fastifyStatic from '@fastify/static'; import { resolveStaticAssetHeaders } from './static.module'; // Unit tests for the static-asset cache classifier extracted from the @@ -33,3 +38,69 @@ describe('resolveStaticAssetHeaders', () => { expect(headers['vary']).toBe('Accept-Encoding'); }); }); + +// Integration test proving the ACTUAL response header emitted by @fastify/static +// with the exact registration options StaticModule uses. This is the regression +// guard for #452: without `cacheControl: false`, @fastify/static writes its own +// `Cache-Control: public, max-age=0` AFTER the setHeaders callback, overwriting +// the immutable header — the /assets/ assertion below would then fail. +describe('static.module @fastify/static registration (integration)', () => { + let app: FastifyInstance; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'static-module-spec-')); + fs.mkdirSync(join(tmpDir, 'assets'), { recursive: true }); + fs.mkdirSync(join(tmpDir, 'locales'), { recursive: true }); + fs.writeFileSync( + join(tmpDir, 'assets', 'index-a1b2c3.js'), + 'console.log(1);', + ); + fs.writeFileSync(join(tmpDir, 'locales', 'en.json'), '{"hello":"world"}'); + + app = Fastify(); + // Mirror StaticModule.onModuleInit's registration options exactly. + await app.register(fastifyStatic, { + root: tmpDir, + wildcard: false, + preCompressed: true, + cacheControl: false, + setHeaders: (res, filePath) => { + for (const [name, value] of Object.entries( + resolveStaticAssetHeaders(filePath), + )) { + res.setHeader(name, value); + } + }, + }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('serves a hashed /assets/ file with an immutable, 1-year cache-control', async () => { + const res = await app.inject({ + method: 'GET', + url: '/assets/index-a1b2c3.js', + }); + expect(res.statusCode).toBe(200); + const cacheControl = res.headers['cache-control']; + expect(cacheControl).toContain('immutable'); + expect(cacheControl).toContain('max-age=31536000'); + }); + + it('serves a non-hashed /locales/ file WITHOUT an immutable cache-control', async () => { + const res = await app.inject({ method: 'GET', url: '/locales/en.json' }); + expect(res.statusCode).toBe(200); + // resolveStaticAssetHeaders sets no cache-control here and cacheControl:false + // stops @fastify/static from adding one, so the browser revalidates by + // etag/last-modified — either an absent header or one without `immutable`. + const cacheControl = res.headers['cache-control']; + if (cacheControl !== undefined) { + expect(cacheControl).not.toContain('immutable'); + } + }); +}); diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 54a7c092..ea2d398f 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -115,6 +115,11 @@ export class StaticModule implements OnModuleInit { // Serve the build-time .br/.gz neighbour when the client accepts it // (see vite-plugin-compression2 in apps/client/vite.config.ts). preCompressed: true, + // @fastify/static's default cacheControl:true writes its own + // Cache-Control (from maxAge, default 0) AFTER the setHeaders callback, + // silently overwriting the immutable header that resolveStaticAssetHeaders + // sets — disable it so setHeaders/resolveStaticAssetHeaders own the header. + cacheControl: false, setHeaders: (res, filePath) => { for (const [name, value] of Object.entries( resolveStaticAssetHeaders(filePath),