15a9eba562
Хэшированные ассеты отдавали '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/<hashed>.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) <noreply@anthropic.com>
107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
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
|
|
// @fastify/static setHeaders callback (precedent: sandbox.controller.spec.ts).
|
|
describe('resolveStaticAssetHeaders', () => {
|
|
it('marks a content-hashed /assets/ file immutable and sets Vary', () => {
|
|
const headers = resolveStaticAssetHeaders(
|
|
'/app/apps/client/dist/assets/index-a1b2c3.js',
|
|
);
|
|
expect(headers['cache-control']).toBe(
|
|
'public, max-age=31536000, immutable',
|
|
);
|
|
expect(headers['vary']).toBe('Accept-Encoding');
|
|
});
|
|
|
|
it('makes index.html always revalidate (never immutable)', () => {
|
|
const headers = resolveStaticAssetHeaders(
|
|
'/app/apps/client/dist/index.html',
|
|
);
|
|
expect(headers['cache-control']).toBe(
|
|
'no-cache, no-store, must-revalidate',
|
|
);
|
|
expect(headers['vary']).toBe('Accept-Encoding');
|
|
});
|
|
|
|
it('does NOT mark a non-hashed asset immutable but still sets Vary', () => {
|
|
const headers = resolveStaticAssetHeaders(
|
|
'/app/apps/client/dist/locales/en.json',
|
|
);
|
|
// No immutable cache-control — this path keeps @fastify/static's default
|
|
// etag/last-modified revalidation.
|
|
expect(headers['cache-control']).toBeUndefined();
|
|
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');
|
|
}
|
|
});
|
|
});
|