Skip to content

Commit

Permalink
Implement hot reloading for the locale files during development (#5050)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
absidue committed May 14, 2024
1 parent 2fcd5c9 commit 61820b1
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 94 deletions.
145 changes: 91 additions & 54 deletions _scripts/ProcessLocalesPlugin.js
Original file line number Diff line number Diff line change
@@ -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.')
Expand All @@ -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
}

Object.values(this.locales).forEach((localeEntry) => {
const { locale, data, mtimeMs } = localeEntry
/** @type {[string, string][]} */
const updatedLocales = []
if (this.hotReload && !this.notifyLocaleChange) {
console.warn('ProcessLocalesPlugin: Unable to live reload locales as `notifyLocaleChange` is not set.')
}

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
}
}

Expand All @@ -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)
Expand All @@ -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)
}
})
})
}

loadLocales(loadModifiedFilesOnly = false) {
if (this.activeLocales.length === 0) {
this.activeLocales = JSON.parse(readFileSync(`${this.inputDir}/activeLocales.json`))
}
compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
if (!!compiler.watching) {
// watch locale files for changes
compilation.fileDependencies.addAll(this.filePaths)
}
})

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
}
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() {
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)

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)
}
}

Expand Down
18 changes: 18 additions & 0 deletions _scripts/_hotReloadLocalesScript.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
26 changes: 25 additions & 1 deletion _scripts/dev-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -116,6 +134,7 @@ function startRenderer(callback) {
ignored: [
/(dashFiles|storyboards)\/*/,
'/**/.DS_Store',
'**/static/locales/*'
]
},
publicPath: '/static'
Expand All @@ -126,6 +145,8 @@ function startRenderer(callback) {
server.startCallback(err => {
if (err) console.error(err)

setupNotifyLocaleUpdate(compiler, server)

callback()
})
}
Expand All @@ -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/*'
]
}
},
Expand All @@ -155,6 +177,8 @@ function startWeb () {

server.startCallback(err => {
if (err) console.error(err)

setupNotifyLocaleUpdate(compiler, server)
})
}
if (!web) {
Expand Down
14 changes: 1 addition & 13 deletions _scripts/webpack.renderer.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
})
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions _scripts/webpack.web.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const config = {

const processLocalesPlugin = new ProcessLocalesPlugin({
compress: false,
hotReload: isDevMode,
inputDir: path.join(__dirname, '../static/locales'),
outputDir: 'static/locales',
})
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

0 comments on commit 61820b1

Please sign in to comment.