From 1e713f253b611b0bb9ad6603d81f78ebbf932560 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 06:20:57 +0000 Subject: [PATCH 1/7] Warm the file cache on a background worker thread The first request for an uncached file read and processed it synchronously (Sass, Babel, Snockets, UglifyJS, gzip), blocking the event loop and slowing the whole site. Extract the processing pipeline into a shared processor module and run it in a background worker thread that lazily reads every file in the directory, posting finished cache entries back to the main thread. The synchronous read stays as the per-request fallback, so behavior is unchanged for any file not yet warmed. Warming is skipped while watching (it registers discovered dependencies on the main thread) and when options carry functions (which can't be cloned into the worker), to keep cached output identical to the synchronous path. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- lib/index.js | 423 ++++++++++++----------------------------------- lib/processor.js | 348 ++++++++++++++++++++++++++++++++++++++ lib/warmer.js | 88 ++++++++++ 3 files changed, 540 insertions(+), 319 deletions(-) create mode 100644 lib/processor.js create mode 100644 lib/warmer.js diff --git a/lib/index.js b/lib/index.js index c99114b..2394bc8 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,19 +1,98 @@ -const crypto = require('node:crypto'); -const fs = require('node:fs'); const path = require('node:path'); -const zlib = require('node:zlib'); +const { Worker } = require('node:worker_threads'); -const babel = require('@babel/core'); const chokidar = require('chokidar'); -const mime = require('mime'); const Negotiator = require('negotiator'); -const sass = require('sass'); -const sassGraph = require('sass-graph'); -const Snockets = require('snockets'); -const UglifyJS = require('uglify-js'); -const UglifyCss = require('uglifycss'); -const gzipContentTypes = require('./gzipContentTypes.js'); +const { createProcessor, hashifyUrl, parseUrlPath } = require('./processor.js'); + +/** + * Deeply checks whether a value contains any functions, which can't be + * structured-cloned to a worker thread. When options carry functions (e.g. a + * Babel plugin or Sass importer), the worker can't reproduce the main thread's + * output, so background warming is disabled to avoid caching divergent results. + * @param {*} value + * @param {Set} [seen] + */ +function hasFunction(value, seen = new Set()) { + if (typeof value === 'function') { + return true; + } + + if (value && typeof value === 'object') { + if (seen.has(value)) { + return false; + } + + seen.add(value); + + for (const key of Object.keys(value)) { + if (hasFunction(value[key], seen)) { + return true; + } + } + } + + return false; +} + +/** + * Restores Buffers lost to structured cloning. Binary content posted from the + * worker arrives as a Uint8Array; wrap it back into a Buffer so it behaves like + * a synchronously-read file. + * @param {object} file + */ +function reviveFile(file) { + if (typeof file.content !== 'string') { + file.content = Buffer.from(file.content.buffer, file.content.byteOffset, file.content.byteLength); + } + + if (file.gzip && typeof file.gzip.content !== 'string') { + file.gzip.content = Buffer.from(file.gzip.content.buffer, file.gzip.content.byteOffset, file.gzip.content.byteLength); + } + + return file; +} + +/** + * Spawns a background worker thread that lazily reads and processes every file + * in the directory, posting finished cache entries back to the main thread. + * This keeps the expensive first read (Sass/Babel/UglifyJS/gzip) off the event + * loop so requests don't stall. The worker is unref'd so it never keeps the + * process alive, and any failure simply leaves the synchronous read in place. + * @param {string} directory + * @param {object} options + * @param {object} processor + */ +function startWarmer(directory, options, processor) { + let worker; + + try { + worker = new Worker(path.join(__dirname, 'warmer.js'), { + workerData: { directory, options } + }); + } catch (err) { + //eslint-disable-next-line no-console + console.warn(`Electricity skipping cache warming:\n ${err}`); + return; + } + + worker.on('message', ({ file, urlPath }) => { + // Don't clobber an entry a request already populated via the + // synchronous fallback; both produce identical content. + if (!processor.files[urlPath]) { + processor.files[urlPath] = reviveFile(file); + } + }); + + worker.on('error', (err) => { + //eslint-disable-next-line no-console + console.warn(`Electricity cache warming error:\n ${err}`); + }); + + // Never let warming hold the process open. + worker.unref(); +} exports.static = (directory, options) => { // Default to 'public' if the directory is not specified @@ -73,329 +152,35 @@ exports.static = (directory, options) => { }; } - // Create a local cache to hold the files - const files = {}; - - const snockets = new Snockets(); - let watcher; if (options.watch.enabled) { // Setup the watcher watcher = chokidar.watch(directory, { ignoreInitial: true }); - - watcher.on('all', (eventName, filePath) => { - removeFile(filePath); - }); } - /** - * Tries to read a file from local cache. - * Reads the file from disk if it's not present in the local cache. - * @param {string} urlPath - */ - function fetchFile(urlPath) { - // Try to get the file from local cache - let file = files[urlPath]; - - // Return the file from cache if found - if (file) { - return file; - } - - // Read the file from disk - file = readFile(urlPath); - - // Put the file in local cache - files[urlPath] = file; + const processor = createProcessor(directory, options, watcher); - return file; - } - - /** - * Converts a URL (/robots.txt) to a URL that includes the file's hash (/robots-3f54004ef6fc21b24a9e6069fc114fd9070b77a1.txt) - * @param {string} url - * @param {string} hash - */ - function hashifyUrl(url, hash) { - if (!url.includes('.')) { - return url.replace(/([?#].*)?$/, `-${hash}$1`); - } - - return url.replace(/\.([^.]*)([?#].*)?$/, `-${hash}.$1$2`); - } - - /** - * Parses a URL path potentially containing a hash (/robots-3f54004ef6fc21b24a9e6069fc114fd9070b77a1.txt) - * into an object with a hash and path properties ({ hash: '3f54004ef6fc21b24a9e6069fc114fd9070b77a1', path: '/robots.txt' }) - * @param {object} req - */ - function parseUrlPath(urlPath) { - // https://regex101.com/r/j5hvRj/2 - const regex = /\/.+(-([0-9a-f]{32,40}))/; - const matches = urlPath.match(regex); - - if (!matches) { - return { - path: urlPath - }; - } - - return { - hash: matches[2], - path: urlPath.replace(matches[1], '') - }; - } - - function readCascadingStyleSheetsFile(filePath) { - let data; - - // CSS - try { - data = fs.readFileSync(filePath).toString(); - } catch (err) { - // Handle ENOENT (No such file or directory): https://nodejs.org/api/errors.html#common-system-errors - if (err.code !== 'ENOENT') { - throw err; - } - - // SASS - const basename = path.basename(filePath, path.extname(filePath)); - const sassFile = path.join(path.dirname(filePath), `${basename}.scss`); - const result = sass.compile(sassFile, options.sass); - - data = result.css; - - // SASS (watcher) - if (watcher) { - result.loadedUrls.forEach(file => { - watcher.add(file.pathname); - }); - } - } - - // Update URLs in CSS: https://regex101.com/r/FxrppP/4 - data = data.replace(/url\(['"]?(.*?)['"]?\)/g, (match, p1) => { - return `url(${urlBuilder(p1)})`; + if (watcher) { + watcher.on('all', (eventName, filePath) => { + processor.removeFile(filePath); }); - - // UglifyCSS - if (options.uglifycss.enabled) { - data = UglifyCss.processString(data, options.uglifycss); - } - - return data; } - function readFile(urlPath) { - let filePath = toFilePath(urlPath); - let extension = path.extname(filePath); - let data; - - if (extension === '.css') { - data = readCascadingStyleSheetsFile(filePath); - } else if (extension === '.js') { - data = readJavaScriptFile(filePath); - } else { - data = fs.readFileSync(filePath); - } - - const file = { - content: data, - contentLength: data.length, - contentType: mime.getType(urlPath), - hash: crypto.createHash('sha1').update(data).digest('hex') - }; - - // Don't gzip any content less that 1500 bytes (the size of a TCP packet). Only gzip specific content types. - if (options.gzip.enabled && file.contentLength > 1500 && gzipContentTypes.includes(file.contentType)) { - const gzipContent = zlib.gzipSync(file.content); - - file.gzip = { - content: gzipContent, - contentLength: gzipContent.length - }; - } - - return file; - } - - function readJavaScriptFile(filePath) { - let data; - - // Snockets - try { - data = snockets.getConcatenation(filePath, options.snockets); - } catch(err) { - // Snockets can't parse, so just pass the js file along - //eslint-disable-next-line no-console - console.warn(`Snockets skipping ${filePath}:\n ${err}`); - } - - // Snockets (watcher) - if (watcher) { - try { - // Get all files in the snockets chain - const compiledChain = snockets.getCompiledChain(filePath, options.snockets); - - // Add each file of the snockets chain to the watcher - compiledChain.forEach(c => { - watcher.add(c.filename); - }); - } catch(err) { - // Snockets can't parse, so skip watch - //eslint-disable-next-line no-console - console.warn(`Snockets skipping watch for ${filePath}:\n ${err}`); - } - } - - // If Snockets didn't parse the file, read it from disk - if (!data) { - data = fs.readFileSync(filePath).toString(); - } - - // Babel - try { - let result = babel.transformSync(data, { - ...options.babel, - presets: [require('@babel/preset-react')] - }); - - data = result.code; - } catch(err) { - // Babel can't transform, so just pass the file along - //eslint-disable-next-line no-console - console.warn(`Babel skipping ${filePath}:\n ${err}`); - } - - // UglifyJS - if (options.uglifyjs.enabled) { - const uglifyjsOptions = JSON.parse(JSON.stringify(options.uglifyjs)); - delete uglifyjsOptions.enabled; - - const result = UglifyJS.minify(data, uglifyjsOptions); - - if (result.error) { - //eslint-disable-next-line no-console - console.warn(`UglifyJS skipping ${filePath}:\n ${JSON.stringify(result.error)}`); - } else { - data = result.code; - } - } - - return data; - } - - /** - * Removes a file from the local cache. - * @param {*} filePath - */ - function removeFile(filePath) { - let extension = path.extname(filePath); - - if (extension === '.js') { - return removeJavaScriptFile(filePath); - } else if (extension === '.scss') { - return removeSassFile(filePath); - } - - // Remove the changed file from the local cache - delete files[toUrlPath(filePath)]; - } - - /** - * Removes a JavaScript file from the local cache. - * @param {string} filePath - */ - function removeJavaScriptFile(filePath) { - // Remove the changed file from the local cache - delete files[toUrlPath(filePath)]; - - // Resolve the absolute file path for the changed file - const absoluteFilePath = path.resolve(filePath); - - // Find any parents that have a dependency on this file and remove them too - snockets.depGraph.parentsOf(absoluteFilePath).forEach(removeJavaScriptFile); - } - - /** - * Removes a SASS file from the local cache. - * @param {string} filePath - */ - function removeSassFile(filePath) { - const basename = path.basename(filePath, path.extname(filePath)); - const cssFilePath = path.join(path.dirname(filePath), `${basename}.css`); - const urlPath = toUrlPath(cssFilePath); - - // Remove the changed file from the local cache - delete files[urlPath]; - - // Resolve the absolute file path for the changed file - let absoluteFilePath = path.resolve(filePath); - - // Try to resolve symlinks - try { - absoluteFilePath = fs.realpathSync(filePath); - } catch (e) { - // ignore error - } - - const graph = sassGraph.parseDir(directory); - const sassFile = graph.index[absoluteFilePath]; - - if (sassFile) { - sassFile.importedBy.forEach(removeSassFile); - } - } - - function urlBuilder(urlPath) { - let file; - const request = parseUrlPath(urlPath); - let url = urlPath; - - try { - file = fetchFile(request.path); - } catch(err) { - // If we don't have a file that matches the specified URL path simply return the original URL path - return urlPath; - } - - if (options.hashify) { - url = hashifyUrl(request.path, file.hash); - } - - if (options.hostname) { - url = `https://${options.hostname}${url}`; - } - - return url; - } - - /** - * Converts a URL path (/robots.txt) to a file path (/Users/username/site/public/robots.txt). - * @param {string} urlPath - */ - function toFilePath(urlPath) { - const myURL = new URL(urlPath, 'https://example.org/'); - const pathname = myURL.pathname.replace(/^\//, ''); - return path.resolve(directory, pathname); - } - - /** - * Converts a file path (/Users/username/site/public/robots.txt) to a URL path (/robots.txt). - * @param {string} urlPath - */ - function toUrlPath(filePath) { - const urlPath = path.posix.relative(directory, path.resolve(filePath)); - - return `/${urlPath}`; + // Warm the cache on a background thread so the first request for a file + // doesn't read and process it synchronously on the event loop. Watching + // keeps processing on the main thread (it registers discovered + // dependencies with the watcher), and options carrying functions can't be + // cloned into the worker, so warming is skipped in those cases. + if (!options.watch.enabled && !hasFunction(options)) { + startWarmer(directory, options, processor); } return function staticMiddleware(req, res, next) { // Register function in app.locals to help views build URLs: https://expressjs.com/en/api.html#app.locals if (req.app && !req.app.locals.electricity) { req.app.locals.electricity = { - url: urlBuilder + url: processor.urlBuilder }; } @@ -408,7 +193,7 @@ exports.static = (directory, options) => { const request = parseUrlPath(req.path); try { - file = fetchFile(request.path); + file = processor.fetchFile(request.path); } catch (err) { // Handle EISDIR (Is a directory): https://nodejs.org/api/errors.html#common-system-errors if (err.code === 'EISDIR') { @@ -491,4 +276,4 @@ exports.static = (directory, options) => { res.send(content); }; -}; \ No newline at end of file +}; diff --git a/lib/processor.js b/lib/processor.js new file mode 100644 index 0000000..93f6f69 --- /dev/null +++ b/lib/processor.js @@ -0,0 +1,348 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +const babel = require('@babel/core'); +const mime = require('mime'); +const sass = require('sass'); +const sassGraph = require('sass-graph'); +const Snockets = require('snockets'); +const UglifyJS = require('uglify-js'); +const UglifyCss = require('uglifycss'); + +const gzipContentTypes = require('./gzipContentTypes.js'); + +/** + * Converts a URL (/robots.txt) to a URL that includes the file's hash (/robots-3f54004ef6fc21b24a9e6069fc114fd9070b77a1.txt) + * @param {string} url + * @param {string} hash + */ +function hashifyUrl(url, hash) { + if (!url.includes('.')) { + return url.replace(/([?#].*)?$/, `-${hash}$1`); + } + + return url.replace(/\.([^.]*)([?#].*)?$/, `-${hash}.$1$2`); +} + +/** + * Parses a URL path potentially containing a hash (/robots-3f54004ef6fc21b24a9e6069fc114fd9070b77a1.txt) + * into an object with a hash and path properties ({ hash: '3f54004ef6fc21b24a9e6069fc114fd9070b77a1', path: '/robots.txt' }) + * @param {string} urlPath + */ +function parseUrlPath(urlPath) { + // https://regex101.com/r/j5hvRj/2 + const regex = /\/.+(-([0-9a-f]{32,40}))/; + const matches = urlPath.match(regex); + + if (!matches) { + return { + path: urlPath + }; + } + + return { + hash: matches[2], + path: urlPath.replace(matches[1], '') + }; +} + +/** + * Creates a file processor bound to a directory and a set of options. + * The processor owns its own in-memory cache, so the same logic can run on + * the main thread (serving requests) or inside a worker thread (warming the + * cache). When no watcher is supplied (the production / worker case) the + * watch-only side effects are skipped, so both contexts produce identical + * cache entries for the same file. + * @param {string} directory + * @param {object} options + * @param {object} [watcher] + */ +function createProcessor(directory, options, watcher) { + // Create a local cache to hold the files + const files = {}; + + const snockets = new Snockets(); + + /** + * Tries to read a file from local cache. + * Reads the file from disk if it's not present in the local cache. + * @param {string} urlPath + */ + function fetchFile(urlPath) { + // Try to get the file from local cache + let file = files[urlPath]; + + // Return the file from cache if found + if (file) { + return file; + } + + // Read the file from disk + file = readFile(urlPath); + + // Put the file in local cache + files[urlPath] = file; + + return file; + } + + function readCascadingStyleSheetsFile(filePath) { + let data; + + // CSS + try { + data = fs.readFileSync(filePath).toString(); + } catch (err) { + // Handle ENOENT (No such file or directory): https://nodejs.org/api/errors.html#common-system-errors + if (err.code !== 'ENOENT') { + throw err; + } + + // SASS + const basename = path.basename(filePath, path.extname(filePath)); + const sassFile = path.join(path.dirname(filePath), `${basename}.scss`); + const result = sass.compile(sassFile, options.sass); + + data = result.css; + + // SASS (watcher) + if (watcher) { + result.loadedUrls.forEach(file => { + watcher.add(file.pathname); + }); + } + } + + // Update URLs in CSS: https://regex101.com/r/FxrppP/4 + data = data.replace(/url\(['"]?(.*?)['"]?\)/g, (match, p1) => { + return `url(${urlBuilder(p1)})`; + }); + + // UglifyCSS + if (options.uglifycss.enabled) { + data = UglifyCss.processString(data, options.uglifycss); + } + + return data; + } + + function readFile(urlPath) { + let filePath = toFilePath(urlPath); + let extension = path.extname(filePath); + let data; + + if (extension === '.css') { + data = readCascadingStyleSheetsFile(filePath); + } else if (extension === '.js') { + data = readJavaScriptFile(filePath); + } else { + data = fs.readFileSync(filePath); + } + + const file = { + content: data, + contentLength: data.length, + contentType: mime.getType(urlPath), + hash: crypto.createHash('sha1').update(data).digest('hex') + }; + + // Don't gzip any content less that 1500 bytes (the size of a TCP packet). Only gzip specific content types. + if (options.gzip.enabled && file.contentLength > 1500 && gzipContentTypes.includes(file.contentType)) { + const gzipContent = zlib.gzipSync(file.content); + + file.gzip = { + content: gzipContent, + contentLength: gzipContent.length + }; + } + + return file; + } + + function readJavaScriptFile(filePath) { + let data; + + // Snockets + try { + data = snockets.getConcatenation(filePath, options.snockets); + } catch(err) { + // Snockets can't parse, so just pass the js file along + //eslint-disable-next-line no-console + console.warn(`Snockets skipping ${filePath}:\n ${err}`); + } + + // Snockets (watcher) + if (watcher) { + try { + // Get all files in the snockets chain + const compiledChain = snockets.getCompiledChain(filePath, options.snockets); + + // Add each file of the snockets chain to the watcher + compiledChain.forEach(c => { + watcher.add(c.filename); + }); + } catch(err) { + // Snockets can't parse, so skip watch + //eslint-disable-next-line no-console + console.warn(`Snockets skipping watch for ${filePath}:\n ${err}`); + } + } + + // If Snockets didn't parse the file, read it from disk + if (!data) { + data = fs.readFileSync(filePath).toString(); + } + + // Babel + try { + let result = babel.transformSync(data, { + ...options.babel, + presets: [require('@babel/preset-react')] + }); + + data = result.code; + } catch(err) { + // Babel can't transform, so just pass the file along + //eslint-disable-next-line no-console + console.warn(`Babel skipping ${filePath}:\n ${err}`); + } + + // UglifyJS + if (options.uglifyjs.enabled) { + const uglifyjsOptions = JSON.parse(JSON.stringify(options.uglifyjs)); + delete uglifyjsOptions.enabled; + + const result = UglifyJS.minify(data, uglifyjsOptions); + + if (result.error) { + //eslint-disable-next-line no-console + console.warn(`UglifyJS skipping ${filePath}:\n ${JSON.stringify(result.error)}`); + } else { + data = result.code; + } + } + + return data; + } + + /** + * Removes a file from the local cache. + * @param {*} filePath + */ + function removeFile(filePath) { + let extension = path.extname(filePath); + + if (extension === '.js') { + return removeJavaScriptFile(filePath); + } else if (extension === '.scss') { + return removeSassFile(filePath); + } + + // Remove the changed file from the local cache + delete files[toUrlPath(filePath)]; + } + + /** + * Removes a JavaScript file from the local cache. + * @param {string} filePath + */ + function removeJavaScriptFile(filePath) { + // Remove the changed file from the local cache + delete files[toUrlPath(filePath)]; + + // Resolve the absolute file path for the changed file + const absoluteFilePath = path.resolve(filePath); + + // Find any parents that have a dependency on this file and remove them too + snockets.depGraph.parentsOf(absoluteFilePath).forEach(removeJavaScriptFile); + } + + /** + * Removes a SASS file from the local cache. + * @param {string} filePath + */ + function removeSassFile(filePath) { + const basename = path.basename(filePath, path.extname(filePath)); + const cssFilePath = path.join(path.dirname(filePath), `${basename}.css`); + const urlPath = toUrlPath(cssFilePath); + + // Remove the changed file from the local cache + delete files[urlPath]; + + // Resolve the absolute file path for the changed file + let absoluteFilePath = path.resolve(filePath); + + // Try to resolve symlinks + try { + absoluteFilePath = fs.realpathSync(filePath); + } catch (e) { + // ignore error + } + + const graph = sassGraph.parseDir(directory); + const sassFile = graph.index[absoluteFilePath]; + + if (sassFile) { + sassFile.importedBy.forEach(removeSassFile); + } + } + + function urlBuilder(urlPath) { + let file; + const request = parseUrlPath(urlPath); + let url = urlPath; + + try { + file = fetchFile(request.path); + } catch(err) { + // If we don't have a file that matches the specified URL path simply return the original URL path + return urlPath; + } + + if (options.hashify) { + url = hashifyUrl(request.path, file.hash); + } + + if (options.hostname) { + url = `https://${options.hostname}${url}`; + } + + return url; + } + + /** + * Converts a URL path (/robots.txt) to a file path (/Users/username/site/public/robots.txt). + * @param {string} urlPath + */ + function toFilePath(urlPath) { + const myURL = new URL(urlPath, 'https://example.org/'); + const pathname = myURL.pathname.replace(/^\//, ''); + return path.resolve(directory, pathname); + } + + /** + * Converts a file path (/Users/username/site/public/robots.txt) to a URL path (/robots.txt). + * @param {string} filePath + */ + function toUrlPath(filePath) { + const urlPath = path.posix.relative(directory, path.resolve(filePath)); + + return `/${urlPath}`; + } + + return { + files, + fetchFile, + removeFile, + toUrlPath, + urlBuilder + }; +} + +module.exports = { + createProcessor, + hashifyUrl, + parseUrlPath +}; diff --git a/lib/warmer.js b/lib/warmer.js new file mode 100644 index 0000000..a4b4f62 --- /dev/null +++ b/lib/warmer.js @@ -0,0 +1,88 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { parentPort, workerData } = require('node:worker_threads'); + +const { createProcessor } = require('./processor.js'); + +const { directory, options } = workerData; + +// No watcher in the worker: the watch-only side effects (registering +// discovered Sass imports / Snockets dependencies with chokidar) must stay on +// the main thread, so warming is only ever enabled when watching is off. +const processor = createProcessor(directory, options); + +/** + * Recursively yields every regular file beneath a directory. Reads are + * synchronous on purpose: this runs on a worker thread, so blocking here never + * touches the main event loop. + * @param {string} dir + */ +function* walk(dir) { + let entries; + + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + // Missing or unreadable directory: nothing to warm. + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + yield* walk(fullPath); + } else if (entry.isFile()) { + yield fullPath; + } + } +} + +/** + * Maps a file on disk to the URL path(s) that should be warmed for it. + * Sass sources are served as their compiled `.css` URL, and partials + * (`_foo.scss`) are never served on their own, so they're skipped. + * @param {string} filePath + */ +function urlPathsToWarm(filePath) { + const extension = path.extname(filePath); + + if (extension === '.scss') { + if (path.basename(filePath).startsWith('_')) { + return []; + } + + const cssFilePath = `${filePath.slice(0, -extension.length)}.css`; + + return [processor.toUrlPath(cssFilePath)]; + } + + return [processor.toUrlPath(filePath)]; +} + +const warmed = new Set(); + +for (const filePath of walk(directory)) { + for (const urlPath of urlPathsToWarm(filePath)) { + if (warmed.has(urlPath)) { + continue; + } + + warmed.add(urlPath); + + let file; + + try { + file = processor.fetchFile(urlPath); + } catch (err) { + // Couldn't process this file (e.g. it's a directory or unreadable). + // The main thread's synchronous fallback will surface any real + // error when the file is actually requested. + continue; + } + + parentPort.postMessage({ file, urlPath }); + } +} + +parentPort.close(); From 41f856c915fe98d12f63302d71de56138de1040e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:14:32 +0000 Subject: [PATCH 2/7] Share a single warm worker across all static() instances Spawning one worker per electricity.static() instance loaded the heavy transform libraries (Babel/Sass/UglifyJS) once per instance and competed for CPU. Use a single lazily-created worker that all instances submit warm jobs to, tagged by job id so each instance's cache receives its own entries. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- lib/index.js | 75 +++++++++++++++++++++++++++++++++++++++------------ lib/warmer.js | 65 ++++++++++++++++++++++---------------------- 2 files changed, 91 insertions(+), 49 deletions(-) diff --git a/lib/index.js b/lib/index.js index 2394bc8..191eedc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -54,30 +54,48 @@ function reviveFile(file) { return file; } +// A single background worker thread warms every electricity.static() instance +// in the process. It's created lazily on the first warm job and shared from +// then on, so loading the heavy transform libraries (Babel/Sass/UglifyJS) +// happens once rather than once per instance. +let warmWorker = null; +let warmWorkerFailed = false; +let nextWarmJobId = 0; + +// Maps a job id to the processor whose cache should receive that job's entries. +const warmJobs = new Map(); + /** - * Spawns a background worker thread that lazily reads and processes every file - * in the directory, posting finished cache entries back to the main thread. - * This keeps the expensive first read (Sass/Babel/UglifyJS/gzip) off the event - * loop so requests don't stall. The worker is unref'd so it never keeps the - * process alive, and any failure simply leaves the synchronous read in place. - * @param {string} directory - * @param {object} options - * @param {object} processor + * Returns the shared warm worker, creating it on first use. Returns null if the + * worker can't be created, in which case warming is skipped and the + * synchronous read remains the only path. */ -function startWarmer(directory, options, processor) { - let worker; +function getWarmWorker() { + if (warmWorker || warmWorkerFailed) { + return warmWorker; + } try { - worker = new Worker(path.join(__dirname, 'warmer.js'), { - workerData: { directory, options } - }); + warmWorker = new Worker(path.join(__dirname, 'warmer.js')); } catch (err) { + warmWorkerFailed = true; //eslint-disable-next-line no-console console.warn(`Electricity skipping cache warming:\n ${err}`); - return; + return null; } - worker.on('message', ({ file, urlPath }) => { + warmWorker.on('message', ({ done, file, jobId, urlPath }) => { + const processor = warmJobs.get(jobId); + + if (!processor) { + return; + } + + if (done) { + warmJobs.delete(jobId); + return; + } + // Don't clobber an entry a request already populated via the // synchronous fallback; both produce identical content. if (!processor.files[urlPath]) { @@ -85,13 +103,36 @@ function startWarmer(directory, options, processor) { } }); - worker.on('error', (err) => { + warmWorker.on('error', (err) => { //eslint-disable-next-line no-console console.warn(`Electricity cache warming error:\n ${err}`); }); // Never let warming hold the process open. - worker.unref(); + warmWorker.unref(); + + return warmWorker; +} + +/** + * Queues a warm job on the shared worker thread. The worker lazily reads and + * processes every file in the directory, posting finished cache entries back so + * the expensive first read (Sass/Babel/UglifyJS/gzip) stays off the event loop. + * @param {string} directory + * @param {object} options + * @param {object} processor + */ +function startWarmer(directory, options, processor) { + const worker = getWarmWorker(); + + if (!worker) { + return; + } + + const jobId = nextWarmJobId++; + + warmJobs.set(jobId, processor); + worker.postMessage({ directory, jobId, options }); } exports.static = (directory, options) => { diff --git a/lib/warmer.js b/lib/warmer.js index a4b4f62..e884ad8 100644 --- a/lib/warmer.js +++ b/lib/warmer.js @@ -1,16 +1,9 @@ const fs = require('node:fs'); const path = require('node:path'); -const { parentPort, workerData } = require('node:worker_threads'); +const { parentPort } = require('node:worker_threads'); const { createProcessor } = require('./processor.js'); -const { directory, options } = workerData; - -// No watcher in the worker: the watch-only side effects (registering -// discovered Sass imports / Snockets dependencies with chokidar) must stay on -// the main thread, so warming is only ever enabled when watching is off. -const processor = createProcessor(directory, options); - /** * Recursively yields every regular file beneath a directory. Reads are * synchronous on purpose: this runs on a worker thread, so blocking here never @@ -42,9 +35,10 @@ function* walk(dir) { * Maps a file on disk to the URL path(s) that should be warmed for it. * Sass sources are served as their compiled `.css` URL, and partials * (`_foo.scss`) are never served on their own, so they're skipped. + * @param {object} processor * @param {string} filePath */ -function urlPathsToWarm(filePath) { +function urlPathsToWarm(processor, filePath) { const extension = path.extname(filePath); if (extension === '.scss') { @@ -60,29 +54,36 @@ function urlPathsToWarm(filePath) { return [processor.toUrlPath(filePath)]; } -const warmed = new Set(); - -for (const filePath of walk(directory)) { - for (const urlPath of urlPathsToWarm(filePath)) { - if (warmed.has(urlPath)) { - continue; +// A single shared worker handles every warm job in the process. The main +// thread posts one job per electricity.static() instance; each job gets its +// own processor (cache + Snockets graph) so jobs never share state, and the +// finished entries are posted back tagged with the job id. +parentPort.on('message', ({ directory, jobId, options }) => { + const processor = createProcessor(directory, options); + const warmed = new Set(); + + for (const filePath of walk(directory)) { + for (const urlPath of urlPathsToWarm(processor, filePath)) { + if (warmed.has(urlPath)) { + continue; + } + + warmed.add(urlPath); + + let file; + + try { + file = processor.fetchFile(urlPath); + } catch (err) { + // Couldn't process this file (e.g. it's a directory or + // unreadable). The main thread's synchronous fallback will + // surface any real error when the file is actually requested. + continue; + } + + parentPort.postMessage({ file, jobId, urlPath }); } - - warmed.add(urlPath); - - let file; - - try { - file = processor.fetchFile(urlPath); - } catch (err) { - // Couldn't process this file (e.g. it's a directory or unreadable). - // The main thread's synchronous fallback will surface any real - // error when the file is actually requested. - continue; - } - - parentPort.postMessage({ file, urlPath }); } -} -parentPort.close(); + parentPort.postMessage({ done: true, jobId }); +}); From aab0882e8240afd413a27ac12f4885707da49e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:20:16 +0000 Subject: [PATCH 3/7] Warm the cache with a sharded worker pool Replace the single warm worker with a shared pool sized to max(1, cpus - 1), leaving a core free for the event loop. Each warm job is fanned out across the pool, with every worker processing a disjoint, deterministic shard of the directory's files so a single directory warms in parallel. Each worker keeps its own processor, so a CSS file's url() dependencies still resolve within that worker even when the referenced asset lives in another shard; deterministic hashing keeps every entry byte-identical to a synchronous read. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- lib/index.js | 121 ++++++++++++++++++++++++++++++-------------------- lib/warmer.js | 23 +++++++--- 2 files changed, 89 insertions(+), 55 deletions(-) diff --git a/lib/index.js b/lib/index.js index 191eedc..f91f7f8 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,3 +1,4 @@ +const os = require('node:os'); const path = require('node:path'); const { Worker } = require('node:worker_threads'); @@ -54,85 +55,109 @@ function reviveFile(file) { return file; } -// A single background worker thread warms every electricity.static() instance -// in the process. It's created lazily on the first warm job and shared from -// then on, so loading the heavy transform libraries (Babel/Sass/UglifyJS) -// happens once rather than once per instance. -let warmWorker = null; -let warmWorkerFailed = false; +// A shared pool of worker threads warms every electricity.static() instance in +// the process. The pool is created lazily on the first warm job and reused from +// then on, so the heavy transform libraries (Babel/Sass/UglifyJS) load once per +// worker rather than once per instance. It's sized to leave a core free for the +// event loop that serves live traffic. +const WARM_POOL_SIZE = Math.max(1, os.cpus().length - 1); + +let warmPool = null; +let warmPoolFailed = false; let nextWarmJobId = 0; -// Maps a job id to the processor whose cache should receive that job's entries. +// Maps a job id to the processor receiving that job's entries and the number of +// shards still working on it. const warmJobs = new Map(); +function handleWarmMessage({ done, file, jobId, urlPath }) { + const job = warmJobs.get(jobId); + + if (!job) { + return; + } + + if (done) { + job.pending--; + + if (job.pending === 0) { + warmJobs.delete(jobId); + } + + return; + } + + // Don't clobber an entry a request already populated via the synchronous + // fallback, or one another shard already posted; all produce identical + // content. + if (!job.processor.files[urlPath]) { + job.processor.files[urlPath] = reviveFile(file); + } +} + /** - * Returns the shared warm worker, creating it on first use. Returns null if the - * worker can't be created, in which case warming is skipped and the + * Returns the shared warm worker pool, creating it on first use. Returns null + * if the pool can't be created, in which case warming is skipped and the * synchronous read remains the only path. */ -function getWarmWorker() { - if (warmWorker || warmWorkerFailed) { - return warmWorker; +function getWarmPool() { + if (warmPool || warmPoolFailed) { + return warmPool; } - try { - warmWorker = new Worker(path.join(__dirname, 'warmer.js')); - } catch (err) { - warmWorkerFailed = true; - //eslint-disable-next-line no-console - console.warn(`Electricity skipping cache warming:\n ${err}`); - return null; - } + const pool = []; - warmWorker.on('message', ({ done, file, jobId, urlPath }) => { - const processor = warmJobs.get(jobId); + try { + for (let i = 0; i < WARM_POOL_SIZE; i++) { + const worker = new Worker(path.join(__dirname, 'warmer.js')); - if (!processor) { - return; - } + worker.on('message', handleWarmMessage); + worker.on('error', (err) => { + //eslint-disable-next-line no-console + console.warn(`Electricity cache warming error:\n ${err}`); + }); - if (done) { - warmJobs.delete(jobId); - return; - } + // Never let warming hold the process open. + worker.unref(); - // Don't clobber an entry a request already populated via the - // synchronous fallback; both produce identical content. - if (!processor.files[urlPath]) { - processor.files[urlPath] = reviveFile(file); + pool.push(worker); } - }); - - warmWorker.on('error', (err) => { + } catch (err) { + warmPoolFailed = true; + pool.forEach(worker => worker.terminate()); //eslint-disable-next-line no-console - console.warn(`Electricity cache warming error:\n ${err}`); - }); + console.warn(`Electricity skipping cache warming:\n ${err}`); + return null; + } - // Never let warming hold the process open. - warmWorker.unref(); + warmPool = pool; - return warmWorker; + return warmPool; } /** - * Queues a warm job on the shared worker thread. The worker lazily reads and - * processes every file in the directory, posting finished cache entries back so - * the expensive first read (Sass/Babel/UglifyJS/gzip) stays off the event loop. + * Queues a warm job across the worker pool. Each worker reads and processes a + * disjoint shard of the directory's files, posting finished cache entries back + * so the expensive first read (Sass/Babel/UglifyJS/gzip) stays off the event + * loop. * @param {string} directory * @param {object} options * @param {object} processor */ function startWarmer(directory, options, processor) { - const worker = getWarmWorker(); + const pool = getWarmPool(); - if (!worker) { + if (!pool) { return; } const jobId = nextWarmJobId++; - warmJobs.set(jobId, processor); - worker.postMessage({ directory, jobId, options }); + warmJobs.set(jobId, { pending: pool.length, processor }); + + pool.forEach((worker, shardIndex) => { + worker.postMessage({ directory, jobId, options, shardCount: pool.length, shardIndex }); + }); } exports.static = (directory, options) => { diff --git a/lib/warmer.js b/lib/warmer.js index e884ad8..73f95d3 100644 --- a/lib/warmer.js +++ b/lib/warmer.js @@ -54,16 +54,25 @@ function urlPathsToWarm(processor, filePath) { return [processor.toUrlPath(filePath)]; } -// A single shared worker handles every warm job in the process. The main -// thread posts one job per electricity.static() instance; each job gets its -// own processor (cache + Snockets graph) so jobs never share state, and the -// finished entries are posted back tagged with the job id. -parentPort.on('message', ({ directory, jobId, options }) => { +// A shared pool of workers warms every electricity.static() instance in the +// process. Each warm job is fanned out to the whole pool, with each worker +// handling a disjoint shard of the directory's files (every shardCount-th file +// starting at shardIndex). Files are sorted so the shards are deterministic and +// cover the tree exactly once. Each worker creates its own processor, so a CSS +// file's url() dependencies resolve within that worker even when the referenced +// asset belongs to another shard; the entries stay byte-identical to a +// synchronous read because hashing is deterministic. +parentPort.on('message', ({ directory, jobId, options, shardCount, shardIndex }) => { const processor = createProcessor(directory, options); + const filePaths = [...walk(directory)].sort(); const warmed = new Set(); - for (const filePath of walk(directory)) { - for (const urlPath of urlPathsToWarm(processor, filePath)) { + for (let i = 0; i < filePaths.length; i++) { + if (i % shardCount !== shardIndex) { + continue; + } + + for (const urlPath of urlPathsToWarm(processor, filePaths[i])) { if (warmed.has(urlPath)) { continue; } From 6123f81f66d322fcc46776f5c078ea3e18f39515 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:30:06 +0000 Subject: [PATCH 4/7] Size the warm pool with os.availableParallelism() Use os.availableParallelism() instead of os.cpus().length so the pool respects the process's CPU affinity mask (e.g. cpuset pinning) rather than always counting every host core. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- lib/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index f91f7f8..13d6799 100644 --- a/lib/index.js +++ b/lib/index.js @@ -60,7 +60,7 @@ function reviveFile(file) { // then on, so the heavy transform libraries (Babel/Sass/UglifyJS) load once per // worker rather than once per instance. It's sized to leave a core free for the // event loop that serves live traffic. -const WARM_POOL_SIZE = Math.max(1, os.cpus().length - 1); +const WARM_POOL_SIZE = Math.max(1, os.availableParallelism() - 1); let warmPool = null; let warmPoolFailed = false; From e8180fb68880524f120ddfbb6e3a17f3350ed052 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:49:36 +0000 Subject: [PATCH 5/7] Guard non-cloneable warm options with try/catch Replace the bespoke hasFunction scan with a try/catch around the worker postMessage. Options that can't be structured-cloned (e.g. a Sass importer or a Babel plugin passed as a function) now skip warming for that instance via the caught DataCloneError, which is simpler and covers any non-cloneable value rather than only functions. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- lib/index.js | 50 +++++++++++++------------------------------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/lib/index.js b/lib/index.js index 13d6799..a2c2b42 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,36 +7,6 @@ const Negotiator = require('negotiator'); const { createProcessor, hashifyUrl, parseUrlPath } = require('./processor.js'); -/** - * Deeply checks whether a value contains any functions, which can't be - * structured-cloned to a worker thread. When options carry functions (e.g. a - * Babel plugin or Sass importer), the worker can't reproduce the main thread's - * output, so background warming is disabled to avoid caching divergent results. - * @param {*} value - * @param {Set} [seen] - */ -function hasFunction(value, seen = new Set()) { - if (typeof value === 'function') { - return true; - } - - if (value && typeof value === 'object') { - if (seen.has(value)) { - return false; - } - - seen.add(value); - - for (const key of Object.keys(value)) { - if (hasFunction(value[key], seen)) { - return true; - } - } - } - - return false; -} - /** * Restores Buffers lost to structured cloning. Binary content posted from the * worker arrives as a Uint8Array; wrap it back into a Buffer so it behaves like @@ -155,9 +125,16 @@ function startWarmer(directory, options, processor) { warmJobs.set(jobId, { pending: pool.length, processor }); - pool.forEach((worker, shardIndex) => { - worker.postMessage({ directory, jobId, options, shardCount: pool.length, shardIndex }); - }); + try { + pool.forEach((worker, shardIndex) => { + worker.postMessage({ directory, jobId, options, shardCount: pool.length, shardIndex }); + }); + } catch (err) { + // options aren't structured-cloneable (e.g. a Sass importer or a Babel + // plugin passed as a function); leave warming off for this instance and + // rely on the synchronous read. + warmJobs.delete(jobId); + } } exports.static = (directory, options) => { @@ -235,10 +212,9 @@ exports.static = (directory, options) => { // Warm the cache on a background thread so the first request for a file // doesn't read and process it synchronously on the event loop. Watching - // keeps processing on the main thread (it registers discovered - // dependencies with the watcher), and options carrying functions can't be - // cloned into the worker, so warming is skipped in those cases. - if (!options.watch.enabled && !hasFunction(options)) { + // keeps processing on the main thread (it registers discovered dependencies + // with the watcher), so warming is skipped while watching. + if (!options.watch.enabled) { startWarmer(directory, options, processor); } From 048973d7ef34dfa2008df99eea2c02bdb57dfadd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 16:39:36 +0000 Subject: [PATCH 6/7] Use exports.X convention in processor.js Match the org convention used elsewhere (e.g. exports.static in index.js): attach public functions to exports directly instead of a trailing module.exports block. Internal references go through exports.X since named function expressions aren't bound in module scope. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- lib/processor.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/lib/processor.js b/lib/processor.js index 93f6f69..380b4aa 100644 --- a/lib/processor.js +++ b/lib/processor.js @@ -18,20 +18,20 @@ const gzipContentTypes = require('./gzipContentTypes.js'); * @param {string} url * @param {string} hash */ -function hashifyUrl(url, hash) { +exports.hashifyUrl = function hashifyUrl(url, hash) { if (!url.includes('.')) { return url.replace(/([?#].*)?$/, `-${hash}$1`); } return url.replace(/\.([^.]*)([?#].*)?$/, `-${hash}.$1$2`); -} +}; /** * Parses a URL path potentially containing a hash (/robots-3f54004ef6fc21b24a9e6069fc114fd9070b77a1.txt) * into an object with a hash and path properties ({ hash: '3f54004ef6fc21b24a9e6069fc114fd9070b77a1', path: '/robots.txt' }) * @param {string} urlPath */ -function parseUrlPath(urlPath) { +exports.parseUrlPath = function parseUrlPath(urlPath) { // https://regex101.com/r/j5hvRj/2 const regex = /\/.+(-([0-9a-f]{32,40}))/; const matches = urlPath.match(regex); @@ -46,7 +46,7 @@ function parseUrlPath(urlPath) { hash: matches[2], path: urlPath.replace(matches[1], '') }; -} +}; /** * Creates a file processor bound to a directory and a set of options. @@ -59,7 +59,7 @@ function parseUrlPath(urlPath) { * @param {object} options * @param {object} [watcher] */ -function createProcessor(directory, options, watcher) { +exports.createProcessor = function createProcessor(directory, options, watcher) { // Create a local cache to hold the files const files = {}; @@ -291,7 +291,7 @@ function createProcessor(directory, options, watcher) { function urlBuilder(urlPath) { let file; - const request = parseUrlPath(urlPath); + const request = exports.parseUrlPath(urlPath); let url = urlPath; try { @@ -302,7 +302,7 @@ function createProcessor(directory, options, watcher) { } if (options.hashify) { - url = hashifyUrl(request.path, file.hash); + url = exports.hashifyUrl(request.path, file.hash); } if (options.hostname) { @@ -339,10 +339,4 @@ function createProcessor(directory, options, watcher) { toUrlPath, urlBuilder }; -} - -module.exports = { - createProcessor, - hashifyUrl, - parseUrlPath }; From de81857e5e56cf9fe4c6f47dc899ef0e15ae35a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 23:15:20 +0000 Subject: [PATCH 7/7] Test that non-cloneable options skip warming without throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the startWarmer DataCloneError path: passing a Sass importer (a function) must not crash static() — warming is skipped for that instance and the synchronous read still serves the file. https://claude.ai/code/session_013nWBHBH1uYqZ35q6TTks99 --- test/index.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/index.js b/test/index.js index 6880000..812179b 100644 --- a/test/index.js +++ b/test/index.js @@ -84,6 +84,34 @@ test('electricity.static', { concurrency: true }, async (t) => { }); }); + t.test('should not throw when options can not be cloned to the warm worker', async () => { + // A Sass importer is a function, which can't be structured-cloned to + // the background warm worker. static() must not throw; warming is + // simply skipped for this instance and the synchronous read still + // serves the file. + await new Promise((resolve) => { + const middleware = electricity.static('test/public', { + sass: { importers: [() => null] } + }); + + const req = { + get: () => {}, + method: 'GET', + path: '/robots.txt' + }; + + const res = { + redirect: (path) => { + assert.strictEqual(path, '/robots-423251d722a53966eb9368c65bfd14b39649105d.txt'); + resolve(); + }, + set: () => {} + }; + + middleware(req, res); + }); + }); + t.test('babel', async (t) => { t.test('preset-react', { concurrency: true }, async (t) => { t.test('should transform JSX files', async () => {