diff --git a/.gitignore b/.gitignore index bc36491347..76b023d5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ yarn.lock .eslintcache +test/fixtures/contentbase-config/public/assets/non-exist.txt test/fixtures/reload-config/main.css test/fixtures/reload-config-2/main.css !/test/fixtures/contentbase-config/public/node_modules diff --git a/bin/cli-flags.js b/bin/cli-flags.js index 5f7ad9ce4f..8d409a8654 100644 --- a/bin/cli-flags.js +++ b/bin/cli-flags.js @@ -228,5 +228,16 @@ module.exports = { multiple: true, negative: true, }, + { + name: 'watch-files', + type: String, + configs: [ + { + type: 'string', + }, + ], + description: 'Watch static files for file changes', + multiple: true, + }, ], }; diff --git a/lib/Server.js b/lib/Server.js index f7db641318..debcfb4cbb 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -71,6 +71,7 @@ class Server { // Should be after `webpack-dev-middleware`, otherwise other middlewares might rewrite response routes(this); + this.setupWatchFiles(); this.setupFeatures(); this.setupHttps(); this.createServer(); @@ -349,6 +350,27 @@ class Server { this.options.onBeforeSetupMiddleware(this); } + setupWatchFiles() { + if (this.options.watchFiles) { + const { watchFiles } = this.options; + + if (typeof watchFiles === 'string') { + this.watchFiles(watchFiles, {}); + } else if (Array.isArray(watchFiles)) { + watchFiles.forEach((file) => { + if (typeof file === 'string') { + this.watchFiles(file, {}); + } else { + this.watchFiles(file.paths, file.options || {}); + } + }); + } else { + // { paths: [...], options: {} } + this.watchFiles(watchFiles.paths, watchFiles.options || {}); + } + } + } + setupMiddleware() { this.app.use(this.middleware); } diff --git a/lib/options.json b/lib/options.json index 8d9dac1cec..fd598a02de 100644 --- a/lib/options.json +++ b/lib/options.json @@ -103,6 +103,35 @@ ] } } + }, + "WatchFilesString": { + "type": "string", + "minLength": 1 + }, + "WatchFilesObject": { + "type": "object", + "properties": { + "paths": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false } }, "properties": { @@ -424,6 +453,29 @@ "enum": ["sockjs", "ws"] } ] + }, + "watchFiles": { + "anyOf": [ + { + "$ref": "#/definitions/WatchFilesString" + }, + { + "$ref": "#/definitions/WatchFilesObject" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/WatchFilesString" + }, + { + "$ref": "#/definitions/WatchFilesObject" + } + ] + } + } + ] } }, "errorMessage": { @@ -443,13 +495,14 @@ "onAfterSetupMiddleware": "should be {Function} (https://webpack.js.org/configuration/dev-server/#devserverafter)", "onBeforeSetupMiddleware": "should be {Function} (https://webpack.js.org/configuration/dev-server/#devserverbefore)", "onListening": "should be {Function} (https://webpack.js.org/configuration/dev-server/#onlistening)", - "open": "should be {Boolean|String|(STRING | Object)[]} (https://webpack.js.org/configuration/dev-server/#devserveropen)", + "open": "should be {Boolean|String|(String | Object)[]} (https://webpack.js.org/configuration/dev-server/#devserveropen)", "port": "should be {Number|String|Null} (https://webpack.js.org/configuration/dev-server/#devserverport)", "proxy": "should be {Object|Array} (https://webpack.js.org/configuration/dev-server/#devserverproxy)", "public": "should be {String} (https://webpack.js.org/configuration/dev-server/#devserverpublic)", "setupExitSignals": "should be {Boolean} (https://webpack.js.org/configuration/dev-server/#devserversetupexitsignals)", "static": "should be {Boolean|String|Object|Array} (https://webpack.js.org/configuration/dev-server/#devserverstatic)", - "transportMode": "should be {String|Object} (https://webpack.js.org/configuration/dev-server/#devservertransportmode)" + "transportMode": "should be {String|Object} (https://webpack.js.org/configuration/dev-server/#devservertransportmode)", + "watchFiles": "should be {String|Array|Object} (https://webpack.js.org/configuration/dev-server/#devserverwatchfiles)" } }, "additionalProperties": false diff --git a/test/__snapshots__/Validation.test.js.snap b/test/__snapshots__/Validation.test.js.snap index e2f492f407..55e88d26ff 100644 --- a/test/__snapshots__/Validation.test.js.snap +++ b/test/__snapshots__/Validation.test.js.snap @@ -43,5 +43,5 @@ exports[`Validation validation should fail validation for invalid \`static\` con exports[`Validation validation should fail validation for no additional properties 1`] = ` "Invalid configuration object. Object has been initialized using a configuration object that does not match the API schema. - configuration has an unknown property 'additional'. These properties are valid: - object { bonjour?, client?, compress?, dev?, firewall?, headers?, historyApiFallback?, host?, hot?, http2?, https?, liveReload?, onAfterSetupMiddleware?, onBeforeSetupMiddleware?, onListening?, open?, port?, proxy?, public?, setupExitSignals?, static?, transportMode? }" + object { bonjour?, client?, compress?, dev?, firewall?, headers?, historyApiFallback?, host?, hot?, http2?, https?, liveReload?, onAfterSetupMiddleware?, onBeforeSetupMiddleware?, onListening?, open?, port?, proxy?, public?, setupExitSignals?, static?, transportMode?, watchFiles? }" `; diff --git a/test/fixtures/contentbase-config/public/assets/other.txt b/test/fixtures/contentbase-config/public/assets/other.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/options.test.js b/test/options.test.js index 455baa517a..ce1c87d4f3 100644 --- a/test/options.test.js +++ b/test/options.test.js @@ -401,6 +401,16 @@ describe('options', () => { }, ], }, + watchFiles: { + success: [ + 'dir', + ['one-dir', 'two-dir'], + { paths: ['dir'] }, + { paths: ['dir'], options: { usePolling: true } }, + [{ paths: ['one-dir'] }, 'two-dir'], + ], + failure: [false, 123], + }, }; Object.keys(cases).forEach((key) => { diff --git a/test/ports-map.js b/test/ports-map.js index 736deed6b7..907ab4cb1c 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -44,6 +44,7 @@ const portsList = { bundle: 1, ModuleFederation: 1, 'setupExitSignals-option': 1, + 'watchFiles-option': 1, }; let startPort = 8089; diff --git a/test/server/watchFiles-option.test.js b/test/server/watchFiles-option.test.js new file mode 100644 index 0000000000..1903cad9eb --- /dev/null +++ b/test/server/watchFiles-option.test.js @@ -0,0 +1,314 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const testServer = require('../helpers/test-server'); +const config = require('../fixtures/contentbase-config/webpack.config'); +const port = require('../ports-map')['watchFiles-option']; + +const watchDir = path.resolve( + __dirname, + '../fixtures/contentbase-config/public' +); + +describe("'watchFiles' option", () => { + let server; + + describe('should work with string and path to file', () => { + const file = path.join(watchDir, 'assets/example.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: file, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + }); + + it('should reload on file content changed', (done) => { + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(file); + + done(); + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work with string and path to dir', () => { + const file = path.join(watchDir, 'assets/example.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: watchDir, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + }); + + it('should reload on file content changed', (done) => { + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(file); + + done(); + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work with string and glob', () => { + const file = path.join(watchDir, 'assets/example.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: `${watchDir}/**/*`, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + }); + + it('should reload on file content changed', (done) => { + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(file); + + done(); + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work not crash on non exist file', () => { + const nonExistFile = path.join(watchDir, 'assets/non-exist.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: nonExistFile, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(nonExistFile); + }); + + it('should reload on file content changed', (done) => { + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(nonExistFile); + + done(); + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(nonExistFile, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work with object with single path', () => { + const file = path.join(watchDir, 'assets/example.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: { paths: file }, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + }); + + it('should reload on file content channge', (done) => { + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(file); + + done(); + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work with object with multiple paths', () => { + const file = path.join(watchDir, 'assets/example.txt'); + const other = path.join(watchDir, 'assets/other.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: { paths: [file, other] }, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + }); + + it('should reload on file content channge', (done) => { + const expected = [file, other]; + + let changed = 0; + + server.staticWatchers[0].on('change', (changedPath) => { + expect(expected.includes(changedPath)).toBeTruthy(); + + changed += 1; + + if (changed === 2) { + done(); + } + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + fs.writeFileSync(other, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work with array config', () => { + const file = path.join(watchDir, 'assets/example.txt'); + const other = path.join(watchDir, 'assets/other.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: [{ paths: [file] }, other], + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + fs.truncateSync(other); + }); + + it('should reload on file content change', (done) => { + let changed = 0; + + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(file); + + changed += 1; + + if (changed === 2) { + done(); + } + }); + + server.staticWatchers[1].on('change', (changedPath) => { + expect(changedPath).toBe(other); + + changed += 1; + + if (changed === 2) { + done(); + } + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + fs.writeFileSync(other, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); + + describe('should work with options', () => { + const file = path.join(watchDir, 'assets/example.txt'); + + beforeAll((done) => { + server = testServer.start( + config, + { + watchFiles: { + paths: file, + options: { + usePolling: true, + }, + }, + port, + }, + done + ); + }); + + afterAll((done) => { + testServer.close(done); + fs.truncateSync(file); + }); + + it('should reload on file content changed', (done) => { + server.staticWatchers[0].on('change', (changedPath) => { + expect(changedPath).toBe(file); + + done(); + }); + + // change file content + setTimeout(() => { + fs.writeFileSync(file, 'Kurosaki Ichigo', 'utf8'); + }, 1000); + }); + }); +});