Added more performant minifiers for css and js.

This commit is contained in:
SamTV12345 2024-03-21 10:04:08 +01:00
parent 3a1ef560ec
commit eec1dd18d2
8 changed files with 87 additions and 112 deletions

View file

@ -6,12 +6,6 @@
"shutdown": "ep_etherpad-lite/node/db/DB" "shutdown": "ep_etherpad-lite/node/db/DB"
} }
}, },
{
"name": "Minify",
"hooks": {
"shutdown": "ep_etherpad-lite/node/utils/Minify"
}
},
{ {
"name": "express", "name": "express",
"hooks": { "hooks": {

View file

@ -28,6 +28,8 @@ let sessionStore: { shutdown: () => void; } | null;
const sockets:Set<Socket> = new Set(); const sockets:Set<Socket> = new Set();
const socketsEvents = new events.EventEmitter(); const socketsEvents = new events.EventEmitter();
const startTime = stats.settableGauge('httpStartTime'); const startTime = stats.settableGauge('httpStartTime');
import https from 'https';
import http from 'http';
exports.server = null; exports.server = null;
@ -119,11 +121,8 @@ exports.restartServer = async () => {
options.ca.push(fs.readFileSync(caFileName)); options.ca.push(fs.readFileSync(caFileName));
} }
} }
const https = require('https');
exports.server = https.createServer(options, app); exports.server = https.createServer(options, app);
} else { } else {
const http = require('http');
exports.server = http.createServer(app); exports.server = http.createServer(app);
} }

View file

@ -4,8 +4,8 @@ import {MapArrayType} from "../../types/MapType";
import {PartType} from "../../types/PartType"; import {PartType} from "../../types/PartType";
const fs = require('fs').promises; const fs = require('fs').promises;
const minify = require('../../utils/Minify'); import {requestURIs, minify} from '../../utils/Minify';
const path = require('path'); import path from 'path';
const plugins = require('../../../static/js/pluginfw/plugin_defs'); const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
import CachingMiddleware from '../../utils/caching_middleware'; import CachingMiddleware from '../../utils/caching_middleware';
@ -41,7 +41,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
// Minify will serve static files compressed (minify enabled). It also has // Minify will serve static files compressed (minify enabled). It also has
// file-specific hacks for ace/require-kernel/etc. // file-specific hacks for ace/require-kernel/etc.
app.all('/static/:filename(*)', minify.minify); app.all('/static/:filename(*)', minify);
// Setup middleware that will package JavaScript files served by minify for // Setup middleware that will package JavaScript files served by minify for
// CommonJS loader on the client-side. // CommonJS loader on the client-side.
@ -51,7 +51,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
rootURI: 'http://invalid.invalid/static/js/', rootURI: 'http://invalid.invalid/static/js/',
libraryPath: 'javascripts/lib/', libraryPath: 'javascripts/lib/',
libraryURI: 'http://invalid.invalid/static/plugins/', libraryURI: 'http://invalid.invalid/static/plugins/',
requestURIs: minify.requestURIs, // Loop-back is causing problems, this is a workaround. requestURIs: requestURIs, // Loop-back is causing problems, this is a workaround.
}); });
const StaticAssociator = Yajsml.associators.StaticAssociator; const StaticAssociator = Yajsml.associators.StaticAssociator;

View file

@ -31,11 +31,13 @@ import axios from "axios";
const settings = require('./utils/Settings'); const settings = require('./utils/Settings');
import wtfnodeMod from 'wtfnode';
let wtfnode: any; let wtfnode: any;
if (settings.dumpOnUncleanExit) { if (settings.dumpOnUncleanExit) {
// wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and // wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and
// it should be above everything else so that it can hook in before resources are used. // it should be above everything else so that it can hook in before resources are used.
wtfnode = require('wtfnode'); wtfnode = wtfnodeMod;
} }

View file

@ -21,21 +21,22 @@
* limitations under the License. * limitations under the License.
*/ */
import {compressCSS, compressJS} from "./MinifyWorker";
import {MapArrayType} from "../types/MapType";
const settings = require('./Settings'); const settings = require('./Settings');
const fs = require('fs').promises; import {promises as fs} from 'fs'
const path = require('path'); import path from 'path';
const plugins = require('../../static/js/pluginfw/plugin_defs'); const plugins = require('../../static/js/pluginfw/plugin_defs');
const RequireKernel = require('etherpad-require-kernel'); const RequireKernel = require('etherpad-require-kernel');
const mime = require('mime-types'); import mime from 'mime-types';
const Threads = require('threads'); import log4js from 'log4js';
const log4js = require('log4js');
const sanitizePathname = require('./sanitizePathname'); const sanitizePathname = require('./sanitizePathname');
const logger = log4js.getLogger('Minify'); const logger = log4js.getLogger('Minify');
const ROOT_DIR = path.join(settings.root, 'src/static/'); const ROOT_DIR = path.join(settings.root, 'src/static/');
const threadsPool = new Threads.Pool(() => Threads.spawn(new Threads.Worker('./MinifyWorker')), 2);
const LIBRARY_WHITELIST = [ const LIBRARY_WHITELIST = [
'async', 'async',
@ -49,10 +50,10 @@ const LIBRARY_WHITELIST = [
// What follows is a terrible hack to avoid loop-back within the server. // What follows is a terrible hack to avoid loop-back within the server.
// TODO: Serve files from another service, or directly from the file system. // TODO: Serve files from another service, or directly from the file system.
const requestURI = async (url, method, headers) => { export const requestURI = async (url: string, method: string, headers: MapArrayType<any>) => {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
let status = 500; let status = 500;
const content = []; const content: string[] = [];
const mockRequest = { const mockRequest = {
url, url,
method, method,
@ -62,7 +63,7 @@ const requestURI = async (url, method, headers) => {
let mockResponse; let mockResponse;
const p = new Promise((resolve) => { const p = new Promise((resolve) => {
mockResponse = { mockResponse = {
writeHead: (_status, _headers) => { writeHead: (_status: number, _headers: MapArrayType<any>) => {
status = _status; status = _status;
for (const header in _headers) { for (const header in _headers) {
if (Object.prototype.hasOwnProperty.call(_headers, header)) { if (Object.prototype.hasOwnProperty.call(_headers, header)) {
@ -70,16 +71,16 @@ const requestURI = async (url, method, headers) => {
} }
} }
}, },
setHeader: (header, value) => { setHeader: (header: string, value: string) => {
headers[header.toLowerCase()] = value.toString(); headers[header.toLowerCase()] = value.toString();
}, },
header: (header, value) => { header: (header: string, value: string) => {
headers[header.toLowerCase()] = value.toString(); headers[header.toLowerCase()] = value.toString();
}, },
write: (_content) => { write: (_content: string) => {
_content && content.push(_content); _content && content.push(_content);
}, },
end: (_content) => { end: (_content: string) => {
_content && content.push(_content); _content && content.push(_content);
resolve([status, headers, content.join('')]); resolve([status, headers, content.join('')]);
}, },
@ -89,16 +90,17 @@ const requestURI = async (url, method, headers) => {
return await p; return await p;
}; };
const requestURIs = (locations, method, headers, callback) => { export const requestURIs = (locations: string[], method: string, headers: string[], callback:Function) => {
Promise.all(locations.map(async (loc) => { Promise.all(locations.map(async (loc) => {
try { try {
return await requestURI(loc, method, headers); return await requestURI(loc, method, headers);
} catch (err) { } catch (err:any) {
logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` + logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` +
`${JSON.stringify(headers)}) failed: ${err.stack || err}`); `${JSON.stringify(headers)}) failed: ${err.stack || err}`);
return [500, headers, '']; return [500, headers, ''];
} }
})).then((responses) => { // @ts-ignore
})).then((responses:[[number,number,number]]) => {
const statuss = responses.map((x) => x[0]); const statuss = responses.map((x) => x[0]);
const headerss = responses.map((x) => x[1]); const headerss = responses.map((x) => x[1]);
const contentss = responses.map((x) => x[2]); const contentss = responses.map((x) => x[2]);
@ -120,11 +122,11 @@ const compatPaths = {
* @param req the Express request * @param req the Express request
* @param res the Express response * @param res the Express response
*/ */
const minify = async (req, res) => { export const minify = async (req: any, res:any) => {
let filename = req.params.filename; let filename = req.params.filename;
try { try {
filename = sanitizePathname(filename); filename = sanitizePathname(filename);
} catch (err) { } catch (err:any) {
logger.error(`sanitization of pathname "${filename}" failed: ${err.stack || err}`); logger.error(`sanitization of pathname "${filename}" failed: ${err.stack || err}`);
res.writeHead(404, {}); res.writeHead(404, {});
res.end(); res.end();
@ -132,6 +134,7 @@ const minify = async (req, res) => {
} }
// Backward compatibility for plugins that require() files from old paths. // Backward compatibility for plugins that require() files from old paths.
// @ts-ignore
const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')]; const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')];
if (newLocation != null) { if (newLocation != null) {
logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`); logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`);
@ -169,7 +172,7 @@ const minify = async (req, res) => {
const [, testf] = /^plugins\/ep_etherpad-lite\/(tests\/frontend\/.*)/.exec(filename) || []; const [, testf] = /^plugins\/ep_etherpad-lite\/(tests\/frontend\/.*)/.exec(filename) || [];
if (testf != null) filename = `../${testf}`; if (testf != null) filename = `../${testf}`;
const contentType = mime.lookup(filename); const contentType = mime.lookup(filename) as string;
const [date, exists] = await statFile(filename, 3); const [date, exists] = await statFile(filename, 3);
if (date) { if (date) {
@ -186,7 +189,7 @@ const minify = async (req, res) => {
if (!exists) { if (!exists) {
res.writeHead(404, {}); res.writeHead(404, {});
res.end(); res.end();
} else if (new Date(req.headers['if-modified-since']) >= date) { } else if (new Date(req.headers['if-modified-since']) >= date!) {
res.writeHead(304, {}); res.writeHead(304, {});
res.end(); res.end();
} else if (req.method === 'HEAD') { } else if (req.method === 'HEAD') {
@ -206,7 +209,7 @@ const minify = async (req, res) => {
}; };
// Check for the existance of the file and get the last modification date. // Check for the existance of the file and get the last modification date.
const statFile = async (filename, dirStatLimit) => { const statFile = async (filename: string, dirStatLimit: number):Promise<[Date|null, boolean]> => {
/* /*
* The only external call to this function provides an explicit value for * The only external call to this function provides an explicit value for
* dirStatLimit: this check could be removed. * dirStatLimit: this check could be removed.
@ -227,9 +230,10 @@ const statFile = async (filename, dirStatLimit) => {
let stats; let stats;
try { try {
stats = await fs.stat(path.resolve(ROOT_DIR, filename)); stats = await fs.stat(path.resolve(ROOT_DIR, filename));
} catch (err) { } catch (err:any) {
if (['ENOENT', 'ENOTDIR'].includes(err.code)) { if (['ENOENT', 'ENOTDIR'].includes(err.code)) {
// Stat the directory instead. // Stat the directory instead.
// @ts-ignore
const [date] = await statFile(path.dirname(filename), dirStatLimit - 1); const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);
return [date, false]; return [date, false];
} }
@ -241,7 +245,7 @@ const statFile = async (filename, dirStatLimit) => {
const lastModifiedDateOfEverything = async () => { const lastModifiedDateOfEverything = async () => {
const folders2check = [path.join(ROOT_DIR, 'js/'), path.join(ROOT_DIR, 'css/')]; const folders2check = [path.join(ROOT_DIR, 'js/'), path.join(ROOT_DIR, 'css/')];
let latestModification = null; let latestModification:Date|null = null;
// go through this two folders // go through this two folders
await Promise.all(folders2check.map(async (dir) => { await Promise.all(folders2check.map(async (dir) => {
// read the files in the folder // read the files in the folder
@ -251,7 +255,7 @@ const lastModifiedDateOfEverything = async () => {
files.push('.'); files.push('.');
// go through all files in this folder // go through all files in this folder
await Promise.all(files.map(async (filename) => { await Promise.all(files.map(async (filename: string) => {
// get the stat data of this file // get the stat data of this file
const stats = await fs.stat(path.join(dir, filename)); const stats = await fs.stat(path.join(dir, filename));
@ -269,58 +273,39 @@ const lastModifiedDateOfEverything = async () => {
const _requireLastModified = new Date(); const _requireLastModified = new Date();
const requireDefinition = () => `var require = ${RequireKernel.kernelSource};\n`; const requireDefinition = () => `var require = ${RequireKernel.kernelSource};\n`;
const getFileCompressed = async (filename, contentType) => { const getFileCompressed = async (filename: string, contentType: string) => {
let content = await getFile(filename); let content = await getFile(filename);
if (!content || !settings.minify) { if (!content || !settings.minify) {
return content; return content;
} else if (contentType === 'application/javascript') { } else if (contentType === 'application/javascript') {
return await new Promise((resolve) => {
threadsPool.queue(async ({compressJS}) => {
try { try {
logger.info('Compress JS file %s.', filename); logger.info('Compress JS file %s.', filename);
content = content.toString(); content = content.toString();
const compressResult = await compressJS(content); const compressResult = compressJS(content);
if (compressResult.error) {
console.error(`Error compressing JS (${filename}) using terser`, compressResult.error);
} else {
content = compressResult.code.toString(); // Convert content obj code to string content = compressResult.code.toString(); // Convert content obj code to string
}
} catch (error) { } catch (error) {
console.error('getFile() returned an error in ' + console.error('getFile() returned an error in ' +
`getFileCompressed(${filename}, ${contentType}): ${error}`); `getFileCompressed(${filename}, ${contentType}): ${error}`);
} }
resolve(content); return content
});
});
} else if (contentType === 'text/css') { } else if (contentType === 'text/css') {
return await new Promise((resolve) => {
threadsPool.queue(async ({compressCSS}) => {
try { try {
logger.info('Compress CSS file %s.', filename); logger.info('Compress CSS file %s.', filename);
content = compressCSS(filename, ROOT_DIR, Buffer.from(content)).toString();
content = await compressCSS(filename, ROOT_DIR); return content
} catch (error) { } catch (error) {
console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`); console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`);
} }
resolve(content);
});
});
} else { } else {
return content; return content;
} }
}; };
const getFile = async (filename) => { const getFile = async (filename: string) => {
if (filename === 'js/require-kernel.js') return requireDefinition(); if (filename === 'js/require-kernel.js') return requireDefinition();
return await fs.readFile(path.resolve(ROOT_DIR, filename)); return await fs.readFile(path.resolve(ROOT_DIR, filename));
}; };
exports.minify = (req, res, next) => minify(req, res).catch((err) => next(err || new Error(err))); exports.minify = (req: Request, res: Response, next: Function) => minify(req, res).catch((err) => next(err || new Error(err)));
exports.requestURIs = requestURIs;
exports.shutdown = async (hookName, context) => {
await threadsPool.terminate();
};

View file

@ -1,33 +0,0 @@
'use strict';
/**
* Worker thread to minify JS & CSS files out of the main NodeJS thread
*/
const CleanCSS = require('clean-css');
const Terser = require('terser');
const fsp = require('fs').promises;
const path = require('path');
const Threads = require('threads');
const compressJS = (content) => Terser.minify(content);
const compressCSS = async (filename, ROOT_DIR) => {
const absPath = path.resolve(ROOT_DIR, filename);
try {
const basePath = path.dirname(absPath);
const output = await new CleanCSS({
rebase: true,
rebaseTo: basePath,
}).minify([absPath]);
return output.styles;
} catch (error) {
// on error, just yield the un-minified original, but write a log message
console.error(`Unexpected error minifying ${filename} (${absPath}): ${error}`);
return await fsp.readFile(absPath, 'utf8');
}
};
Threads.expose({
compressJS,
compressCSS,
});

View file

@ -0,0 +1,29 @@
'use strict';
/**
* Worker thread to minify JS & CSS files out of the main NodeJS thread
*/
import {promises as fsp} from "fs";
import path from 'path';
import {minifySync} from "@swc/core";
import lightminify from 'lightningcss';
export const compressJS = (content: string) => minifySync(content);
export const compressCSS = (filename: string, ROOT_DIR: string, content: Buffer) => {
const absPath = path.resolve(ROOT_DIR, filename);
try {
const basePath = path.dirname(absPath);
let { code } = lightminify.transform({
filename: absPath,
minify: true,
code: content
});
return code;
} catch (error) {
// on error, just yield the un-minified original, but write a log message
console.error(`Unexpected error minifying ${filename} (${absPath}): ${error}`);
return content.toString()
}
};

View file

@ -30,9 +30,9 @@
} }
], ],
"dependencies": { "dependencies": {
"@swc/core": "^1.4.8",
"async": "^3.2.5", "async": "^3.2.5",
"axios": "^1.7.2", "axios": "^1.7.2",
"clean-css": "^5.3.3",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
@ -51,6 +51,7 @@
"jsonminify": "0.4.2", "jsonminify": "0.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"languages4translatewiki": "0.1.3", "languages4translatewiki": "0.1.3",
"lightningcss": "^1.24.1",
"live-plugin-manager": "^1.0.0", "live-plugin-manager": "^1.0.0",
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
"log4js": "^6.9.1", "log4js": "^6.9.1",
@ -68,8 +69,6 @@
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"superagent": "^9.0.2", "superagent": "^9.0.2",
"terser": "^5.30.3",
"threads": "^1.7.0",
"tinycon": "0.6.8", "tinycon": "0.6.8",
"tsx": "^4.10.5", "tsx": "^4.10.5",
"ueberdb2": "^4.2.63", "ueberdb2": "^4.2.63",