feat(compression): Add cross-runtime response compression (Brotli/gzip), per-route decorators, and pre-compressed static file support
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user