feat(seo): add compression, cache-control headers, configurable frontend dist path

This commit is contained in:
2026-08-02 10:20:22 +02:00
parent 26433fa9c7
commit 5ff8a865e8
5 changed files with 160 additions and 2 deletions
+84
View File
@@ -13,6 +13,7 @@
"7zip-min": "^3.0.1", "7zip-min": "^3.0.1",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"compression": "^1.8.1",
"docx": "^9.7.1", "docx": "^9.7.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
@@ -2831,6 +2832,80 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -5396,6 +5471,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+1
View File
@@ -20,6 +20,7 @@
"7zip-min": "^3.0.1", "7zip-min": "^3.0.1",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"compression": "^1.8.1",
"docx": "^9.7.1", "docx": "^9.7.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
+14 -2
View File
@@ -1,6 +1,7 @@
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import express from 'express'; import express from 'express';
import compression from 'compression';
import multer from 'multer'; import multer from 'multer';
import { rateLimit } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -65,6 +66,7 @@ export function createApp(config, prisma) {
const app = express(); const app = express();
app.set('trust proxy', '1'); app.set('trust proxy', '1');
app.use(compression());
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(config.storageDir, 'uploads')), destination: (req, file, cb) => cb(null, path.join(config.storageDir, 'uploads')),
@@ -248,8 +250,18 @@ export function createApp(config, prisma) {
fs.createReadStream(filePath).pipe(res); fs.createReadStream(filePath).pipe(res);
}); });
const frontendDist = path.join(import.meta.dirname, '..', 'frontend', 'dist'); const frontendDist = config.frontendDist;
app.use(express.static(frontendDist)); app.use(
express.static(frontendDist, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
} else if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
})
);
app.get(/^\/(?!api\/).*/, (req, res) => { app.get(/^\/(?!api\/).*/, (req, res) => {
res.sendFile(path.join(frontendDist, 'index.html')); res.sendFile(path.join(frontendDist, 'index.html'));
}); });
+2
View File
@@ -1,4 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import path from 'node:path';
const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
@@ -11,6 +12,7 @@ export function loadConfig() {
return { return {
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
storageDir: process.env.STORAGE_DIR, storageDir: process.env.STORAGE_DIR,
frontendDist: path.join(import.meta.dirname, '..', 'frontend', 'dist'),
db: { db: {
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { createApp } from '../src/app.js';
import { getPrismaClient, closePrismaClient } from '../src/db.js';
import { loadConfig } from '../src/config.js';
import { ensureStorageDirs } from '../src/storage.js';
let app;
let prisma;
let config;
let frontendDist;
beforeAll(async () => {
frontendDist = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-dist-'));
await fs.mkdir(path.join(frontendDist, 'fr'), { recursive: true });
await fs.mkdir(path.join(frontendDist, 'assets'), { recursive: true });
await fs.writeFile(
path.join(frontendDist, 'fr', 'index.html'),
`<html><body>fr home ${'x'.repeat(2000)}</body></html>`
);
await fs.writeFile(path.join(frontendDist, 'assets', 'app.abc123.js'), 'console.log(1)');
config = {
...loadConfig(),
storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-api-')),
frontendDist,
};
await ensureStorageDirs(config);
prisma = getPrismaClient(config);
app = createApp(config, prisma);
});
afterAll(async () => {
await closePrismaClient();
await fs.rm(config.storageDir, { recursive: true, force: true });
await fs.rm(frontendDist, { recursive: true, force: true });
});
describe('static asset serving', () => {
it('sends HTML pages with Cache-Control: no-cache', async () => {
const response = await request(app).get('/fr/');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-cache');
});
it('sends hashed assets with a long immutable Cache-Control', async () => {
const response = await request(app).get('/assets/app.abc123.js');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('compresses responses when the client accepts it', async () => {
const response = await request(app).get('/fr/').set('Accept-Encoding', 'gzip');
expect(response.headers['content-encoding']).toBe('gzip');
});
});