Skip to content

Commit b76041e

Browse files
Refactor build pipeline for performance and dev output reliability
Replace JS/CSS minifiers with esbuild to reduce build times while preserving outputs. Enable `thread-loader` by default in dev and production. Choose workers dynamically (based on CPU cores) and allow overrides via environment variables. Keep filesystem cache enabled and make cache compression configurable, defaulting to uncompressed for faster warm builds on CPU-bound machines. Add BUILD_PARALLEL toggle (default on) to switch between parallel and sequential production variant builds. Ensure watch-once dev builds exit cleanly. Adopt `sass-embedded` for SASS processing. In development, use `style-loader` to speed up CSS/SCSS compilation while keeping production outputs unchanged. Maintain CSP-safe source maps for extension contexts and suppress CSS 404 noise in development outputs. Additionally, dependency caching has been added to the GitHub Actions workflow to accelerate CI/CD runs. Results on this DO VPS (2 cores, ~4 GiB RAM): - Production first run (cold): ~44s (baseline ~105s) - Production second run (warm): ~19s (baseline ~39s) - Development first run: ~31s; second run: ~29s Times vary by environment; numbers above are for this machine.
1 parent 3768a06 commit b76041e

File tree

5 files changed

+1644
-381
lines changed

5 files changed

+1644
-381
lines changed

.github/copilot-instructions.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ Always reference these instructions first and fall back to search or bash comman
1717
- Lint code: `npm run lint` -- uses ESLint.
1818
- Safari build: `npm run build:safari` (see Platform-Specific Instructions for details)
1919

20+
### Build Performance Options
21+
22+
- BUILD_PARALLEL: Toggle parallel build of production variants
23+
- Default: on (parallel). Set to `0` to run sequentially (lower CPU/IO spikes on low-core machines)
24+
- BUILD_THREAD / BUILD_THREAD_WORKERS: Control Babel parallelism via thread-loader
25+
- Default: threads enabled in dev/prod; workers = CPU cores
26+
- Set `BUILD_THREAD=0` to disable; set `BUILD_THREAD_WORKERS=<n>` to override worker count
27+
- BUILD_CACHE_COMPRESSION: Webpack filesystem cache compression
28+
- Default: `0` (no compression) for faster warm builds on CPU‑bound SSD machines
29+
- Options: `0|false|none`, `gzip` (or `brotli` if explicitly desired)
30+
- Affects only `.cache/webpack` size/speed; does not change final artifacts
31+
- BUILD_WATCH_ONCE (dev): When set, `npm run dev` runs a single build and exits (useful for timing)
32+
33+
Performance defaults: esbuild is used for JS/CSS minification; dev injects CSS via style-loader,
34+
prod extracts CSS via MiniCssExtractPlugin; thread-loader is enabled by default in both dev and prod.
35+
2036
### Build Output Structure
2137

2238
Production build creates multiple variants in `build/` directory:

.github/workflows/pre-release-build.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,23 @@ jobs:
2424
- uses: actions/setup-node@v4
2525
with:
2626
node-version: 20
27+
- name: Cache npm cache
28+
uses: actions/cache@v4
29+
with:
30+
path: ~/.npm
31+
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
32+
restore-keys: |
33+
${{ runner.os }}-node-
2734
- run: npm ci
35+
- name: Cache Webpack filesystem cache
36+
uses: actions/cache@v4
37+
with:
38+
path: |
39+
.cache/webpack
40+
node_modules/.cache/webpack
41+
key: ${{ runner.os }}-webpack-${{ hashFiles('**/package-lock.json') }}
42+
restore-keys: |
43+
${{ runner.os }}-webpack-
2844
- run: npm run build
2945

3046
- uses: josStorer/get-current-time@v2

build.mjs

Lines changed: 167 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,42 @@ import archiver from 'archiver'
22
import fs from 'fs-extra'
33
import path from 'path'
44
import webpack from 'webpack'
5+
import os from 'os'
56
import ProgressBarPlugin from 'progress-bar-webpack-plugin'
67
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
78
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
8-
import TerserPlugin from 'terser-webpack-plugin'
9+
import { EsbuildPlugin } from 'esbuild-loader'
910
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
1011

1112
const outdir = 'build'
1213

1314
const __dirname = path.resolve()
1415
const isProduction = process.argv[2] !== '--development' // --production and --analyze are both production
1516
const isAnalyzing = process.argv[2] === '--analyze'
17+
const parallelBuild = process.env.BUILD_PARALLEL === '0' ? false : true
18+
const isWatchOnce = !!process.env.BUILD_WATCH_ONCE
19+
// Cache compression control: default none; allow override via env
20+
const cacheCompressionEnv = process.env.BUILD_CACHE_COMPRESSION
21+
let cacheCompressionOption
22+
if (cacheCompressionEnv) {
23+
const v = String(cacheCompressionEnv).toLowerCase()
24+
if (v === '0' || v === 'false' || v === 'none') cacheCompressionOption = false
25+
else if (v === 'brotli') cacheCompressionOption = 'brotli'
26+
else cacheCompressionOption = 'gzip' // treat any truthy/unknown as gzip
27+
}
28+
const cpuCount = os.cpus()?.length || 1
29+
const envWorkers = process.env.BUILD_THREAD_WORKERS
30+
? parseInt(process.env.BUILD_THREAD_WORKERS, 10)
31+
: undefined
32+
const threadWorkers = Number.isInteger(envWorkers) && envWorkers > 0 ? envWorkers : cpuCount
33+
// Enable threads by default; allow disabling via BUILD_THREAD=0
34+
const enableThread = process.env.BUILD_THREAD === '0' ? false : true
1635

1736
async function deleteOldDir() {
1837
await fs.rm(outdir, { recursive: true, force: true })
1938
}
2039

21-
async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback) {
40+
async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, sourceBuildDir, callback) {
2241
const shared = [
2342
'preact',
2443
'webextension-polyfill',
@@ -33,6 +52,8 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
3352
]
3453
if (isWithoutKatex) shared.push('./src/components')
3554

55+
const sassImpl = await import('sass-embedded')
56+
3657
const compiler = webpack({
3758
entry: {
3859
'content-script': {
@@ -54,18 +75,29 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
5475
},
5576
output: {
5677
filename: '[name].js',
57-
path: path.resolve(__dirname, outdir),
78+
path: path.resolve(__dirname, sourceBuildDir || outdir),
5879
},
5980
mode: isProduction ? 'production' : 'development',
60-
devtool: isProduction ? false : 'inline-source-map',
81+
devtool: isProduction ? false : 'cheap-module-source-map',
82+
cache: {
83+
type: 'filesystem',
84+
// default none; override via BUILD_CACHE_COMPRESSION=gzip|brotli
85+
compression: cacheCompressionOption ?? false,
86+
buildDependencies: {
87+
config: [path.resolve('build.mjs')],
88+
},
89+
},
6190
optimization: {
6291
minimizer: [
63-
new TerserPlugin({
64-
terserOptions: {
65-
output: { ascii_only: true },
66-
},
92+
// Use esbuild for JS minification (faster than Terser)
93+
new EsbuildPlugin({
94+
target: 'es2017',
95+
legalComments: 'none',
96+
}),
97+
// Use esbuild-based CSS minify via css-minimizer plugin
98+
new CssMinimizerPlugin({
99+
minify: CssMinimizerPlugin.esbuildMinify,
67100
}),
68-
new CssMinimizerPlugin(),
69101
],
70102
concatenateModules: !isAnalyzing,
71103
},
@@ -103,6 +135,7 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
103135
],
104136
resolve: {
105137
extensions: ['.jsx', '.mjs', '.js'],
138+
symlinks: false,
106139
alias: {
107140
parse5: path.resolve(__dirname, 'node_modules/parse5'),
108141
...(minimal
@@ -124,9 +157,23 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
124157
fullySpecified: false,
125158
},
126159
use: [
160+
...(enableThread
161+
? [
162+
{
163+
loader: 'thread-loader',
164+
options: {
165+
workers: threadWorkers,
166+
// Ensure one-off dev build exits quickly
167+
poolTimeout: isProduction ? 2000 : isWatchOnce ? 0 : Infinity,
168+
},
169+
},
170+
]
171+
: []),
127172
{
128173
loader: 'babel-loader',
129174
options: {
175+
cacheDirectory: true,
176+
cacheCompression: false,
130177
presets: [
131178
'@babel/preset-env',
132179
{
@@ -149,7 +196,7 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
149196
{
150197
test: /\.s[ac]ss$/,
151198
use: [
152-
MiniCssExtractPlugin.loader,
199+
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
153200
{
154201
loader: 'css-loader',
155202
options: {
@@ -158,13 +205,14 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
158205
},
159206
{
160207
loader: 'sass-loader',
208+
options: { implementation: sassImpl },
161209
},
162210
],
163211
},
164212
{
165213
test: /\.less$/,
166214
use: [
167-
MiniCssExtractPlugin.loader,
215+
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
168216
{
169217
loader: 'css-loader',
170218
options: {
@@ -179,7 +227,7 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
179227
{
180228
test: /\.css$/,
181229
use: [
182-
MiniCssExtractPlugin.loader,
230+
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
183231
{
184232
loader: 'css-loader',
185233
},
@@ -258,7 +306,12 @@ async function runWebpack(isWithoutKatex, isWithoutTiktoken, minimal, callback)
258306
},
259307
})
260308
if (isProduction) compiler.run(callback)
261-
else compiler.watch({}, callback)
309+
else {
310+
const watching = compiler.watch({}, (err, stats) => {
311+
callback(err, stats)
312+
if (process.env.BUILD_WATCH_ONCE) watching.close(() => {})
313+
})
314+
}
262315
}
263316

264317
async function zipFolder(dir) {
@@ -275,28 +328,30 @@ async function copyFiles(entryPoints, targetDir) {
275328
if (!fs.existsSync(targetDir)) await fs.mkdir(targetDir)
276329
await Promise.all(
277330
entryPoints.map(async (entryPoint) => {
278-
await fs.copy(entryPoint.src, `${targetDir}/${entryPoint.dst}`)
331+
if (await fs.pathExists(entryPoint.src)) {
332+
await fs.copy(entryPoint.src, `${targetDir}/${entryPoint.dst}`)
333+
}
279334
}),
280335
)
281336
}
282337

283-
async function finishOutput(outputDirSuffix) {
338+
async function finishOutput(outputDirSuffix, sourceBuildDir = outdir) {
284339
const commonFiles = [
285340
{ src: 'src/logo.png', dst: 'logo.png' },
286341
{ src: 'src/rules.json', dst: 'rules.json' },
287342

288-
{ src: 'build/shared.js', dst: 'shared.js' },
289-
{ src: 'build/content-script.css', dst: 'content-script.css' }, // shared
343+
{ src: `${sourceBuildDir}/shared.js`, dst: 'shared.js' },
344+
{ src: `${sourceBuildDir}/content-script.css`, dst: 'content-script.css' }, // shared
290345

291-
{ src: 'build/content-script.js', dst: 'content-script.js' },
346+
{ src: `${sourceBuildDir}/content-script.js`, dst: 'content-script.js' },
292347

293-
{ src: 'build/background.js', dst: 'background.js' },
348+
{ src: `${sourceBuildDir}/background.js`, dst: 'background.js' },
294349

295-
{ src: 'build/popup.js', dst: 'popup.js' },
296-
{ src: 'build/popup.css', dst: 'popup.css' },
350+
{ src: `${sourceBuildDir}/popup.js`, dst: 'popup.js' },
351+
{ src: `${sourceBuildDir}/popup.css`, dst: 'popup.css' },
297352
{ src: 'src/popup/index.html', dst: 'popup.html' },
298353

299-
{ src: 'build/IndependentPanel.js', dst: 'IndependentPanel.js' },
354+
{ src: `${sourceBuildDir}/IndependentPanel.js`, dst: 'IndependentPanel.js' },
300355
{ src: 'src/pages/IndependentPanel/index.html', dst: 'IndependentPanel.html' },
301356
]
302357

@@ -306,6 +361,18 @@ async function finishOutput(outputDirSuffix) {
306361
[...commonFiles, { src: 'src/manifest.json', dst: 'manifest.json' }],
307362
chromiumOutputDir,
308363
)
364+
// In development, ensure placeholder CSS files exist to avoid 404 noise
365+
if (!isProduction) {
366+
const chromiumCssPlaceholders = [
367+
path.join(chromiumOutputDir, 'popup.css'),
368+
path.join(chromiumOutputDir, 'content-script.css'),
369+
]
370+
for (const p of chromiumCssPlaceholders) {
371+
if (!(await fs.pathExists(p))) {
372+
await fs.outputFile(p, '/* dev placeholder */\n')
373+
}
374+
}
375+
}
309376
if (isProduction) await zipFolder(chromiumOutputDir)
310377

311378
// firefox
@@ -314,43 +381,92 @@ async function finishOutput(outputDirSuffix) {
314381
[...commonFiles, { src: 'src/manifest.v2.json', dst: 'manifest.json' }],
315382
firefoxOutputDir,
316383
)
317-
if (isProduction) await zipFolder(firefoxOutputDir)
318-
}
319-
320-
function generateWebpackCallback(finishOutputFunc) {
321-
return async function webpackCallback(err, stats) {
322-
if (err || stats.hasErrors()) {
323-
console.error(err || stats.toString())
324-
return
384+
// In development, ensure placeholder CSS files exist to avoid 404 noise
385+
if (!isProduction) {
386+
const firefoxCssPlaceholders = [
387+
path.join(firefoxOutputDir, 'popup.css'),
388+
path.join(firefoxOutputDir, 'content-script.css'),
389+
]
390+
for (const p of firefoxCssPlaceholders) {
391+
if (!(await fs.pathExists(p))) {
392+
await fs.outputFile(p, '/* dev placeholder */\n')
393+
}
325394
}
326-
// console.log(stats.toString())
327-
328-
await finishOutputFunc()
329395
}
396+
if (isProduction) await zipFolder(firefoxOutputDir)
330397
}
331398

332399
async function build() {
333400
await deleteOldDir()
334401
if (isProduction && !isAnalyzing) {
335-
// await runWebpack(
336-
// true,
337-
// false,
338-
// generateWebpackCallback(() => finishOutput('-without-katex')),
339-
// )
340-
// await new Promise((r) => setTimeout(r, 5000))
341-
await runWebpack(
342-
true,
343-
true,
344-
true,
345-
generateWebpackCallback(() => finishOutput('-without-katex-and-tiktoken')),
346-
)
347-
await new Promise((r) => setTimeout(r, 10000))
402+
const tmpFull = `${outdir}/.tmp-full`
403+
const tmpMin = `${outdir}/.tmp-min`
404+
if (parallelBuild) {
405+
await Promise.all([
406+
new Promise((resolve, reject) =>
407+
runWebpack(true, true, true, tmpMin, async (err, stats) => {
408+
if (err || stats.hasErrors()) {
409+
console.error(err || stats.toString())
410+
reject(err || new Error('webpack error'))
411+
return
412+
}
413+
await finishOutput('-without-katex-and-tiktoken', tmpMin)
414+
resolve()
415+
}),
416+
),
417+
new Promise((resolve, reject) =>
418+
runWebpack(false, false, false, tmpFull, async (err, stats) => {
419+
if (err || stats.hasErrors()) {
420+
console.error(err || stats.toString())
421+
reject(err || new Error('webpack error'))
422+
return
423+
}
424+
await finishOutput('', tmpFull)
425+
resolve()
426+
}),
427+
),
428+
])
429+
await fs.rm(tmpFull, { recursive: true, force: true })
430+
await fs.rm(tmpMin, { recursive: true, force: true })
431+
} else {
432+
await new Promise((resolve, reject) =>
433+
runWebpack(true, true, true, tmpMin, async (err, stats) => {
434+
if (err || stats.hasErrors()) {
435+
console.error(err || stats.toString())
436+
reject(err || new Error('webpack error'))
437+
return
438+
}
439+
await finishOutput('-without-katex-and-tiktoken', tmpMin)
440+
resolve()
441+
}),
442+
)
443+
await new Promise((resolve, reject) =>
444+
runWebpack(false, false, false, tmpFull, async (err, stats) => {
445+
if (err || stats.hasErrors()) {
446+
console.error(err || stats.toString())
447+
reject(err || new Error('webpack error'))
448+
return
449+
}
450+
await finishOutput('', tmpFull)
451+
resolve()
452+
}),
453+
)
454+
await fs.rm(tmpFull, { recursive: true, force: true })
455+
await fs.rm(tmpMin, { recursive: true, force: true })
456+
}
457+
return
348458
}
349-
await runWebpack(
350-
false,
351-
false,
352-
false,
353-
generateWebpackCallback(() => finishOutput('')),
459+
460+
await new Promise((resolve, reject) =>
461+
runWebpack(false, false, false, outdir, async (err, stats) => {
462+
if (err || stats.hasErrors()) {
463+
console.error(err || stats.toString())
464+
reject(err || new Error('webpack error'))
465+
return
466+
}
467+
await finishOutput('')
468+
resolve()
469+
}),
354470
)
355471
}
356472

0 commit comments

Comments
 (0)