Implement hot reloading for the locale files during development (#5050)

* Stop using deprecated additionalAssets hook

* Watch locale files without webpack-watch-external-files-plugin

* Use Maps instead of objects

* Use webpack's file timestamps instead of checking the files ourselves

* Add hot reloading

* Inject hot reload code snippet
This commit is contained in:
absidue 2024-05-14 18:35:50 +02:00 committed by GitHub
parent 2fcd5c985e
commit 61820b1bcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 138 additions and 94 deletions

View File

@ -1,14 +1,18 @@
const { existsSync, readFileSync, statSync } = require('fs')
const { existsSync, readFileSync } = require('fs')
const { readFile } = require('fs/promises')
const { join } = require('path')
const { brotliCompress, constants } = require('zlib')
const { promisify } = require('util')
const { load: loadYaml } = require('js-yaml')
const brotliCompressAsync = promisify(brotliCompress)
const PLUGIN_NAME = 'ProcessLocalesPlugin'
class ProcessLocalesPlugin {
constructor(options = {}) {
this.compress = !!options.compress
this.isIncrementalBuild = false
this.hotReload = !!options.hotReload
if (typeof options.inputDir !== 'string') {
throw new Error('ProcessLocalesPlugin: no input directory `inputDir` specified.')
@ -22,49 +26,68 @@ class ProcessLocalesPlugin {
}
this.outputDir = options.outputDir
this.locales = {}
/** @type {Map<str, any>} */
this.locales = new Map()
this.localeNames = []
this.activeLocales = []
this.cache = {}
/** @type {Map<str, any>} */
this.cache = new Map()
this.filePaths = []
this.previousTimestamps = new Map()
this.startTime = Date.now()
/** @type {(updatedLocales: [string, string][]) => void|null} */
this.notifyLocaleChange = null
if (this.hotReload) {
this.hotReloadScript = readFileSync(`${__dirname}/_hotReloadLocalesScript.js`, 'utf-8')
}
this.loadLocales()
}
/** @param {import('webpack').Compiler} compiler */
apply(compiler) {
compiler.hooks.thisCompilation.tap('ProcessLocalesPlugin', (compilation) => {
const { CachedSource, RawSource } = compiler.webpack.sources;
const { Compilation } = compiler.webpack
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
const IS_DEV_SERVER = !!compiler.watching
const { CachedSource, RawSource } = compiler.webpack.sources;
compilation.hooks.additionalAssets.tapPromise('process-locales-plugin', async (_assets) => {
compilation.hooks.processAssets.tapPromise({
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
}, async (_assets) => {
// While running in the webpack dev server, this hook gets called for every incremental build.
// For incremental builds we can return the already processed versions, which saves time
// and makes webpack treat them as cached
const promises = []
// Prevents `loadLocales` called twice on first time (e.g. release build)
if (this.isIncrementalBuild) {
this.loadLocales(true)
} else {
this.isIncrementalBuild = true
/** @type {[string, string][]} */
const updatedLocales = []
if (this.hotReload && !this.notifyLocaleChange) {
console.warn('ProcessLocalesPlugin: Unable to live reload locales as `notifyLocaleChange` is not set.')
}
Object.values(this.locales).forEach((localeEntry) => {
const { locale, data, mtimeMs } = localeEntry
for (let [locale, data] of this.locales) {
promises.push(new Promise(async (resolve) => {
if (IS_DEV_SERVER) {
const cacheEntry = this.cache[locale]
if (IS_DEV_SERVER && compiler.fileTimestamps) {
const filePath = join(this.inputDir, `${locale}.yaml`)
if (cacheEntry != null) {
const { filename, source, mtimeMs: cachedMtimeMs } = cacheEntry
const timestamp = compiler.fileTimestamps.get(filePath)?.safeTime
if (cachedMtimeMs === mtimeMs) {
compilation.emitAsset(filename, source, { minimized: true })
resolve()
return
}
if (timestamp && timestamp > (this.previousTimestamps.get(locale) ?? this.startTime)) {
this.previousTimestamps.set(locale, timestamp)
const contents = await readFile(filePath, 'utf-8')
data = loadYaml(contents)
} else {
const { filename, source } = this.cache.get(locale)
compilation.emitAsset(filename, source, { minimized: true })
resolve()
return
}
}
@ -73,6 +96,10 @@ class ProcessLocalesPlugin {
let filename = `${this.outputDir}/${locale}.json`
let output = JSON.stringify(data)
if (this.hotReload && compiler.fileTimestamps) {
updatedLocales.push([locale, output])
}
if (this.compress) {
filename += '.br'
output = await this.compressLocale(output)
@ -82,51 +109,61 @@ class ProcessLocalesPlugin {
if (IS_DEV_SERVER) {
source = new CachedSource(source)
this.cache[locale] = { filename, source, mtimeMs }
this.cache.set(locale, { filename, source })
// we don't need the unmodified sources anymore, as we use the cache `this.cache`
// so we can clear this to free some memory
this.locales.set(locale, null)
}
compilation.emitAsset(filename, source, { minimized: true })
resolve()
}))
if (IS_DEV_SERVER) {
// we don't need the unmodified sources anymore, as we use the cache `this.cache`
// so we can clear this to free some memory
delete localeEntry.data
}
})
}
await Promise.all(promises)
if (this.hotReload && this.notifyLocaleChange && updatedLocales.length > 0) {
this.notifyLocaleChange(updatedLocales)
}
})
})
compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
if (!!compiler.watching) {
// watch locale files for changes
compilation.fileDependencies.addAll(this.filePaths)
}
})
compiler.hooks.emit.tap(PLUGIN_NAME, (compilation) => {
if (this.hotReload) {
// Find generated JavaScript output file (e.g. renderer.js or web.js)
// and inject the code snippet that listens for locale updates and replaces vue-i18n's locales
/** @type {string} */
const filename = [...[...compilation.chunks][0].files]
.find(file => file.endsWith('.js'))
compilation.assets[filename]._source._children.push(`\n${this.hotReloadScript}`)
}
})
}
loadLocales(loadModifiedFilesOnly = false) {
if (this.activeLocales.length === 0) {
this.activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`))
}
loadLocales() {
const activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`))
for (const locale of activeLocales) {
const filePath = join(this.inputDir, `${locale}.yaml`)
this.filePaths.push(filePath)
for (const locale of this.activeLocales) {
const filePath = `${this.inputDir}/${locale}.yaml`
// Cannot use `mtime` since values never equal
const mtimeMsFromStats = statSync(filePath).mtimeMs
if (loadModifiedFilesOnly) {
// Skip reading files where mtime (modified time) same as last read
// (stored in mtime)
const existingMtime = this.locales[locale]?.mtimeMs
if (existingMtime != null && existingMtime === mtimeMsFromStats) {
continue
}
}
const contents = readFileSync(filePath, 'utf-8')
const data = loadYaml(contents)
this.locales[locale] = { locale, data, mtimeMs: mtimeMsFromStats }
this.locales.set(locale, data)
const localeName = data['Locale Name'] ?? locale
if (!loadModifiedFilesOnly) {
this.localeNames.push(localeName)
}
this.localeNames.push(data['Locale Name'] ?? locale)
}
}

View File

@ -0,0 +1,18 @@
const websocket = new WebSocket('ws://localhost:9080/ws')
websocket.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'freetube-locale-update') {
const i18n = document.getElementById('app').__vue__.$i18n
for (const [locale, data] of message.data) {
// Only update locale data if it was already loaded
if (i18n.availableLocales.includes(locale)) {
const localeData = JSON.parse(data)
i18n.setLocaleMessage(locale, localeData)
}
}
}
}

View File

@ -8,6 +8,8 @@ const kill = require('tree-kill')
const path = require('path')
const { spawn } = require('child_process')
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
let electronProcess = null
let manualRestart = null
@ -76,6 +78,22 @@ async function restartElectron() {
})
}
/**
* @param {import('webpack').Compiler} compiler
* @param {WebpackDevServer} devServer
*/
function setupNotifyLocaleUpdate(compiler, devServer) {
const notifyLocaleChange = (updatedLocales) => {
devServer.sendMessage(devServer.webSocketServer.clients, "freetube-locale-update", updatedLocales)
}
compiler.options.plugins
.filter(plugin => plugin instanceof ProcessLocalesPlugin)
.forEach((/** @type {ProcessLocalesPlugin} */plugin) => {
plugin.notifyLocaleChange = notifyLocaleChange
})
}
function startMain() {
const compiler = webpack(mainConfig)
const { name } = compiler
@ -116,6 +134,7 @@ function startRenderer(callback) {
ignored: [
/(dashFiles|storyboards)\/*/,
'/**/.DS_Store',
'**/static/locales/*'
]
},
publicPath: '/static'
@ -126,6 +145,8 @@ function startRenderer(callback) {
server.startCallback(err => {
if (err) console.error(err)
setupNotifyLocaleUpdate(compiler, server)
callback()
})
}
@ -142,11 +163,12 @@ function startWeb () {
const server = new WebpackDevServer({
open: true,
static: {
directory: path.join(process.cwd(), 'dist/web/static'),
directory: path.resolve(__dirname, '..', 'static'),
watch: {
ignored: [
/(dashFiles|storyboards)\/*/,
'/**/.DS_Store',
'**/static/locales/*'
]
}
},
@ -155,6 +177,8 @@ function startWeb () {
server.startCallback(err => {
if (err) console.error(err)
setupNotifyLocaleUpdate(compiler, server)
})
}
if (!web) {

View File

@ -6,7 +6,6 @@ const VueLoaderPlugin = require('vue-loader/lib/plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const ProcessLocalesPlugin = require('./ProcessLocalesPlugin')
const WatchExternalFilesPlugin = require('webpack-watch-external-files-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const isDevMode = process.env.NODE_ENV === 'development'
@ -15,6 +14,7 @@ const { version: swiperVersion } = JSON.parse(readFileSync(path.join(__dirname,
const processLocalesPlugin = new ProcessLocalesPlugin({
compress: !isDevMode,
hotReload: isDevMode,
inputDir: path.join(__dirname, '../static/locales'),
outputDir: 'static/locales',
})
@ -165,16 +165,4 @@ const config = {
target: 'electron-renderer',
}
if (isDevMode) {
const activeLocales = JSON.parse(readFileSync(path.join(__dirname, '../static/locales/activeLocales.json')))
config.plugins.push(
new WatchExternalFilesPlugin({
files: [
`./static/locales/{${activeLocales.join(',')}}.yaml`,
],
}),
)
}
module.exports = config

View File

@ -178,6 +178,7 @@ const config = {
const processLocalesPlugin = new ProcessLocalesPlugin({
compress: false,
hotReload: isDevMode,
inputDir: path.join(__dirname, '../static/locales'),
outputDir: 'static/locales',
})

View File

@ -129,7 +129,6 @@
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4",
"webpack-watch-external-files-plugin": "^3.0.0",
"yaml-eslint-parser": "^1.2.2"
}
}

View File

@ -4662,7 +4662,7 @@ glob-to-regexp@^0.4.1:
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
glob@10.3.10, glob@^10.3.7:
glob@^10.3.7:
version "10.3.10"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b"
integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==
@ -6651,14 +6651,6 @@ path-type@^5.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-5.0.0.tgz#14b01ed7aea7ddf9c7c3f46181d4d04f9c785bb8"
integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==
path@0.12.7:
version "0.12.7"
resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f"
integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==
dependencies:
process "^0.11.1"
util "^0.10.3"
pend@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
@ -7046,7 +7038,7 @@ process-nextick-args@~2.0.0:
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
process@^0.11.1, process@^0.11.10:
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
@ -8604,13 +8596,6 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
util@^0.10.3:
version "0.10.4"
resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901"
integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==
dependencies:
inherits "2.0.3"
util@^0.12.4:
version "0.12.4"
resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253"
@ -8923,14 +8908,6 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack-watch-external-files-plugin@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/webpack-watch-external-files-plugin/-/webpack-watch-external-files-plugin-3.0.0.tgz#65d75389b1c9b05e84a2cfad83897ac12f146c7e"
integrity sha512-u0geLnZ/uJXh92B+40apyovnexoP+m9I6QktyGlG8rP6CXXYKe3yhG7zY9P2Wbg75sPTP1PYv2rropRlZdxg9A==
dependencies:
glob "10.3.10"
path "0.12.7"
webpack@^5.91.0:
version "5.91.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.91.0.tgz#ffa92c1c618d18c878f06892bbdc3373c71a01d9"