|
| 1 | +const fs = require('fs') |
| 2 | +const path = require('path') |
| 3 | +const chalk = require('chalk') |
| 4 | +const webpack = require('webpack') |
| 5 | +const BrowserSyncPlugin = require('browser-sync-webpack-plugin') |
| 6 | + |
| 7 | +const logId = '[' + chalk.blue('WebpackBrowserSyncPlugin') + ']' |
| 8 | + |
| 9 | +/** |
| 10 | + * Dev-only: BrowserSync + HMR |
| 11 | + * |
| 12 | + * To use BrowserSync (BS) or Hot Module Replacement (HMR), you must define the following environment variables. |
| 13 | + * Two modes are available: |
| 14 | + * - BROWSER_SYNC_APP_URL |
| 15 | + * - BROWSER_SYNC_APP_PORT |
| 16 | + * - BROWSER_SYNC_APP_IP |
| 17 | + * |
| 18 | + * "host" mode |
| 19 | + * Enables live reloading in your local environment by using the domain name to configure the proxy. |
| 20 | + * |
| 21 | + * "ip" mode |
| 22 | + * Uses an IP address for the proxy. |
| 23 | + * By entering your local IP, you can access and preview your environment from another device (such as a mobile phone) on the same network. |
| 24 | + * |
| 25 | + * Use yarn scripts to start the development server: `yarn start:host` | `yarn start:ip`. |
| 26 | + */ |
| 27 | +class WebpackBrowserSyncPlugin { |
| 28 | + /** |
| 29 | + * @param {'development'|'production'|string} mode Webpack mode from `plugins.get(mode)`. |
| 30 | + * @returns {import('webpack').WebpackPluginInstance[]} Empty in production. |
| 31 | + */ |
| 32 | + static getPlugins(mode) { |
| 33 | + if (mode === 'production') { |
| 34 | + return [] |
| 35 | + } |
| 36 | + |
| 37 | + const hotReload = this.resolveMode() |
| 38 | + |
| 39 | + if (hotReload === '') { |
| 40 | + return [] |
| 41 | + } |
| 42 | + |
| 43 | + const env = this.getLocalAppEnv() |
| 44 | + const issues = this.collectBrowserSyncConfigIssues(hotReload, env) |
| 45 | + if (issues.missing.length > 0) { |
| 46 | + this.fatalConfig(hotReload, issues) |
| 47 | + } |
| 48 | + |
| 49 | + const parsed = this.parseAppConnection(env) |
| 50 | + if (!parsed) { |
| 51 | + this.fatalConfig(hotReload, { missing: ['BROWSER_SYNC_APP_URL'] }) |
| 52 | + } |
| 53 | + |
| 54 | + const { appUrl, urlHadPort, wordPressPort, localAppPortStr } = parsed |
| 55 | + |
| 56 | + const browserSyncPort = this.pickBrowserSyncPort(wordPressPort, localAppPortStr, urlHadPort) |
| 57 | + |
| 58 | + let appHost = appUrl.replace(/^https?:\/\//i, '').replace(/:\d+$/, '') |
| 59 | + let appIp = String(env.BROWSER_SYNC_APP_IP || '') |
| 60 | + .trim() |
| 61 | + .replace(/:\d+$/, '') |
| 62 | + |
| 63 | + /** @type {import('browser-sync').Options} */ |
| 64 | + const browserSyncConfig = { |
| 65 | + injectCss: true, |
| 66 | + proxy: appUrl, |
| 67 | + port: browserSyncPort, |
| 68 | + files: ['**/*.php', 'dist/images/**/*', 'dist/icons/**/*', 'dist/fonts/**/*', 'dist/**/*.css', 'dist/**/*.js'], |
| 69 | + open: false, |
| 70 | + reloadDelay: 0, |
| 71 | + notify: true, |
| 72 | + injectNotification: true, |
| 73 | + } |
| 74 | + |
| 75 | + if (hotReload === 'host') { |
| 76 | + Object.assign(browserSyncConfig, { host: appHost }) |
| 77 | + } |
| 78 | + |
| 79 | + if (hotReload === 'ip') { |
| 80 | + Object.assign(browserSyncConfig, { host: appIp }) |
| 81 | + } |
| 82 | + |
| 83 | + // eslint-disable-next-line no-console |
| 84 | + console.log(logId, 'BrowserSync enabled (' + chalk.green(hotReload) + '), proxy:', chalk.cyan(appUrl)) |
| 85 | + // eslint-disable-next-line no-console |
| 86 | + console.log( |
| 87 | + logId, |
| 88 | + chalk.dim( |
| 89 | + 'BS listen: ' + |
| 90 | + chalk.bold(':' + browserSyncPort) + |
| 91 | + ' | WordPress port (proxy): ' + |
| 92 | + chalk.bold(String(wordPressPort)) + |
| 93 | + (urlHadPort |
| 94 | + ? localAppPortStr |
| 95 | + ? ' | BROWSER_SYNC_APP_PORT = BrowserSync port' |
| 96 | + : ' | BROWSER_SYNC_APP_PORT unset → BS default port' |
| 97 | + : ' | BROWSER_SYNC_APP_PORT = WordPress port (URL had no port)') |
| 98 | + ) |
| 99 | + ) |
| 100 | + // eslint-disable-next-line no-console |
| 101 | + console.log(logId, chalk.dim('Use BrowserSync Local / External URL below, not the WordPress URL directly.')) |
| 102 | + |
| 103 | + return [ |
| 104 | + new webpack.HotModuleReplacementPlugin(), |
| 105 | + new BrowserSyncPlugin(browserSyncConfig, { |
| 106 | + reload: false, |
| 107 | + }), |
| 108 | + ] |
| 109 | + } |
| 110 | + |
| 111 | + /** |
| 112 | + * @returns {null|{ appUrl: string, urlHadPort: boolean, wordPressPort: number, localAppPortStr: string }} |
| 113 | + */ |
| 114 | + static parseAppConnection(env) { |
| 115 | + let base = String(env.BROWSER_SYNC_APP_URL || '').trim() |
| 116 | + const localPort = String(env.BROWSER_SYNC_APP_PORT || '').trim() |
| 117 | + |
| 118 | + if (!base) { |
| 119 | + return null |
| 120 | + } |
| 121 | + |
| 122 | + if (!/^https?:\/\//i.test(base)) { |
| 123 | + base = 'http://' + base |
| 124 | + } |
| 125 | + |
| 126 | + try { |
| 127 | + const url = new URL(base) |
| 128 | + const urlHadPort = Boolean(url.port) |
| 129 | + if (!urlHadPort && localPort) { |
| 130 | + url.port = localPort |
| 131 | + } |
| 132 | + const appUrl = url.toString().replace(/\/+$/, '') |
| 133 | + const u2 = new URL(appUrl) |
| 134 | + const wordPressPort = u2.port ? Number.parseInt(u2.port, 10) : u2.protocol === 'https:' ? 443 : 80 |
| 135 | + |
| 136 | + return { |
| 137 | + appUrl, |
| 138 | + urlHadPort, |
| 139 | + wordPressPort, |
| 140 | + localAppPortStr: localPort, |
| 141 | + } |
| 142 | + } catch { |
| 143 | + return null |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * @param {number} wordPressPort |
| 149 | + * @param {string} localAppPortStr |
| 150 | + * @param {boolean} urlHadPort |
| 151 | + * @returns {number} |
| 152 | + */ |
| 153 | + static pickBrowserSyncPort(wordPressPort, localAppPortStr, urlHadPort) { |
| 154 | + const wp = wordPressPort |
| 155 | + const preferredRaw = Number.parseInt(String(localAppPortStr || '').trim(), 10) |
| 156 | + const hasPreferred = Number.isFinite(preferredRaw) && preferredRaw > 0 |
| 157 | + |
| 158 | + let candidate |
| 159 | + if (urlHadPort) { |
| 160 | + if (hasPreferred) { |
| 161 | + candidate = preferredRaw |
| 162 | + } else { |
| 163 | + candidate = 8080 |
| 164 | + } |
| 165 | + } else { |
| 166 | + candidate = 8080 |
| 167 | + } |
| 168 | + |
| 169 | + let warned = false |
| 170 | + let safety = 0 |
| 171 | + while (candidate === wp && safety < 10000) { |
| 172 | + if (!warned) { |
| 173 | + warned = true |
| 174 | + // eslint-disable-next-line no-console |
| 175 | + console.warn( |
| 176 | + logId, |
| 177 | + chalk.yellow('BrowserSync port would match WordPress port ' + wp + '. Using next available port.') |
| 178 | + ) |
| 179 | + } |
| 180 | + candidate++ |
| 181 | + if (candidate > 65535) { |
| 182 | + candidate = 3000 |
| 183 | + } |
| 184 | + safety++ |
| 185 | + } |
| 186 | + |
| 187 | + return candidate |
| 188 | + } |
| 189 | + |
| 190 | + /** |
| 191 | + * @returns {''|'host'|'ip'} |
| 192 | + */ |
| 193 | + static resolveMode() { |
| 194 | + const fromEnv = String(process.env.BROWSERSYNC_MODE || '') |
| 195 | + .trim() |
| 196 | + .toLowerCase() |
| 197 | + if (fromEnv === 'host' || fromEnv === 'ip') { |
| 198 | + return fromEnv |
| 199 | + } |
| 200 | + return '' |
| 201 | + } |
| 202 | + |
| 203 | + static getLocalAppEnv() { |
| 204 | + const defaults = this.readWpEnvConfigDefaults() |
| 205 | + return { |
| 206 | + BROWSER_SYNC_APP_URL: process.env.BROWSER_SYNC_APP_URL ?? defaults.BROWSER_SYNC_APP_URL ?? '', |
| 207 | + BROWSER_SYNC_APP_PORT: process.env.BROWSER_SYNC_APP_PORT ?? defaults.BROWSER_SYNC_APP_PORT ?? '', |
| 208 | + BROWSER_SYNC_APP_IP: process.env.BROWSER_SYNC_APP_IP ?? defaults.BROWSER_SYNC_APP_IP ?? '', |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + /** |
| 213 | + * Reads `config` from `.wp-env.json` (project root). Env vars take precedence later. |
| 214 | + * |
| 215 | + * @returns {{ BROWSER_SYNC_APP_URL: string, BROWSER_SYNC_APP_PORT: string, BROWSER_SYNC_APP_IP: string }} |
| 216 | + */ |
| 217 | + static readWpEnvConfigDefaults() { |
| 218 | + const empty = { BROWSER_SYNC_APP_URL: '', BROWSER_SYNC_APP_PORT: '', BROWSER_SYNC_APP_IP: '' } |
| 219 | + const file = path.resolve(__dirname, '../.wp-env.json') |
| 220 | + |
| 221 | + try { |
| 222 | + if (!fs.existsSync(file)) { |
| 223 | + return empty |
| 224 | + } |
| 225 | + const json = JSON.parse(fs.readFileSync(file, 'utf8')) |
| 226 | + const cfg = json.config |
| 227 | + |
| 228 | + if (!cfg || typeof cfg !== 'object') { |
| 229 | + return empty |
| 230 | + } |
| 231 | + |
| 232 | + return { |
| 233 | + BROWSER_SYNC_APP_URL: cfg.BROWSER_SYNC_APP_URL != null ? String(cfg.BROWSER_SYNC_APP_URL) : '', |
| 234 | + BROWSER_SYNC_APP_PORT: cfg.BROWSER_SYNC_APP_PORT != null ? String(cfg.BROWSER_SYNC_APP_PORT) : '', |
| 235 | + BROWSER_SYNC_APP_IP: cfg.BROWSER_SYNC_APP_IP != null ? String(cfg.BROWSER_SYNC_APP_IP) : '', |
| 236 | + } |
| 237 | + } catch { |
| 238 | + return empty |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + /** |
| 243 | + * @returns {{ missing: string[] }} |
| 244 | + */ |
| 245 | + static collectBrowserSyncConfigIssues(hotReload, env) { |
| 246 | + const missing = [] |
| 247 | + |
| 248 | + const urlRaw = String(env.BROWSER_SYNC_APP_URL || '').trim() |
| 249 | + if (!urlRaw) { |
| 250 | + missing.push('BROWSER_SYNC_APP_URL') |
| 251 | + } else { |
| 252 | + let base = urlRaw |
| 253 | + if (!/^https?:\/\//i.test(base)) { |
| 254 | + base = 'http://' + base |
| 255 | + } |
| 256 | + /** @type {URL|null} */ |
| 257 | + let url = null |
| 258 | + try { |
| 259 | + url = new URL(base) |
| 260 | + } catch { |
| 261 | + missing.push('BROWSER_SYNC_APP_URL') |
| 262 | + } |
| 263 | + if (url) { |
| 264 | + const urlHadPort = Boolean(url.port) |
| 265 | + const portVar = String(env.BROWSER_SYNC_APP_PORT || '').trim() |
| 266 | + if (!urlHadPort && !portVar) { |
| 267 | + missing.push('BROWSER_SYNC_APP_PORT') |
| 268 | + } |
| 269 | + } |
| 270 | + } |
| 271 | + |
| 272 | + if (hotReload === 'ip') { |
| 273 | + const ip = String(env.BROWSER_SYNC_APP_IP || '') |
| 274 | + .trim() |
| 275 | + .replace(/:\d+$/, '') |
| 276 | + if (!ip) { |
| 277 | + missing.push('BROWSER_SYNC_APP_IP') |
| 278 | + } |
| 279 | + } |
| 280 | + |
| 281 | + return { missing } |
| 282 | + } |
| 283 | + |
| 284 | + /** |
| 285 | + * First line: fatal summary. Second line: Missing vars only. then exit(1). |
| 286 | + */ |
| 287 | + static fatalConfig(hotReload, issues) { |
| 288 | + const head = `Cannot start BrowserSync with ${chalk.yellow('--' + hotReload)}.` |
| 289 | + // eslint-disable-next-line no-console |
| 290 | + console.error(`${logId} ${chalk.white.bold.bgRed(' ERROR ')} ${head}`) |
| 291 | + |
| 292 | + const names = [...new Set(issues.missing || [])] |
| 293 | + if (names.length > 0) { |
| 294 | + // eslint-disable-next-line no-console |
| 295 | + console.error( |
| 296 | + `${logId} ${chalk.red('Missing:')} ` + names.map((name) => chalk.yellow.bold(name)).join(chalk.dim(', ')) |
| 297 | + ) |
| 298 | + } |
| 299 | + process.exit(1) |
| 300 | + } |
| 301 | +} |
| 302 | + |
| 303 | +module.exports = WebpackBrowserSyncPlugin |
0 commit comments