babc42c2ff
- 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) <noreply@anthropic.com>
137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
import { Module, OnModuleInit } from '@nestjs/common';
|
|
import { HttpAdapterHost } from '@nestjs/core';
|
|
import { join } from 'path';
|
|
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<string, string> {
|
|
const headers: Record<string, string> = { 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(
|
|
private readonly httpAdapterHost: HttpAdapterHost,
|
|
private readonly environmentService: EnvironmentService,
|
|
) {}
|
|
|
|
public async onModuleInit() {
|
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
|
const app = httpAdapter.getInstance();
|
|
|
|
const clientDistPath = join(
|
|
__dirname,
|
|
'..',
|
|
'..',
|
|
'..',
|
|
'..',
|
|
'client/dist',
|
|
);
|
|
|
|
const indexFilePath = join(clientDistPath, 'index.html');
|
|
|
|
if (fs.existsSync(clientDistPath) && fs.existsSync(indexFilePath)) {
|
|
const indexTemplateFilePath = join(clientDistPath, 'index-template.html');
|
|
const windowVar = '<!--window-config-->';
|
|
|
|
const configString = {
|
|
ENV: this.environmentService.getNodeEnv(),
|
|
APP_URL: this.environmentService.getAppUrl(),
|
|
CLOUD: this.environmentService.isCloud(),
|
|
COMPACT_PAGE_TREE: this.environmentService.isCompactPageTreeEnabled(),
|
|
FILE_UPLOAD_SIZE_LIMIT:
|
|
this.environmentService.getFileUploadSizeLimit(),
|
|
FILE_IMPORT_SIZE_LIMIT:
|
|
this.environmentService.getFileImportSizeLimit(),
|
|
DRAWIO_URL: this.environmentService.getDrawioUrl(),
|
|
SUBDOMAIN_HOST: this.environmentService.isCloud()
|
|
? this.environmentService.getSubdomainHost()
|
|
: undefined,
|
|
COLLAB_URL: this.environmentService.getCollabUrl(),
|
|
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
|
|
? this.environmentService.getBillingTrialDays()
|
|
: undefined,
|
|
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
|
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
|
// #355 — mirrors the server-side CLIENT_TELEMETRY_ENABLED gate so the
|
|
// client only collects/sends vitals when the operator opts in.
|
|
CLIENT_TELEMETRY_ENABLED:
|
|
this.environmentService.isClientTelemetryEnabled(),
|
|
};
|
|
|
|
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
|
|
|
if (!fs.existsSync(indexTemplateFilePath)) {
|
|
fs.copyFileSync(indexFilePath, indexTemplateFilePath);
|
|
}
|
|
|
|
const html = fs.readFileSync(indexTemplateFilePath, 'utf8');
|
|
const transformedHtml = html.replace(windowVar, windowScriptContent);
|
|
|
|
fs.writeFileSync(indexFilePath, transformedHtml);
|
|
|
|
const RENDER_PATH = '*';
|
|
|
|
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) => {
|
|
for (const [name, value] of Object.entries(
|
|
resolveStaticAssetHeaders(filePath),
|
|
)) {
|
|
res.setHeader(name, value);
|
|
}
|
|
},
|
|
});
|
|
|
|
app.get(RENDER_PATH, (req: any, res: any) => {
|
|
const stream = fs.createReadStream(indexFilePath);
|
|
res
|
|
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
.type('text/html')
|
|
.send(stream);
|
|
});
|
|
}
|
|
}
|
|
}
|