@quicktvui/web-cli 1.0.7 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * BundleWatcher - 监听 dist/dev 目录变化,检测 bundle 文件就绪
3
+ *
4
+ * 职责:
5
+ * 1. 监听 dist/dev 目录的文件变化
6
+ * 2. 检测 index.bundle 入口文件是否就绪
7
+ * 3. 文件变化时通知回调
8
+ * 4. 支持防抖,避免频繁触发
9
+ */
10
+
11
+ const chokidar = require('chokidar')
12
+ const path = require('path')
13
+ const fs = require('fs')
14
+ const signale = require('signale')
15
+
16
+ class BundleWatcher {
17
+ constructor(projectRoot, options = {}) {
18
+ this.projectRoot = projectRoot
19
+ this.distDir = path.join(projectRoot, 'dist', 'dev')
20
+ this.watcher = null
21
+ this.bundleEntry = null
22
+ this.onChange = options.onChange || null
23
+ this.onReady = options.onReady || null
24
+ this.debounceMs = options.debounceMs || 800
25
+ this._debounceTimer = null
26
+ }
27
+
28
+ /**
29
+ * 检测 bundle 入口文件
30
+ */
31
+ detectBundleEntry() {
32
+ if (!fs.existsSync(this.distDir)) {
33
+ return null
34
+ }
35
+
36
+ // 优先查找 index.bundle
37
+ const indexPath = path.join(this.distDir, 'index.bundle')
38
+ if (fs.existsSync(indexPath)) {
39
+ return 'index.bundle'
40
+ }
41
+
42
+ // 兼容 index.android.js
43
+ const androidPath = path.join(this.distDir, 'index.android.js')
44
+ if (fs.existsSync(androidPath)) {
45
+ return 'index.android.js'
46
+ }
47
+
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * 获取 bundle 的完整 URL 路径(相对于服务器根目录)
53
+ */
54
+ getBundleUrlPath() {
55
+ const entry = this.detectBundleEntry()
56
+ if (!entry) return null
57
+ return `/dist/dev/${entry}`
58
+ }
59
+
60
+ /**
61
+ * 获取所有 bundle 文件列表
62
+ */
63
+ listBundleFiles() {
64
+ if (!fs.existsSync(this.distDir)) {
65
+ return []
66
+ }
67
+
68
+ const files = []
69
+ const entries = fs.readdirSync(this.distDir)
70
+
71
+ for (const entry of entries) {
72
+ const fullPath = path.join(this.distDir, entry)
73
+ const stat = fs.statSync(fullPath)
74
+
75
+ if (stat.isFile()) {
76
+ files.push({
77
+ name: entry,
78
+ path: fullPath,
79
+ size: stat.size,
80
+ mtime: stat.mtime,
81
+ urlPath: `/dist/dev/${entry}`,
82
+ isEntry: entry === 'index.bundle' || entry === 'index.android.js',
83
+ })
84
+ }
85
+ }
86
+
87
+ return files
88
+ }
89
+
90
+ /**
91
+ * 启动监听
92
+ */
93
+ start() {
94
+ // 确保 dist 目录存在
95
+ if (!fs.existsSync(this.distDir)) {
96
+ fs.mkdirSync(this.distDir, { recursive: true })
97
+ signale.info(`创建 dist/dev 目录: ${this.distDir}`)
98
+ }
99
+
100
+ // 先检测已有 bundle
101
+ const existingEntry = this.detectBundleEntry()
102
+ if (existingEntry) {
103
+ this.bundleEntry = existingEntry
104
+ signale.success(`检测到已有构建产物: dist/dev/${existingEntry}`)
105
+ }
106
+
107
+ // 启动 chokidar 监听
108
+ this.watcher = chokidar.watch(this.distDir, {
109
+ ignored: [/node_modules/, /\.DS_Store/, /Thumbs\.db/],
110
+ ignoreInitial: true,
111
+ persistent: true,
112
+ awaitWriteFinish: {
113
+ stabilityThreshold: 300,
114
+ pollInterval: 100,
115
+ },
116
+ })
117
+
118
+ this.watcher.on('add', (filePath) => this._handleFileChange(filePath, 'add'))
119
+ this.watcher.on('change', (filePath) => this._handleFileChange(filePath, 'change'))
120
+ this.watcher.on('unlink', (filePath) => this._handleFileChange(filePath, 'unlink'))
121
+
122
+ this.watcher.on('ready', () => {
123
+ signale.info('BundleWatcher 已就绪,正在监听 dist/dev/ 目录变化...')
124
+ if (this.onReady) this.onReady()
125
+ })
126
+
127
+ this.watcher.on('error', (error) => {
128
+ signale.error('BundleWatcher 错误:', error.message)
129
+ })
130
+ }
131
+
132
+ /**
133
+ * 处理文件变化
134
+ */
135
+ _handleFileChange(filePath, eventType) {
136
+ const relativePath = path.relative(this.distDir, filePath)
137
+ const isBundle = this._isBundleFile(filePath)
138
+
139
+ if (isBundle) {
140
+ // 检测入口文件
141
+ const entry = this.detectBundleEntry()
142
+ if (entry && entry !== this.bundleEntry) {
143
+ this.bundleEntry = entry
144
+ signale.success(`检测到 bundle 入口: dist/dev/${entry}`)
145
+ }
146
+
147
+ // 防抖通知
148
+ clearTimeout(this._debounceTimer)
149
+ this._debounceTimer = setTimeout(() => {
150
+ signale.info(`文件变化: ${relativePath} (${eventType})`)
151
+ if (this.onChange) {
152
+ this.onChange({
153
+ type: eventType,
154
+ file: relativePath,
155
+ fullPath: filePath,
156
+ isBundle: true,
157
+ isEntry: relativePath === 'index.bundle' || relativePath === 'index.android.js',
158
+ bundleEntry: this.detectBundleEntry(),
159
+ bundleUrlPath: this.getBundleUrlPath(),
160
+ })
161
+ }
162
+ }, this.debounceMs)
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 判断是否为 bundle 文件
168
+ */
169
+ _isBundleFile(filePath) {
170
+ const basename = path.basename(filePath)
171
+ return (
172
+ basename.endsWith('.bundle') ||
173
+ basename.endsWith('.js') ||
174
+ basename.endsWith('.map') ||
175
+ /assets[\\/]/.test(filePath)
176
+ )
177
+ }
178
+
179
+ /**
180
+ * 停止监听
181
+ */
182
+ stop() {
183
+ clearTimeout(this._debounceTimer)
184
+ if (this.watcher) {
185
+ this.watcher.close()
186
+ this.watcher = null
187
+ signale.info('BundleWatcher 已停止')
188
+ }
189
+ }
190
+ }
191
+
192
+ module.exports = BundleWatcher
@@ -0,0 +1,295 @@
1
+ /**
2
+ * DevBuildManager - 管理项目的 dev 构建进程
3
+ *
4
+ * 职责:
5
+ * 1. 检测项目的 dev 脚本
6
+ * 2. 调用 npm run dev 启动构建
7
+ * 3. 监听子进程 stdout/stderr,检测构建就绪信号
8
+ * 4. 管理子进程生命周期(启动/停止/重启)
9
+ */
10
+
11
+ const { spawn } = require('child_process')
12
+ const path = require('path')
13
+ const fs = require('fs')
14
+ const signale = require('signale')
15
+
16
+ // 构建就绪的检测关键词(不区分大小写匹配)
17
+ // 匹配 webpack 4/5 / qt-dev / hippy-debug-server 的各种输出格式
18
+ const READY_SIGNALS = [
19
+ 'webpack compiled',
20
+ 'compiled successfully',
21
+ 'compiled with warnings',
22
+ 'webpack build is watching',
23
+ // webpack 4 输出: "webpack is watching the files…"
24
+ 'watching the files',
25
+ // webpack 输出 "[built]" 标记(如:[./src/routes.js] 3.19 KiB {index} [built])
26
+ '] [built]',
27
+ // hidden modules 输出(webpack 编译完成的最终行)
28
+ 'hidden modules',
29
+ // qt-dev / hippy-debug-server 的输出
30
+ 'bundle is valid',
31
+ 'bundle created',
32
+ ]
33
+
34
+ // 编译成功但进程因非编译原因退出的信号(如 adb 连接失败)
35
+ // 这些信号表明编译已完成,不应重启进程
36
+ const COMPILATION_DONE_SIGNALS = ['[built]', 'hidden modules', 'bundle is valid', 'bundle created']
37
+
38
+ class DevBuildManager {
39
+ constructor(projectRoot, options = {}) {
40
+ this.projectRoot = projectRoot
41
+ this.pkg = options.pkg || {}
42
+ this.childProcess = null
43
+ this.isReady = false
44
+ this.onReady = options.onReady || null
45
+ this.onOutput = options.onOutput || null
46
+ this.onError = options.onError || null
47
+ this.devScriptName = options.devScript || 'dev'
48
+ this._restartCount = 0
49
+ this._maxRestarts = 3
50
+ }
51
+
52
+ /**
53
+ * 检测项目可用的 dev 脚本
54
+ */
55
+ detectDevScript() {
56
+ const scripts = this.pkg.scripts || {}
57
+
58
+ // 按优先级检测
59
+ const candidates = [this.devScriptName, 'dev', 'dev:android', 'build:dev']
60
+ for (const name of candidates) {
61
+ if (scripts[name]) {
62
+ return { name, script: scripts[name] }
63
+ }
64
+ }
65
+
66
+ return null
67
+ }
68
+
69
+ /**
70
+ * 检测 dist/dev 目录是否已有构建产物
71
+ */
72
+ detectExistingBundle() {
73
+ const distDevDir = path.join(this.projectRoot, 'dist', 'dev')
74
+
75
+ if (!fs.existsSync(distDevDir)) {
76
+ return null
77
+ }
78
+
79
+ // 查找 index.bundle
80
+ const indexPath = path.join(distDevDir, 'index.bundle')
81
+ if (fs.existsSync(indexPath)) {
82
+ const stat = fs.statSync(indexPath)
83
+ return {
84
+ entry: 'index.bundle',
85
+ path: indexPath,
86
+ size: stat.size,
87
+ mtime: stat.mtime,
88
+ }
89
+ }
90
+
91
+ // 查找 index.android.js (兼容旧模式)
92
+ const androidPath = path.join(distDevDir, 'index.android.js')
93
+ if (fs.existsSync(androidPath)) {
94
+ const stat = fs.statSync(androidPath)
95
+ return {
96
+ entry: 'index.android.js',
97
+ path: androidPath,
98
+ size: stat.size,
99
+ mtime: stat.mtime,
100
+ }
101
+ }
102
+
103
+ return null
104
+ }
105
+
106
+ /**
107
+ * 启动 dev 构建
108
+ */
109
+ start() {
110
+ const devScript = this.detectDevScript()
111
+ if (!devScript) {
112
+ signale.error('项目未配置 dev 脚本,请在 package.json 中添加 "dev" 脚本')
113
+ signale.error('示例: "dev": "qt-dev android -c ./scripts/quicktvui-webpack.dev.ts"')
114
+ return Promise.reject(new Error('No dev script found'))
115
+ }
116
+
117
+ signale.info(`检测到 dev 脚本: npm run ${devScript.name} → ${devScript.script}`)
118
+
119
+ return new Promise((resolve, reject) => {
120
+ this._startProcess(devScript.name, resolve, reject)
121
+ })
122
+ }
123
+
124
+ /**
125
+ * 启动子进程
126
+ */
127
+ _startProcess(scriptName, resolve, reject) {
128
+ const isWindows = process.platform === 'win32'
129
+ const cmd = isWindows ? 'npm.cmd' : 'npm'
130
+ const args = ['run', scriptName]
131
+
132
+ signale.pending(`正在启动 dev 构建: npm run ${scriptName} ...`)
133
+
134
+ this.childProcess = spawn(cmd, args, {
135
+ cwd: this.projectRoot,
136
+ stdio: 'pipe',
137
+ shell: isWindows,
138
+ env: {
139
+ ...process.env,
140
+ NODE_ENV: 'development',
141
+ FORCE_COLOR: '1',
142
+ // 阻止 webpack-dev-server 自动打开浏览器
143
+ // web-cli 自己管理浏览器打开逻辑
144
+ BROWSER: 'none',
145
+ },
146
+ })
147
+
148
+ let stdoutBuffer = ''
149
+ let stderrBuffer = ''
150
+ let resolved = false
151
+
152
+ // 去除 ANSI 颜色码的正则(用于构建就绪信号检测)
153
+ const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]/g
154
+
155
+ this.childProcess.stdout.on('data', (data) => {
156
+ const text = data.toString()
157
+ stdoutBuffer += text
158
+
159
+ // 输出到控制台
160
+ if (this.onOutput) {
161
+ this.onOutput(text, 'stdout')
162
+ } else {
163
+ process.stdout.write(text)
164
+ }
165
+
166
+ // 检测构建就绪信号(不区分大小写)
167
+ // 先去除 ANSI 颜色码再匹配,避免颜色码将关键词截断
168
+ if (!resolved && !this.isReady) {
169
+ const cleanBuffer = stdoutBuffer.replace(ANSI_REGEX, '')
170
+ const lowerBuffer = cleanBuffer.toLowerCase()
171
+ for (const signal of READY_SIGNALS) {
172
+ if (lowerBuffer.includes(signal.toLowerCase())) {
173
+ this.isReady = true
174
+ resolved = true
175
+ signale.success('dev 构建已完成,bundle 已就绪')
176
+ if (this.onReady) this.onReady()
177
+ resolve()
178
+ break
179
+ }
180
+ }
181
+ }
182
+ })
183
+
184
+ this.childProcess.stderr.on('data', (data) => {
185
+ const text = data.toString()
186
+ stderrBuffer += text
187
+
188
+ if (this.onOutput) {
189
+ this.onOutput(text, 'stderr')
190
+ } else {
191
+ process.stderr.write(text)
192
+ }
193
+ })
194
+
195
+ this.childProcess.on('error', (err) => {
196
+ signale.error('dev 构建进程启动失败:', err.message)
197
+ if (this.onError) this.onError(err)
198
+ if (!resolved) {
199
+ resolved = true
200
+ reject(err)
201
+ }
202
+ })
203
+
204
+ this.childProcess.on('exit', (code, signal) => {
205
+ this.childProcess = null
206
+
207
+ if (code !== 0 && code !== null) {
208
+ // 检查编译是否实际已完成(即使 READY_SIGNALS 没匹配到)
209
+ // 场景:webpack 编译输出 "[built]" 但进程随后因 adb 等原因退出
210
+ const compilationDone = this.isReady || this._checkCompilationDone(stdoutBuffer)
211
+ const bundleExists = !!this.detectExistingBundle()
212
+
213
+ if (compilationDone && bundleExists) {
214
+ signale.warn(`dev 进程已退出 (code: ${code}),但编译产物已生成,web 预览不受影响`)
215
+ this.isReady = true
216
+ if (!resolved) {
217
+ resolved = true
218
+ if (this.onReady) this.onReady()
219
+ resolve()
220
+ }
221
+ return
222
+ }
223
+
224
+ signale.warn(`dev 构建进程退出 (code: ${code}, signal: ${signal})`)
225
+
226
+ // 自动重启
227
+ if (!resolved && this._restartCount < this._maxRestarts) {
228
+ this._restartCount++
229
+ signale.pending(`正在重启 dev 构建 (第 ${this._restartCount} 次)...`)
230
+ setTimeout(() => {
231
+ this._startProcess(scriptName, resolve, reject)
232
+ }, 2000)
233
+ } else if (!resolved) {
234
+ reject(new Error(`dev 构建进程异常退出 (code: ${code})`))
235
+ }
236
+ } else {
237
+ signale.info('dev 构建进程已退出')
238
+ }
239
+ })
240
+
241
+ // 超时检测 - 60 秒内未就绪则认为可能有问题
242
+ setTimeout(() => {
243
+ if (!resolved && !this.isReady) {
244
+ // 检查 dist/dev 目录是否已有产物
245
+ const bundle = this.detectExistingBundle()
246
+ if (bundle) {
247
+ signale.warn('未检测到构建完成信号,但发现已有构建产物,尝试使用')
248
+ this.isReady = true
249
+ resolved = true
250
+ resolve()
251
+ } else {
252
+ signale.warn('等待构建完成中... (可能需要较长时间)')
253
+ }
254
+ }
255
+ }, 60000)
256
+ }
257
+
258
+ /**
259
+ * 检查 stdout 缓冲区是否包含编译完成的信号
260
+ * 用于处理编译成功但进程因非编译原因(如 adb 失败)退出的情况
261
+ */
262
+ _checkCompilationDone(stdoutBuffer) {
263
+ const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]/g
264
+ const cleanBuffer = stdoutBuffer.replace(ANSI_REGEX, '')
265
+ const lowerBuffer = cleanBuffer.toLowerCase()
266
+ return COMPILATION_DONE_SIGNALS.some((signal) => lowerBuffer.includes(signal.toLowerCase()))
267
+ }
268
+
269
+ /**
270
+ * 停止 dev 构建进程
271
+ */
272
+ stop() {
273
+ if (this.childProcess) {
274
+ signale.info('正在停止 dev 构建进程...')
275
+ this.childProcess.kill('SIGTERM')
276
+
277
+ // 强制退出
278
+ setTimeout(() => {
279
+ if (this.childProcess) {
280
+ this.childProcess.kill('SIGKILL')
281
+ this.childProcess = null
282
+ }
283
+ }, 5000)
284
+ }
285
+ }
286
+
287
+ /**
288
+ * 检查进程是否正在运行
289
+ */
290
+ isRunning() {
291
+ return this.childProcess !== null && this.childProcess.exitCode === null
292
+ }
293
+ }
294
+
295
+ module.exports = DevBuildManager