diff --git a/config/helpers/incremental_webpack_compiler.js b/config/helpers/incremental_webpack_compiler.js deleted file mode 100644 index 5d4f9bd040d164fe76a47bafd3d701cf546840ac..0000000000000000000000000000000000000000 --- a/config/helpers/incremental_webpack_compiler.js +++ /dev/null @@ -1,131 +0,0 @@ -/* eslint-disable max-classes-per-file, no-underscore-dangle */ -const fs = require('fs'); -const path = require('path'); - -const log = (msg, ...rest) => console.log(`IncrementalWebpackCompiler: ${msg}`, ...rest); - -// If we force a recompile immediately, the page reload doesn't seem to work. -// Five seconds seem to work fine and the user can read the message -const TIMEOUT = 5000; - -/* eslint-disable class-methods-use-this */ -class NoopCompiler { - constructor() { - this.enabled = false; - } - - filterEntryPoints(entryPoints) { - return entryPoints; - } - - logStatus() {} - - setupMiddleware() {} -} -/* eslint-enable class-methods-use-this */ - -class IncrementalWebpackCompiler { - constructor(historyFilePath) { - this.enabled = true; - this.history = {}; - this.compiledEntryPoints = new Set([ - // Login page - 'pages.sessions.new', - // Explore page - 'pages.root', - ]); - this.historyFilePath = historyFilePath; - this._loadFromHistory(); - } - - filterEntryPoints(entrypoints) { - return Object.fromEntries( - Object.entries(entrypoints).map(([key, val]) => { - if (this.compiledEntryPoints.has(key)) { - return [key, val]; - } - return [key, ['./webpack_non_compiled_placeholder.js']]; - }), - ); - } - - logStatus(totalCount) { - const current = this.compiledEntryPoints.size; - log(`Currently compiling route entrypoints: ${current} of ${totalCount}`); - } - - setupMiddleware(app, server) { - app.use((req, res, next) => { - const fileName = path.basename(req.url); - - /** - * We are only interested in files that have a name like `pages.foo.bar.chunk.js` - * because those are the ones corresponding to our entry points. - * - * This filters out hot update files that are for example named "pages.foo.bar.[hash].hot-update.js" - */ - if (fileName.startsWith('pages.') && fileName.endsWith('.chunk.js')) { - const chunk = fileName.replace(/\.chunk\.js$/, ''); - - this._addToHistory(chunk); - - if (!this.compiledEntryPoints.has(chunk)) { - log(`First time we are seeing ${chunk}. Adding to compilation.`); - - this.compiledEntryPoints.add(chunk); - - setTimeout(() => { - server.middleware.invalidate(() => { - if (server.sockets) { - server.sockWrite(server.sockets, 'content-changed'); - } - }); - }, TIMEOUT); - } - } - - next(); - }); - } - - // private methods - - _addToHistory(chunk) { - if (!this.history[chunk]) { - this.history[chunk] = { lastVisit: null, count: 0 }; - } - this.history[chunk].lastVisit = Date.now(); - this.history[chunk].count += 1; - - try { - fs.writeFileSync(this.historyFilePath, JSON.stringify(this.history), 'utf8'); - } catch (e) { - log('Warning – Could not write to history', e.message); - } - } - - _loadFromHistory() { - try { - this.history = JSON.parse(fs.readFileSync(this.historyFilePath, 'utf8')); - const entryPoints = Object.keys(this.history); - log(`Successfully loaded history containing ${entryPoints.length} entry points`); - /* - TODO: Let's ask a few folks to give us their history file after a milestone of usage - Then we can make smarter decisions on when to throw out rather than rendering everything - Something like top 20/30/40 entries visited in the last 7/10/15 days might be sufficient - */ - this.compiledEntryPoints = new Set([...this.compiledEntryPoints, ...entryPoints]); - } catch (e) { - log(`No history found...`); - } - } -} - -module.exports = (enabled, historyFilePath) => { - log(`Status – ${enabled ? 'enabled' : 'disabled'}`); - - if (enabled) { - return new IncrementalWebpackCompiler(historyFilePath); - } - return new NoopCompiler(); -}; diff --git a/config/helpers/incremental_webpack_compiler/compiler.js b/config/helpers/incremental_webpack_compiler/compiler.js new file mode 100644 index 0000000000000000000000000000000000000000..480d7fa3263fa511cf40493b61d572e6f04bb11a --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/compiler.js @@ -0,0 +1,117 @@ +/* eslint-disable max-classes-per-file */ + +const path = require('path'); +const { History, HistoryWithTTL } = require('./history'); +const log = require('./log'); + +const onRequestEntryPoint = (app, callback) => { + app.use((req, res, next) => { + const fileName = path.basename(req.url); + + /** + * We are only interested in files that have a name like `pages.foo.bar.chunk.js` + * because those are the ones corresponding to our entry points. + * + * This filters out hot update files that are for example named "pages.foo.bar.[hash].hot-update.js" + */ + if (fileName.startsWith('pages.') && fileName.endsWith('.chunk.js')) { + const entryPoint = fileName.replace(/\.chunk\.js$/, ''); + callback(entryPoint); + } + + next(); + }); +}; + +/** + * The NoopCompiler does nothing, following the null object pattern. + */ +class NoopCompiler { + constructor() { + this.enabled = false; + } + + // eslint-disable-next-line class-methods-use-this + filterEntryPoints(entryPoints) { + return entryPoints; + } + + // eslint-disable-next-line class-methods-use-this + logStatus() {} + + // eslint-disable-next-line class-methods-use-this + setupMiddleware() {} +} + +/** + * The HistoryOnlyCompiler only records which entry points have been requested. + * This is so that if the user disables incremental compilation, history is + * still recorded. If they later enable incremental compilation, that history + * can be used. + */ +class HistoryOnlyCompiler extends NoopCompiler { + constructor(historyFilePath) { + super(); + this.history = new History(historyFilePath); + } + + setupMiddleware(app) { + onRequestEntryPoint(app, (entryPoint) => { + this.history.onRequestEntryPoint(entryPoint); + }); + } +} + +// If we force a recompile immediately, the page reload doesn't seem to work. +// Five seconds seem to work fine and the user can read the message +const TIMEOUT = 5000; + +/** + * The IncrementalWebpackCompiler tracks which entry points have been + * requested, and only compiles entry points visited within the last `ttl` + * days. + */ +class IncrementalWebpackCompiler { + constructor(historyFilePath, ttl) { + this.enabled = true; + this.history = new HistoryWithTTL(historyFilePath, ttl); + } + + filterEntryPoints(entrypoints) { + return Object.fromEntries( + Object.entries(entrypoints).map(([entryPoint, paths]) => { + if (this.history.isRecentlyVisited(entryPoint)) { + return [entryPoint, paths]; + } + return [entryPoint, ['./webpack_non_compiled_placeholder.js']]; + }), + ); + } + + logStatus(totalCount) { + log(`Currently compiling route entrypoints: ${this.history.size} of ${totalCount}`); + } + + setupMiddleware(app, server) { + onRequestEntryPoint(app, (entryPoint) => { + const wasVisitedRecently = this.history.onRequestEntryPoint(entryPoint); + if (!wasVisitedRecently) { + log(`Have not visited ${entryPoint} recently. Adding to compilation.`); + + setTimeout(() => { + server.middleware.invalidate(() => { + if (server.sockets) { + server.sockWrite(server.sockets, 'content-changed'); + } + }); + }, TIMEOUT); + } + }); + } +} + +module.exports = { + NoopCompiler, + HistoryOnlyCompiler, + IncrementalWebpackCompiler, +}; diff --git a/config/helpers/incremental_webpack_compiler/history.js b/config/helpers/incremental_webpack_compiler/history.js new file mode 100644 index 0000000000000000000000000000000000000000..5ff2a5292a39ab77b0f3594a3ed279f7fb00367b --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/history.js @@ -0,0 +1,125 @@ +/* eslint-disable max-classes-per-file, no-underscore-dangle */ + +const fs = require('fs'); +const log = require('./log'); + +/** + * The History class is responsible for tracking which entry points have been + * requested, and persisting/loading the history to/from disk. + */ +class History { + constructor(historyFilePath) { + this._historyFilePath = historyFilePath; + this._history = this._loadHistoryFile(); + } + + onRequestEntryPoint(entryPoint) { + const wasVisitedRecently = this.isRecentlyVisited(entryPoint); + + if (!this._history[entryPoint]) { + this._history[entryPoint] = { lastVisit: null, count: 0 }; + } + + this._history[entryPoint].lastVisit = Date.now(); + this._history[entryPoint].count += 1; + + this._writeHistoryFile(); + + return wasVisitedRecently; + } + + // eslint-disable-next-line class-methods-use-this + isRecentlyVisited() { + return true; + } + + // eslint-disable-next-line class-methods-use-this + get size() { + return 0; + } + + // Private methods + + _writeHistoryFile() { + try { + fs.writeFileSync(this._historyFilePath, JSON.stringify(this._history), 'utf8'); + } catch (e) { + log('Warning – Could not write to history', e.message); + } + } + + _loadHistoryFile() { + let history = {}; + + try { + history = JSON.parse(fs.readFileSync(this._historyFilePath, 'utf8')); + const historySize = Object.keys(history).length; + log(`Successfully loaded history containing ${historySize} entry points`); + } catch (error) { + log(`Could not load history: ${error}`); + } + + return history; + } +} + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +/** + * The HistoryWithTTL class adds LRU-like behaviour onto the base History + * behaviour. Entry points visited within the last `ttl` days are considered + * "recent", and therefore should be eagerly compiled. + */ +class HistoryWithTTL extends History { + constructor(historyFilePath, ttl) { + super(historyFilePath); + this._ttl = ttl; + this._calculateRecentEntryPoints(); + } + + onRequestEntryPoint(entryPoint) { + const wasVisitedRecently = super.onRequestEntryPoint(entryPoint); + + this._calculateRecentEntryPoints(); + + return wasVisitedRecently; + } + + isRecentlyVisited(entryPoint) { + return this._recentEntryPoints.has(entryPoint); + } + + get size() { + return this._recentEntryPoints.size; + } + + // Private methods + + _calculateRecentEntryPoints() { + const oldestVisitAllowed = Date.now() - MS_PER_DAY * this._ttl; + + const recentEntryPoints = Object.entries(this._history).reduce( + (acc, [entryPoint, { lastVisit }]) => { + if (lastVisit > oldestVisitAllowed) { + acc.push(entryPoint); + } + + return acc; + }, + [], + ); + + this._recentEntryPoints = new Set([ + // Login page + 'pages.sessions.new', + // Explore page + 'pages.root', + ...recentEntryPoints, + ]); + } +} + +module.exports = { + History, + HistoryWithTTL, +}; diff --git a/config/helpers/incremental_webpack_compiler/index.js b/config/helpers/incremental_webpack_compiler/index.js new file mode 100644 index 0000000000000000000000000000000000000000..818266074904ccc725f46a60b7c0d1fa4def6e21 --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/index.js @@ -0,0 +1,17 @@ +const { NoopCompiler, HistoryOnlyCompiler, IncrementalWebpackCompiler } = require('./compiler'); +const log = require('./log'); + +module.exports = (recordHistory, enabled, historyFilePath, ttl) => { + if (!recordHistory) { + log(`Status – disabled`); + return new NoopCompiler(); + } + + if (enabled) { + log(`Status – enabled, ttl=${ttl}`); + return new IncrementalWebpackCompiler(historyFilePath, ttl); + } + + log(`Status – history-only`); + return new HistoryOnlyCompiler(historyFilePath); +}; diff --git a/config/helpers/incremental_webpack_compiler/log.js b/config/helpers/incremental_webpack_compiler/log.js new file mode 100644 index 0000000000000000000000000000000000000000..6336cb0a78b22696a486c5f1148df979fef51930 --- /dev/null +++ b/config/helpers/incremental_webpack_compiler/log.js @@ -0,0 +1,3 @@ +const log = (msg, ...rest) => console.log(`IncrementalWebpackCompiler: ${msg}`, ...rest); + +module.exports = log; diff --git a/config/webpack.config.js b/config/webpack.config.js index b81b56110418f3fb2984507b7d7cbbf6e014f337..b5c508b30944961cbc288a0c1e476bb1872fc871 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -48,6 +48,8 @@ const INCREMENTAL_COMPILER_ENABLED = IS_DEV_SERVER && process.env.DEV_SERVER_INCREMENTAL && process.env.DEV_SERVER_INCREMENTAL !== 'false'; +const INCREMENTAL_COMPILER_TTL = Number(process.env.DEV_SERVER_INCREMENTAL_TTL) || Infinity; +const INCREMENTAL_COMPILER_RECORD_HISTORY = IS_DEV_SERVER && !process.env.CI; const WEBPACK_REPORT = process.env.WEBPACK_REPORT && process.env.WEBPACK_REPORT !== 'false'; const WEBPACK_MEMORY_TEST = process.env.WEBPACK_MEMORY_TEST && process.env.WEBPACK_MEMORY_TEST !== 'false'; @@ -69,8 +71,10 @@ let watchAutoEntries = []; const defaultEntries = ['./main']; const incrementalCompiler = createIncrementalWebpackCompiler( + INCREMENTAL_COMPILER_RECORD_HISTORY, INCREMENTAL_COMPILER_ENABLED, path.join(CACHE_PATH, 'incremental-webpack-compiler-history.json'), + INCREMENTAL_COMPILER_TTL, ); function generateEntries() {