From 6ee814b7f363535405639b525fe3d4cf1f30612e Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 4 Jul 2026 21:29:54 +0300 Subject: [PATCH 1/2] perf(delivery): pre-compress static + cache headers + compress API responses (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold load served ALL static + API responses uncompressed and without cache headers (~3.7MB over the wire). Delivery only — feature behavior unchanged; no DB/API-contract/MCP changes. - apps/client/vite.config.ts: vite-plugin-compression2 emits .br + .gz next to each built asset (excludes index.html, which the server rewrites at boot with window.CONFIG — a precompressed copy would go stale). Build emits 187 .br / 175 .gz under dist/assets. - static.module.ts: @fastify/static `preCompressed: true` serves the .br/.gz neighbour; `setHeaders` sets `immutable` ONLY for content-hashed /assets/*, `no-cache` for index.html, and leaves non-hashed files (locales, vad, icons, manifest) on default etag/last-modified revalidation. - main.ts: @fastify/compress (threshold 1024) compresses dynamic API JSON + the rewritten share-SEO HTML. SSE is safe on two counts: `text/event-stream` is not mime-db-compressible (allowlist skips it) AND the AI-chat stream hijacks the raw socket (pipeUIMessageStreamToResponse -> res.raw), bypassing the Fastify onSend lifecycle entirely. No double-compression with preCompressed static (compress skips already-Content-Encoding'd responses). - docker-compose.yml: comment recommending an optional HTTP/2 + brotli reverse proxy (not required). Deps: apps/client vite-plugin-compression2 2.5.3 (dev), apps/server @fastify/compress 9.0.0 (matches fastify 5.8.5). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/package.json | 1 + apps/client/vite.config.ts | 13 +- apps/server/package.json | 1 + .../src/integrations/static/static.module.ts | 26 ++++ apps/server/src/main.ts | 12 ++ docker-compose.yml | 5 + pnpm-lock.yaml | 128 +++++++++++++++++- 7 files changed, 183 insertions(+), 3 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index b922efe9..bff24473 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -98,6 +98,7 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.1", "vite": "8.0.5", + "vite-plugin-compression2": "2.5.3", "vitest": "4.1.6" } } diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 8b9994ce..a28329f9 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; +import { compression } from "vite-plugin-compression2"; import * as path from "path"; import { execSync } from "node:child_process"; @@ -53,7 +54,17 @@ export default defineConfig(({ mode }) => { }, APP_VERSION: JSON.stringify(resolveAppVersion(envPath)), }, - plugins: [react()], + plugins: [ + react(), + // Emit .br and .gz next to every built asset so the server can serve the + // precompressed copy (see @fastify/static preCompressed in static.module.ts). + compression({ + algorithms: ["brotliCompress", "gzip"], + // index.html is rewritten at server boot (window.CONFIG injection); a + // precompressed copy would go stale — NEVER precompress it. + exclude: [/index\.html$/], + }), + ], build: { rolldownOptions: { output: { diff --git a/apps/server/package.json b/apps/server/package.json index f12c1b2d..a6791d80 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -44,6 +44,7 @@ "@docmost/mcp": "workspace:*", "@docmost/pdf-inspector": "1.9.6", "@docmost/prosemirror-markdown": "workspace:*", + "@fastify/compress": "^9.0.0", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 220d5944..985864ca 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -72,6 +72,32 @@ export class StaticModule implements OnModuleInit { await app.register(fastifyStatic, { root: clientDistPath, wildcard: false, + // Serve the build-time .br/.gz neighbour when the client accepts it + // (see vite-plugin-compression2 in apps/client/vite.config.ts). + preCompressed: true, + setHeaders: (res, filePath) => { + // Content-hashed files under /assets/ never change for a given URL, + // so they can be cached forever and skip revalidation entirely. + if (filePath.includes('/assets/')) { + res.setHeader( + 'cache-control', + 'public, max-age=31536000, immutable', + ); + return; + } + // index.html is rewritten at boot (window.CONFIG injection) and on + // every deploy — it must be revalidated on every load. + if (filePath.endsWith('index.html')) { + res.setHeader( + 'cache-control', + 'no-cache, no-store, must-revalidate', + ); + return; + } + // Everything else (locales, vad, icons, manifest) is NOT content-hashed + // and changes between deploys, so it keeps @fastify/static's default + // etag/last-modified revalidation — do NOT mark it immutable. + }, }); app.get(RENDER_PATH, (req: any, res: any) => { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 04df81d8..9870dab9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; +import fastifyCompress from '@fastify/compress'; import fastifyIp from 'fastify-ip'; import { InternalLogFilter } from './common/logger/internal-log-filter'; import { EnvironmentService } from './integrations/environment/environment.service'; @@ -63,6 +64,17 @@ async function bootstrap() { await app.register(fastifyIp); await app.register(fastifyMultipart); await app.register(fastifyCookie); + // Compress dynamic responses (API JSON, the rewritten share-SEO HTML) when the + // client accepts br/gzip. @fastify/compress only compresses content-types that + // mime-db flags `compressible` (application/json, text/html, …); `text/event-stream` + // is not in mime-db, so SSE is never compressed by the allowlist. The AI-chat + // stream additionally hijacks the raw socket (pipeUIMessageStreamToResponse -> + // res.raw in ai-chat.service.ts), bypassing Fastify's reply/onSend lifecycle + // entirely, so this hook can never buffer that stream. + await app.register(fastifyCompress, { + // Skip tiny payloads where compression overhead outweighs the savings. + threshold: 1024, + }); const environmentService = app.get(EnvironmentService); const frameHeader = resolveFrameHeader( diff --git a/docker-compose.yml b/docker-compose.yml index 0c24c6b0..ded83719 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,11 @@ services: ports: - "3000:3000" restart: unless-stopped + # The app already serves precompressed (brotli/gzip) static assets with + # long-lived cache headers and gzips dynamic API responses. For the best + # cold-load latency you can OPTIONALLY put a reverse proxy (caddy / nginx / + # traefik) in front with HTTP/2 (or HTTP/3) and brotli enabled — none is + # required for compression to work. volumes: - docmost:/app/data/storage diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1a7795e..d69db970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -507,6 +507,9 @@ importers: vite: specifier: 8.0.5 version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-compression2: + specifier: 2.5.3 + version: 2.5.3 vitest: specifier: 4.1.6 version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -549,6 +552,9 @@ importers: '@docmost/prosemirror-markdown': specifier: workspace:* version: link:../../packages/prosemirror-markdown + '@fastify/compress': + specifier: ^9.0.0 + version: 9.0.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -2706,6 +2712,9 @@ packages: '@fastify/busboy@3.1.1': resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==} + '@fastify/compress@9.0.0': + resolution: {integrity: sha512-PZRg+ut5xd/ubsGPWfoPNryoCOtEdHboIWpDieTUHov1gKdLitF8mRmT3JbqNnRbelQXSNXUsIpakAEKR6AcTQ==} + '@fastify/cookie@11.0.2': resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} @@ -4499,6 +4508,15 @@ packages: '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -5688,6 +5706,10 @@ packages: resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -6070,6 +6092,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bullmq@5.76.10: resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==} engines: {node: '>=12.22.0'} @@ -6825,6 +6850,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -7068,6 +7096,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -7079,6 +7110,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} @@ -9088,6 +9123,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -9333,6 +9371,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prom-client@15.1.3: resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} engines: {node: ^16 || ^18 || >=20} @@ -9628,6 +9670,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -10016,6 +10062,9 @@ packages: stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + strict-event-emitter-types@2.0.0: resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==} @@ -10156,6 +10205,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-mini@0.2.0: + resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -10199,6 +10251,9 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -10604,6 +10659,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-plugin-compression2@2.5.3: + resolution: {integrity: sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==} + vite@8.0.5: resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -12943,6 +13001,15 @@ snapshots: '@fastify/busboy@3.1.1': {} + '@fastify/compress@9.0.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + fastify-plugin: 5.1.0 + mime-db: 1.54.0 + minipass: 7.1.3 + peek-stream: 1.1.3 + readable-stream: 4.7.0 + '@fastify/cookie@11.0.2': dependencies: cookie: 1.1.1 @@ -14958,6 +15025,12 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -16331,6 +16404,10 @@ snapshots: abbrev@5.0.0: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} accepts@1.3.8: @@ -16779,6 +16856,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bullmq@5.76.10: dependencies: cron-parser: 4.9.0 @@ -17532,6 +17614,13 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.4 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -17958,6 +18047,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -17966,6 +18057,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter2@6.4.9: {} eventemitter3@4.0.7: {} @@ -20229,6 +20322,12 @@ snapshots: peberminta@0.9.0: {} + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + pend@1.2.0: {} perfect-freehand@1.2.0: {} @@ -20500,6 +20599,8 @@ snapshots: process-warning@5.0.0: {} + process@0.11.10: {} + prom-client@15.1.3: dependencies: '@opentelemetry/api': 1.9.0 @@ -20921,6 +21022,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -21374,6 +21483,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + stream-shift@1.0.3: {} + strict-event-emitter-types@2.0.0: {} string-length@4.0.2: @@ -21534,6 +21645,8 @@ snapshots: tapable@2.3.0: {} + tar-mini@0.2.0: {} + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -21583,6 +21696,11 @@ snapshots: throttleit@2.1.0: {} + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -21987,6 +22105,13 @@ snapshots: vary@1.1.2: {} + vite-plugin-compression2@2.5.3: + dependencies: + '@rollup/pluginutils': 5.4.0 + tar-mini: 0.2.0 + transitivePeerDependencies: + - rollup + vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -22367,8 +22492,7 @@ snapshots: xpath@0.0.34: {} - xtend@4.0.2: - optional: true + xtend@4.0.2: {} y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)): dependencies: -- 2.52.0 From babc42c2ff2adc59eec5536667849a877e0d7774 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 4 Jul 2026 23:33:59 +0300 Subject: [PATCH 2/2] fix(#346 review F1-F4): no 206-compress + Vary + precompress VAD + cache test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1 [HIGH — data corruption]: @fastify/compress was compressing 206/Range attachment responses while Content-Range still described the RAW offsets, so a resuming client (curl -C -, download managers) appended encoded bytes as raw → corrupted file. sendFileResponse now sets the request header `x-no-compression` (the documented @fastify/compress opt-out — its onSend skips when the request carries it; the reviewer's `Content-Encoding: identity` does NOT work because compress explicitly excludes `identity` and overwrites it). This opts the whole download route (both 200 full-file and 206 range) out of on-the-fly compression — correct, since attachment bytes are final and mostly binary. - F2: static responses now emit `Vary: Accept-Encoding` (the preCompressed content-negotiated /assets/* were `immutable` without Vary → shared-cache could serve a brotli variant to an identity/gzip-only client). - F3: vite compression `include` extended to .wasm/.onnx so the VAD binaries (~26MB .wasm, ~2.3MB .onnx under public/vad) are precompressed at build (.br emitted) instead of runtime-brotli'd on every request. (include REPLACES the plugin default, so the default js/css/json/html set is re-listed.) - F4: extracted the cache classification into a pure `resolveStaticAssetHeaders` + static.module.spec.ts (3 tests: /assets/* immutable+Vary, index.html no-store, non-hashed not-immutable). Gate: server tsc 0 (deps present), static.module.spec 3/3, client build emits .wasm.br/.onnx.br, frozen install 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/vite.config.ts | 8 +++ .../core/attachment/attachment.controller.ts | 13 ++++ .../integrations/static/static.module.spec.ts | 35 ++++++++++ .../src/integrations/static/static.module.ts | 64 +++++++++++++------ 4 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/integrations/static/static.module.spec.ts diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index a28329f9..a24d0b83 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -60,6 +60,14 @@ export default defineConfig(({ mode }) => { // precompressed copy (see @fastify/static preCompressed in static.module.ts). compression({ algorithms: ["brotliCompress", "gzip"], + // vite-plugin-compression2's default `include` only covers text-ish + // bundle output (js/mjs/json/css/html/svg/…). Extend it with the large + // VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so + // they are brotli/gzip'd once at build time and served via + // @fastify/static preCompressed — otherwise @fastify/compress would + // re-brotli them on EVERY request. The default types are repeated here + // because setting `include` replaces (does not extend) the default. + include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/, // index.html is rewritten at server boot (window.CONFIG injection); a // precompressed copy would go stale — NEVER precompress it. exclude: [/index\.html$/], diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 73605819..55b68bc4 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -474,6 +474,19 @@ export class AttachmentController { const fileSize = Number(attachment.fileSize); const rangeHeader = req.headers.range; + // Opt this download route out of the global @fastify/compress hook. + // Attachment bytes are final and mostly binary, so on-the-fly compression + // only burns CPU — and on the 206/Range branch it is actively corrupting: + // compress decides purely by Content-Type, so for a compressible mime + // (application/octet-stream fallback, image/svg+xml, text/*) it would gzip + // the byte slice and drop Content-Length while Content-Range still + // describes the RAW offsets and the status stays 206. A resuming client + // (`curl -C -`, download managers) then appends the encoded bytes as if + // raw and ends up with a broken file. @fastify/compress skips whenever the + // request carries `x-no-compression` (see its onSend hook), so setting it + // here covers both the 200 (full file) and 206 (range) responses. + req.headers['x-no-compression'] = 'true'; + res.header('Accept-Ranges', 'bytes'); res.header( 'Content-Security-Policy', diff --git a/apps/server/src/integrations/static/static.module.spec.ts b/apps/server/src/integrations/static/static.module.spec.ts new file mode 100644 index 00000000..ff2ce2fb --- /dev/null +++ b/apps/server/src/integrations/static/static.module.spec.ts @@ -0,0 +1,35 @@ +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'); + }); +}); diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 985864ca..54a7c092 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -5,6 +5,46 @@ import * as fs from 'node:fs'; import fastifyStatic from '@fastify/static'; import { EnvironmentService } from '../environment/environment.service'; +/** + * Resolve the response headers for a statically served client asset. + * + * Extracted from the @fastify/static `setHeaders` callback so the cache + * classification stays a pure, unit-testable function (see + * static.module.spec.ts). + * + * `Vary: Accept-Encoding` is emitted for every static response because + * @fastify/static negotiates a precompressed .br/.gz neighbour by the client's + * Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache + * keyed on the URL alone could store the brotli variant and later serve it to a + * client that only sent `Accept-Encoding: identity`/gzip → an undecodable body. + * This matters most for the immutable /assets/ files, which proxies may keep + * for a year. + */ +export function resolveStaticAssetHeaders( + filePath: string, +): Record { + const headers: Record = { vary: 'Accept-Encoding' }; + + // Content-hashed files under /assets/ never change for a given URL, so they + // can be cached forever and skip revalidation entirely. + if (filePath.includes('/assets/')) { + headers['cache-control'] = 'public, max-age=31536000, immutable'; + return headers; + } + + // index.html is rewritten at boot (window.CONFIG injection) and on every + // deploy — it must be revalidated on every load. + if (filePath.endsWith('index.html')) { + headers['cache-control'] = 'no-cache, no-store, must-revalidate'; + return headers; + } + + // Everything else (locales, vad, icons, manifest) is NOT content-hashed and + // changes between deploys, so it keeps @fastify/static's default + // etag/last-modified revalidation — do NOT mark it immutable. + return headers; +} + @Module({}) export class StaticModule implements OnModuleInit { constructor( @@ -76,27 +116,11 @@ export class StaticModule implements OnModuleInit { // (see vite-plugin-compression2 in apps/client/vite.config.ts). preCompressed: true, setHeaders: (res, filePath) => { - // Content-hashed files under /assets/ never change for a given URL, - // so they can be cached forever and skip revalidation entirely. - if (filePath.includes('/assets/')) { - res.setHeader( - 'cache-control', - 'public, max-age=31536000, immutable', - ); - return; + for (const [name, value] of Object.entries( + resolveStaticAssetHeaders(filePath), + )) { + res.setHeader(name, value); } - // index.html is rewritten at boot (window.CONFIG injection) and on - // every deploy — it must be revalidated on every load. - if (filePath.endsWith('index.html')) { - res.setHeader( - 'cache-control', - 'no-cache, no-store, must-revalidate', - ); - return; - } - // Everything else (locales, vad, icons, manifest) is NOT content-hashed - // and changes between deploys, so it keeps @fastify/static's default - // etag/last-modified revalidation — do NOT mark it immutable. }, }); -- 2.52.0