feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support

This commit is contained in:
2025-12-05 12:27:41 +00:00
parent cef6ce750e
commit 57d7fd6483
17 changed files with 1116 additions and 18 deletions

View File

@@ -10,6 +10,16 @@ import type {
} from '../core/smartserve.interfaces.js';
import { getMimeType } from '../utils/utils.mime.js';
import { generateETag } from '../utils/utils.etag.js';
import { parseAcceptEncoding } from '../utils/utils.encoding.js';
/**
* Pre-compressed file variant info
*/
interface IPrecompressedVariant {
path: string;
stat: plugins.fs.Stats;
encoding: string;
}
/**
* Static file server
@@ -112,22 +122,43 @@ export class FileServer {
): Promise<Response> {
const headers = new Headers();
// Content-Type
// Check for pre-compressed variants
let actualFilePath = filePath;
let actualStat = stat;
let contentEncoding: string | undefined;
if (this.options.precompressed) {
const variant = await this.findPrecompressedVariant(filePath, request);
if (variant) {
actualFilePath = variant.path;
actualStat = variant.stat;
contentEncoding = variant.encoding;
}
}
// Content-Type (always use original file's MIME type)
const mimeType = getMimeType(filePath);
headers.set('Content-Type', mimeType);
// Content-Length
headers.set('Content-Length', stat.size.toString());
// Content-Encoding (if serving pre-compressed)
if (contentEncoding) {
headers.set('Content-Encoding', contentEncoding);
headers.set('Vary', 'Accept-Encoding');
}
// Last-Modified
// Content-Length (use actual file size, which may differ for compressed)
headers.set('Content-Length', actualStat.size.toString());
// Last-Modified (use original file's time for consistency)
if (this.options.lastModified) {
headers.set('Last-Modified', stat.mtime.toUTCString());
}
// ETag
// ETag (include encoding in ETag if compressed)
let etag: string | undefined;
if (this.options.etag) {
etag = generateETag(stat);
const baseEtag = generateETag(stat);
etag = contentEncoding ? `${baseEtag}-${contentEncoding}` : baseEtag;
headers.set('ETag', etag);
}
@@ -153,9 +184,9 @@ export class FileServer {
}
}
// Handle Range requests
// Handle Range requests (don't use pre-compressed for range requests)
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
if (rangeHeader && !contentEncoding) {
return this.servePartial(filePath, stat, rangeHeader, headers);
}
@@ -164,8 +195,8 @@ export class FileServer {
return new Response(null, { status: 200, headers });
}
// Stream the file
const stream = plugins.fs.createReadStream(filePath);
// Stream the file (use actualFilePath for pre-compressed)
const stream = plugins.fs.createReadStream(actualFilePath);
const readableStream = this.nodeStreamToWebStream(stream);
return new Response(readableStream, { status: 200, headers });
@@ -361,6 +392,46 @@ export class FileServer {
</html>`;
}
/**
* Find pre-compressed variant of a file if it exists
* Checks for .br and .gz variants based on client Accept-Encoding
*/
private async findPrecompressedVariant(
filePath: string,
request: Request
): Promise<IPrecompressedVariant | null> {
const acceptEncoding = request.headers.get('Accept-Encoding');
const preferences = parseAcceptEncoding(acceptEncoding);
// Supported pre-compressed variants in preference order
const variants: Array<{ encoding: string; extension: string }> = [
{ encoding: 'br', extension: '.br' },
{ encoding: 'gzip', extension: '.gz' },
];
// Check variants in client preference order
for (const pref of preferences) {
const variant = variants.find((v) => v.encoding === pref.encoding);
if (!variant) continue;
const variantPath = filePath + variant.extension;
try {
const variantStat = await plugins.fs.promises.stat(variantPath);
if (variantStat.isFile()) {
return {
path: variantPath,
stat: variantStat,
encoding: variant.encoding,
};
}
} catch {
// Variant doesn't exist, continue to next
}
}
return null;
}
/**
* Convert Node.js stream to Web ReadableStream
*/