diff --git a/lib/static.js b/lib/static.js index 2904a0143..bc24825d3 100644 --- a/lib/static.js +++ b/lib/static.js @@ -78,12 +78,31 @@ const status = (code) => { return file; }; +const COMPRESSORS = { + gzip: node.zlib.createGzip, + deflate: node.zlib.createDeflate, + br: node.zlib.createBrotliCompress, + zstd: node.zlib.createZstdCompress, +}; + class Static extends Place { + files = new Map(); + compressor = null; + compressType = 'none'; + maxFileSize = -1; + constructor(name, application, options = {}) { super(name, application); - this.files = new Map(); - this.ext = options.ext; - this.maxFileSize = -1; + const { compressType, ext } = options; + this.ext = ext; + if (compressType) { + const compressor = COMPRESSORS[compressType]; + if (!compressor) { + throw new Error(`Unsupported compression type ${compressType}`); + } + this.compressType = compressType; + this.compressor = compressor(); + } } get(key) { @@ -101,6 +120,12 @@ class Static extends Place { this.files.delete(key); } + compress(filePath) { + const fileStream = node.fs.createReadStream(filePath); + fileStream.pipe(this.compressor); + return node.streamConsumers.buffer(this.compressor); + } + async change(filePath) { if (this.maxFileSize === -1) { const maxFileSize = this.application.config?.cache?.maxFileSize; @@ -115,7 +140,13 @@ class Static extends Place { if (stat.size > this.maxFileSize) { this.files.set(key, { data: null, stat }); } else { - const data = await fsp.readFile(filePath); + let data = null; + if (this.compressor) { + data = await this.compress(filePath); + stat.size = data.length; + } else { + data = await node.fsp.readFile(filePath); + } this.files.set(key, { data, stat }); } } catch { @@ -155,9 +186,11 @@ class Static extends Place { const fileExt = metautil.fileExt(filePath); let file = this.find(filePath); if (file.data && file.stat) { - if (file.code === -1) this.write(req, res, file.data, 200, 'html'); - else this.write(req, res, file.data, file.code, fileExt); - return; + if (file.code === -1) { + return void this.write(req, res, file.data, 200, 'html'); + } + const opt = { contentEncoding: this.compressType }; + return void this.write(req, res, file.data, file.code, fileExt, opt); } const absPath = path.posix.join(this.path, url); if (absPath.startsWith(this.path)) { @@ -165,7 +198,7 @@ class Static extends Place { if (!stat) stat = await fsp.stat(absPath).catch(() => null); if (stat && stat.isFile()) { const { size } = stat; - const options = { size }; + const options = { size, contentEncoding: this.compressType }; let code = 200; if (req.headers.range) { const range = metautil.parseRange(req.headers.range); diff --git a/test/static.js b/test/static.js index 7d3373f8c..0d9458bfa 100644 --- a/test/static.js +++ b/test/static.js @@ -2,6 +2,7 @@ const { test } = require('node:test'); const assert = require('node:assert'); +const zlib = require('node:zlib'); const path = require('node:path'); const { Static } = require('../lib/static.js'); @@ -32,3 +33,91 @@ test('lib/static load - should load static files correctly', async () => { assert.strictEqual(cache.ext, undefined); assert.strictEqual(cache.maxFileSize, 10000000); }); + +test('lib/static load - should compress correctly by gzip', async () => { + const cache = new Static('lib', application, { compressType: 'gzip' }); + assert.strictEqual(cache.files instanceof Map, true); + assert.strictEqual(cache.files.size, 0); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, -1); + assert.strictEqual(cache.get('/example/add.js'), undefined); + + await cache.load(); + assert.strictEqual(cache.files.size, 13); + const file = cache.get('/example/add.js'); + assert.strictEqual(file.data instanceof Buffer, true); + assert.strictEqual(file.data.length, 116); + assert.strictEqual(cache.get('/example/unknown.js'), undefined); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, 10000000); +}); + +test('lib/static load - should compress correctly by deflate', async () => { + const cache = new Static('lib', application, { + compressType: 'deflate', + }); + assert.strictEqual(cache.files instanceof Map, true); + assert.strictEqual(cache.files.size, 0); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, -1); + assert.strictEqual(cache.get('/example/add.js'), undefined); + + await cache.load(); + assert.strictEqual(cache.files.size, 13); + const file = cache.get('/example/add.js'); + assert.strictEqual(file.data instanceof Buffer, true); + assert.strictEqual(file.data.length, 104); + assert.strictEqual(cache.get('/example/unknown.js'), undefined); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, 10000000); +}); + +test('lib/static load - should compress correctly by brotli', async () => { + const cache = new Static('lib', application, { + compressType: 'br', + }); + assert.strictEqual(cache.files instanceof Map, true); + assert.strictEqual(cache.files.size, 0); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, -1); + assert.strictEqual(cache.get('/example/add.js'), undefined); + + await cache.load(); + assert.strictEqual(cache.files.size, 13); + const file = cache.get('/example/add.js'); + assert.strictEqual(file.data instanceof Buffer, true); + assert.strictEqual(file.data.length, 100); + assert.strictEqual(cache.get('/example/unknown.js'), undefined); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, 10000000); +}); + +if (zlib.zstdCompress) { + test('lib/static load - should compress correctly by zstd', async () => { + const cache = new Static('lib', application, { + compressType: 'zstd', + }); + assert.strictEqual(cache.files instanceof Map, true); + assert.strictEqual(cache.files.size, 0); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, -1); + assert.strictEqual(cache.get('/example/add.js'), undefined); + + await cache.load(); + assert.strictEqual(cache.files.size, 13); + const file = cache.get('/example/add.js'); + assert.strictEqual(file.data instanceof Buffer, true); + assert.strictEqual(file.data.length, 109); + assert.strictEqual(cache.get('/example/unknown.js'), undefined); + assert.strictEqual(cache.ext, undefined); + assert.strictEqual(cache.maxFileSize, 10000000); + }); +} + +test('lib/static - should throw error on unsupported compression', async () => { + assert.throws(() => { + new Static('lib', application, { + compressType: 'unsupported', + }); + }, new Error('Unsupported compression type unsupported')); +}); diff --git a/types/core.d.ts b/types/core.d.ts index 9761310e9..793cdcfd1 100644 --- a/types/core.d.ts +++ b/types/core.d.ts @@ -24,7 +24,8 @@ export interface Static { name: string; path: string; files: Map; - get(name: string): { data: Buffer | null; stat: object | null } | undefined; + get(name: string): unknown; + compress(filePath: string): Promise; find( path: string, code?: number, diff --git a/types/impress.d.ts b/types/impress.d.ts index 89cd9ccd0..06fbe75cd 100644 --- a/types/impress.d.ts +++ b/types/impress.d.ts @@ -20,6 +20,7 @@ import * as _qs from 'node:querystring'; import * as _querystring from 'node:querystring'; import * as _assert from 'node:assert'; import * as _stream from 'node:stream'; +import * as _streamConsumers from 'node:stream/consumers'; import * as _fs from 'node:fs'; import * as _crypto from 'node:crypto'; import * as _zlib from 'node:zlib'; @@ -84,6 +85,8 @@ declare global { const querystring: typeof _qs; const assert: typeof _assert; const stream: typeof _stream; + const stream_consumers: typeof _streamConsumers; + const streamConsumers: typeof _streamConsumers; const fs: typeof _fs; const fsp: typeof _fs.promises; const crypto: typeof _crypto;