diff --git a/package-lock.json b/package-lock.json index 540eff0..ecca435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "7zip-min": "^3.0.1", "adm-zip": "^0.6.0", "archiver": "^8.0.0", + "compression": "^1.8.1", "docx": "^9.7.1", "dotenv": "^17.4.2", "exceljs": "^4.4.0", @@ -2831,6 +2832,80 @@ "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5396,6 +5471,15 @@ "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": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 32275bb..1955458 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "7zip-min": "^3.0.1", "adm-zip": "^0.6.0", "archiver": "^8.0.0", + "compression": "^1.8.1", "docx": "^9.7.1", "dotenv": "^17.4.2", "exceljs": "^4.4.0", diff --git a/src/app.js b/src/app.js index 5e660c2..3d8dbc1 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,7 @@ import path from 'node:path'; import fs from 'node:fs'; import express from 'express'; +import compression from 'compression'; import multer from 'multer'; import { rateLimit } from 'express-rate-limit'; import { v4 as uuidv4 } from 'uuid'; @@ -65,6 +66,7 @@ export function createApp(config, prisma) { const app = express(); app.set('trust proxy', '1'); + app.use(compression()); const storage = multer.diskStorage({ 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); }); - const frontendDist = path.join(import.meta.dirname, '..', 'frontend', 'dist'); - app.use(express.static(frontendDist)); + const frontendDist = config.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) => { res.sendFile(path.join(frontendDist, 'index.html')); }); diff --git a/src/config.js b/src/config.js index 88a7c53..83ac4a2 100644 --- a/src/config.js +++ b/src/config.js @@ -1,4 +1,5 @@ import 'dotenv/config'; +import path from 'node:path'; const REQUIRED_VARS = ['STORAGE_DIR', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; @@ -11,6 +12,7 @@ export function loadConfig() { return { port: Number(process.env.PORT ?? 3000), storageDir: process.env.STORAGE_DIR, + frontendDist: path.join(import.meta.dirname, '..', 'frontend', 'dist'), db: { host: process.env.DB_HOST, user: process.env.DB_USER, diff --git a/test/staticServing.test.js b/test/staticServing.test.js new file mode 100644 index 0000000..d3be775 --- /dev/null +++ b/test/staticServing.test.js @@ -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'), + `fr home ${'x'.repeat(2000)}` + ); + 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'); + }); +});