@quicktvui/web-cli 1.0.8 → 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,586 @@
1
+ /**
2
+ * DevServer - 轻量静态开发服务器
3
+ *
4
+ * 职责:
5
+ * 1. 托管项目的 dist/dev/ 目录(bundle 文件 + assets)
6
+ * 2. 托管 web-runtime 的编译产物(JS 运行时)
7
+ * 3. 提供 dev-renderer HTML 页面(注入 bundle 路径 + 引入 web-runtime JS)
8
+ * 4. SSE 热更新端点
9
+ * 5. 保留 /proxy/ 代理功能
10
+ * 6. CORS 支持
11
+ */
12
+
13
+ const http = require('http')
14
+ const fs = require('fs')
15
+ const path = require('path')
16
+ const url = require('url')
17
+ const { execSync } = require('child_process')
18
+ const signale = require('signale')
19
+
20
+ const MIME_TYPES = {
21
+ '.html': 'text/html; charset=utf-8',
22
+ '.js': 'application/javascript; charset=utf-8',
23
+ '.mjs': 'application/javascript; charset=utf-8',
24
+ '.json': 'application/json; charset=utf-8',
25
+ '.css': 'text/css; charset=utf-8',
26
+ '.png': 'image/png',
27
+ '.jpg': 'image/jpeg',
28
+ '.jpeg': 'image/jpeg',
29
+ '.gif': 'image/gif',
30
+ '.webp': 'image/webp',
31
+ '.svg': 'image/svg+xml',
32
+ '.ico': 'image/x-icon',
33
+ '.woff': 'font/woff',
34
+ '.woff2': 'font/woff2',
35
+ '.ttf': 'font/ttf',
36
+ '.mp3': 'audio/mpeg',
37
+ '.mp4': 'video/mp4',
38
+ '.wav': 'audio/wav',
39
+ '.map': 'application/json',
40
+ '.bundle': 'application/javascript; charset=utf-8',
41
+ '.txt': 'text/plain; charset=utf-8',
42
+ }
43
+
44
+ class DevServer {
45
+ constructor(projectRoot, options = {}) {
46
+ this.projectRoot = projectRoot
47
+ this.port = options.port || 39001
48
+ this.distDir = path.join(projectRoot, 'dist', 'dev')
49
+ this.hotReloader = options.hotReloader || null
50
+ this.bundleUrlPath = options.bundleUrlPath || '/dist/dev/index.bundle'
51
+ this.server = null
52
+ this.templateHtml = null
53
+ this.corsOrigin = options.corsOrigin || '*'
54
+
55
+ // web-runtime 的 dist 目录
56
+ this.webRuntimeDistDir = this._findWebRuntimeDist()
57
+ this.webRuntimeScripts = [] // 扫描到的 JS 文件名列表
58
+
59
+ // 加载 HTML 模板
60
+ this._loadTemplate()
61
+
62
+ // 扫描 web-runtime 的 JS 文件
63
+ this._scanWebRuntimeScripts()
64
+ }
65
+
66
+ /**
67
+ * 查找 web-runtime 的 dist 目录
68
+ */
69
+ _findWebRuntimeDist() {
70
+ const candidates = [
71
+ // 同级 packages 目录
72
+ path.resolve(__dirname, '../../web-runtime/dist'),
73
+ // node_modules 中的包
74
+ path.join(this.projectRoot, 'node_modules/@quicktvui/web-runtime/dist'),
75
+ ]
76
+ for (const dir of candidates) {
77
+ if (fs.existsSync(dir)) {
78
+ signale.info(`找到 web-runtime: ${dir}`)
79
+ return dir
80
+ }
81
+ }
82
+ signale.warn('未找到 web-runtime dist 目录,bundle 加载功能将不可用')
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * 扫描 web-runtime/dist 下的 JS 文件
88
+ * 只引入主入口 JS(以 web-runtime. 开头),异步 chunk 由 webpack 自动加载
89
+ */
90
+ _scanWebRuntimeScripts() {
91
+ if (!this.webRuntimeDistDir) return
92
+
93
+ try {
94
+ const files = fs.readdirSync(this.webRuntimeDistDir)
95
+ const jsFiles = files.filter((f) => f.endsWith('.js') && !f.endsWith('.LICENSE.txt'))
96
+ // 找到主入口:文件名以 web-runtime. 开头且文件最大(主 bundle)
97
+ // 异步 chunk 文件较小,由 webpack runtime 自动按需加载
98
+ const mainEntry = jsFiles
99
+ .filter((f) => f.startsWith('web-runtime.'))
100
+ .sort((a, b) => {
101
+ // 按文件大小降序,最大的就是主入口
102
+ const sizeA = fs.statSync(path.join(this.webRuntimeDistDir, a)).size
103
+ const sizeB = fs.statSync(path.join(this.webRuntimeDistDir, b)).size
104
+ return sizeB - sizeA
105
+ })
106
+
107
+ // 取最大的一个作为主入口
108
+ if (mainEntry.length > 0) {
109
+ this.webRuntimeScripts = [mainEntry[0]]
110
+ } else {
111
+ // 回退:全部引入
112
+ this.webRuntimeScripts = jsFiles
113
+ }
114
+ signale.info(`web-runtime 主入口: ${this.webRuntimeScripts.join(', ')}`)
115
+ } catch (e) {
116
+ signale.warn('扫描 web-runtime 脚本失败:', e.message)
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 加载 HTML 模板
122
+ */
123
+ _loadTemplate() {
124
+ const templatePath = path.resolve(__dirname, '../templates/dev-renderer.html')
125
+ if (fs.existsSync(templatePath)) {
126
+ this.templateHtml = fs.readFileSync(templatePath, 'utf-8')
127
+ } else {
128
+ this.templateHtml = this._getInlineTemplate()
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 更新 bundle URL 路径
134
+ */
135
+ setBundleUrlPath(bundleUrlPath) {
136
+ this.bundleUrlPath = bundleUrlPath
137
+ }
138
+
139
+ /**
140
+ * 启动服务器
141
+ */
142
+ async start() {
143
+ // 先用端口绑定检测端口是否被占用(最可靠的方式)
144
+ const portAvailable = await this._isPortAvailable(this.port)
145
+ if (!portAvailable) {
146
+ // 端口被占用,尝试获取占用进程信息
147
+ const processInfo = this._getPortProcessInfo(this.port)
148
+ signale.warn(`端口 ${this.port} 已被占用`)
149
+ const killed = await this._promptKillPort(this.port, processInfo)
150
+ if (!killed) {
151
+ throw new Error(`端口 ${this.port} 已被占用,请使用 --port 指定其他端口`)
152
+ }
153
+ }
154
+
155
+ return new Promise((resolve, reject) => {
156
+ this.server = http.createServer((req, res) => this._handleRequest(req, res))
157
+
158
+ this.server.on('error', (err) => {
159
+ if (err.code === 'EADDRINUSE') {
160
+ signale.error(`端口 ${this.port} 已被占用,请使用 --port 指定其他端口`)
161
+ reject(err)
162
+ } else {
163
+ signale.error('服务器错误:', err.message)
164
+ reject(err)
165
+ }
166
+ })
167
+
168
+ this.server.listen(this.port, () => {
169
+ resolve()
170
+ })
171
+ })
172
+ }
173
+
174
+ /**
175
+ * 通过尝试绑定端口来检测是否可用(最可靠的方式)
176
+ */
177
+ _isPortAvailable(port) {
178
+ return new Promise((resolve) => {
179
+ const tester = require('net').createServer()
180
+ tester.once('error', (err) => {
181
+ if (err.code === 'EADDRINUSE') {
182
+ resolve(false)
183
+ } else {
184
+ resolve(false)
185
+ }
186
+ })
187
+ tester.once('listening', () => {
188
+ tester.close()
189
+ resolve(true)
190
+ })
191
+ tester.listen(port)
192
+ })
193
+ }
194
+
195
+ /**
196
+ * 获取占用端口的进程信息(用于提示用户)
197
+ * 返回 { pid, name } 或 null
198
+ */
199
+ _getPortProcessInfo(port) {
200
+ try {
201
+ const isWindows = process.platform === 'win32'
202
+ let cmd
203
+ if (isWindows) {
204
+ cmd = `netstat -ano | findstr :${port} | findstr LISTENING`
205
+ } else {
206
+ // macOS/Linux: lsof 不加 -sTCP:LISTEN 以提高兼容性
207
+ cmd = `lsof -i :${port} -P -n -t 2>/dev/null`
208
+ }
209
+ const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim()
210
+ if (!result) return null
211
+
212
+ if (isWindows) {
213
+ const match = result.match(/LISTENING\s+(\d+)/)
214
+ return match ? { pid: match[1], name: '' } : null
215
+ } else {
216
+ // lsof -t 只输出 PID
217
+ const pid = result.split('\n')[0].trim()
218
+ if (!pid) return null
219
+ // 再用 ps 获取进程名
220
+ let name = ''
221
+ try {
222
+ name = execSync(`ps -p ${pid} -o comm= 2>/dev/null`, { encoding: 'utf-8' }).trim()
223
+ } catch (e) {
224
+ /* ignore */
225
+ }
226
+ return { pid, name: name || 'unknown' }
227
+ }
228
+ } catch (e) {
229
+ return null
230
+ }
231
+ }
232
+
233
+ /**
234
+ * 提示用户是否杀掉占用端口的进程
235
+ * 使用 readline 模块读取用户输入
236
+ * 返回 true 表示已杀掉,false 表示用户拒绝
237
+ */
238
+ _promptKillPort(port, processInfo) {
239
+ return new Promise((resolve) => {
240
+ let pidInfo
241
+ if (processInfo && processInfo.pid) {
242
+ pidInfo = processInfo.name
243
+ ? `PID: ${processInfo.pid} (${processInfo.name})`
244
+ : `PID: ${processInfo.pid}`
245
+ } else {
246
+ pidInfo = '无法获取进程信息'
247
+ }
248
+
249
+ console.log('')
250
+ signale.warn(`端口 ${port} 被以下进程占用: ${pidInfo}`)
251
+ process.stdout.write(` 是否杀掉该进程并继续? [y/N] `)
252
+
253
+ // 使用 readline 从 stdin 读取一行输入
254
+ const readline = require('readline')
255
+ const rl = readline.createInterface({
256
+ input: process.stdin,
257
+ output: process.stdout,
258
+ })
259
+
260
+ // 设置超时 30 秒
261
+ const timeout = setTimeout(() => {
262
+ rl.close()
263
+ signale.error('等待用户输入超时,请手动关闭占用端口的进程')
264
+ resolve(false)
265
+ }, 30000)
266
+
267
+ rl.question('', (answer) => {
268
+ clearTimeout(timeout)
269
+ rl.close()
270
+ const confirmed = (answer || '').trim().toLowerCase() === 'y'
271
+ console.log('') // 换行
272
+
273
+ if (confirmed) {
274
+ if (!processInfo || !processInfo.pid) {
275
+ signale.error('无法获取占用端口的进程 PID,请手动查找并关闭')
276
+ resolve(false)
277
+ return
278
+ }
279
+ try {
280
+ const isWindows = process.platform === 'win32'
281
+ if (isWindows) {
282
+ execSync(`taskkill /PID ${processInfo.pid} /F`, { encoding: 'utf-8' })
283
+ } else {
284
+ execSync(`kill -9 ${processInfo.pid}`, { encoding: 'utf-8' })
285
+ }
286
+ signale.success(`已杀掉进程 ${processInfo.pid}`)
287
+ // 等待端口释放
288
+ setTimeout(() => resolve(true), 500)
289
+ } catch (e) {
290
+ signale.error(`杀掉进程失败: ${e.message}`)
291
+ resolve(false)
292
+ }
293
+ } else {
294
+ signale.info('已取消')
295
+ resolve(false)
296
+ }
297
+ })
298
+ })
299
+ }
300
+
301
+ /**
302
+ * 停止服务器
303
+ */
304
+ stop() {
305
+ if (this.server) {
306
+ this.server.close()
307
+ this.server = null
308
+ }
309
+ if (this.hotReloader) {
310
+ this.hotReloader.close()
311
+ }
312
+ }
313
+
314
+ /**
315
+ * 处理 HTTP 请求
316
+ */
317
+ _handleRequest(req, res) {
318
+ const parsedUrl = url.parse(req.url, true)
319
+ const pathname = decodeURIComponent(parsedUrl.pathname)
320
+
321
+ // CORS 预检
322
+ if (req.method === 'OPTIONS') {
323
+ this._setCORSHeaders(res)
324
+ res.writeHead(204)
325
+ res.end()
326
+ return
327
+ }
328
+
329
+ // SSE 端点
330
+ if (pathname === '/sse' && this.hotReloader) {
331
+ this.hotReloader.handleSSE(req, res)
332
+ return
333
+ }
334
+
335
+ // 根路径 - 返回 HTML 页面
336
+ if (pathname === '/' || pathname === '/index.html') {
337
+ this._serveHtml(req, res, parsedUrl)
338
+ return
339
+ }
340
+
341
+ // web-runtime 的 JS 文件 - 映射到 web-runtime/dist/
342
+ if (pathname.startsWith('/web-runtime/')) {
343
+ const relativePath = pathname.replace('/web-runtime/', '')
344
+ if (this.webRuntimeDistDir) {
345
+ const filePath = path.join(this.webRuntimeDistDir, relativePath)
346
+ this._serveStaticFile(res, filePath, this.webRuntimeDistDir)
347
+ } else {
348
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
349
+ res.end('web-runtime not found')
350
+ }
351
+ return
352
+ }
353
+
354
+ // dist/dev/ 下的文件 - 映射到项目的 dist/dev 目录
355
+ if (pathname.startsWith('/dist/dev/')) {
356
+ const relativePath = pathname.replace('/dist/dev/', '')
357
+ const filePath = path.join(this.distDir, relativePath)
358
+ this._serveStaticFile(res, filePath, this.distDir)
359
+ return
360
+ }
361
+
362
+ // assets/ 下的文件 - 映射到 dist/dev/assets/
363
+ if (pathname.startsWith('/assets/')) {
364
+ const relativePath = pathname.replace('/assets/', '')
365
+ const filePath = path.join(this.distDir, 'assets', relativePath)
366
+ this._serveStaticFile(res, filePath, this.distDir)
367
+ return
368
+ }
369
+
370
+ // bundles-dev/ 下的文件 - 映射到 dist/dev/ (兼容 web-runtime 的路径约定)
371
+ if (pathname.startsWith('/bundles-dev/')) {
372
+ const relativePath = pathname.replace('/bundles-dev/', '')
373
+ const filePath = path.join(this.distDir, relativePath)
374
+ this._serveStaticFile(res, filePath, this.distDir)
375
+ return
376
+ }
377
+
378
+ // proxy 代理
379
+ if (pathname.startsWith('/proxy/')) {
380
+ this._handleProxy(req, res, pathname)
381
+ return
382
+ }
383
+
384
+ // web-runtime 的异步 chunk 文件(webpack 会从 publicPath='/' 加载)
385
+ // 匹配 /web-runtime.xxx.js 模式
386
+ if (pathname.match(/^\/web-runtime\.[\w.]+\.js$/)) {
387
+ const filename = pathname.substring(1) // 去掉前导 /
388
+ if (this.webRuntimeDistDir) {
389
+ const filePath = path.join(this.webRuntimeDistDir, filename)
390
+ this._serveStaticFile(res, filePath, this.webRuntimeDistDir)
391
+ } else {
392
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
393
+ res.end('web-runtime not found')
394
+ }
395
+ return
396
+ }
397
+
398
+ // 404
399
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
400
+ res.end('Not Found')
401
+ }
402
+
403
+ /**
404
+ * 提供 HTML 页面
405
+ * 渲染 dev-renderer.html 模板,注入 web-runtime script 标签和 bundle 路径
406
+ */
407
+ _serveHtml(req, res, parsedUrl) {
408
+ // 从 URL 参数获取 bundle 路径,或使用默认值
409
+ const bundleParam = parsedUrl.query.bundle
410
+ const bundlePath = bundleParam || this.bundleUrlPath
411
+ const watch = parsedUrl.query.watch !== 'false' ? 'true' : 'false'
412
+
413
+ let html = this.templateHtml
414
+
415
+ // 替换模板变量
416
+ html = html.replace(/\{\{BUNDLE_PATH\}\}/g, bundlePath)
417
+ html = html.replace(/\{\{WATCH_ENABLED\}\}/g, watch)
418
+ html = html.replace(/\{\{SSE_ENDPOINT\}\}/g, '/sse')
419
+
420
+ // 注入 web-runtime 的 script 标签
421
+ // 替换 {{WEB_RUNTIME_SCRIPTS}} 占位符
422
+ let scriptTags = ''
423
+ if (this.webRuntimeScripts.length > 0) {
424
+ scriptTags = this.webRuntimeScripts
425
+ .map((s) => `<script src="/web-runtime/${s}"></script>`)
426
+ .join('\n')
427
+ }
428
+ html = html.replace(/\{\{WEB_RUNTIME_SCRIPTS\}\}/g, scriptTags)
429
+
430
+ res.writeHead(200, {
431
+ 'Content-Type': 'text/html; charset=utf-8',
432
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
433
+ })
434
+ res.end(html)
435
+ }
436
+
437
+ /**
438
+ * 提供静态文件
439
+ * @param {http.ServerResponse} res
440
+ * @param {string} filePath - 文件绝对路径
441
+ * @param {string} allowedDir - 允许的根目录(安全检查)
442
+ */
443
+ _serveStaticFile(res, filePath, allowedDir) {
444
+ // 安全检查:防止路径遍历
445
+ const resolvedPath = path.resolve(filePath)
446
+ const allowedRoot = path.resolve(allowedDir || this.distDir)
447
+ if (!resolvedPath.startsWith(allowedRoot)) {
448
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
449
+ res.end('Forbidden')
450
+ return
451
+ }
452
+
453
+ if (!fs.existsSync(resolvedPath)) {
454
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
455
+ res.end('Not Found')
456
+ return
457
+ }
458
+
459
+ const stat = fs.statSync(resolvedPath)
460
+ if (stat.isDirectory()) {
461
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
462
+ res.end('Directory listing not allowed')
463
+ return
464
+ }
465
+
466
+ const ext = path.extname(resolvedPath).toLowerCase()
467
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream'
468
+
469
+ res.writeHead(200, {
470
+ 'Content-Type': contentType,
471
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
472
+ 'Access-Control-Allow-Origin': this.corsOrigin,
473
+ })
474
+
475
+ const readStream = fs.createReadStream(resolvedPath)
476
+ readStream.pipe(res)
477
+ }
478
+
479
+ /**
480
+ * 处理代理请求
481
+ */
482
+ _handleProxy(req, res, pathname) {
483
+ // /proxy/https/example.com/path → https://example.com/path
484
+ const match = pathname.match(/^\/proxy\/(https?)\/([^/]+)(\/.*)?$/)
485
+ if (!match) {
486
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
487
+ res.end('Invalid proxy URL format. Use: /proxy/{http|https}/{host}/{path}')
488
+ return
489
+ }
490
+
491
+ const protocol = match[1]
492
+ const host = match[2]
493
+ const proxyPath = match[3] || '/'
494
+
495
+ const options = {
496
+ hostname: host,
497
+ port: protocol === 'https' ? 443 : 80,
498
+ path: proxyPath,
499
+ method: req.method,
500
+ headers: {
501
+ ...req.headers,
502
+ host: host,
503
+ },
504
+ }
505
+
506
+ const httpModule = protocol === 'https' ? require('https') : require('http')
507
+ const proxyReq = httpModule.request(options, (proxyRes) => {
508
+ res.writeHead(proxyRes.statusCode, proxyRes.headers)
509
+ proxyRes.pipe(res)
510
+ })
511
+
512
+ proxyReq.on('error', (err) => {
513
+ res.writeHead(502, { 'Content-Type': 'text/plain' })
514
+ res.end(`Proxy error: ${err.message}`)
515
+ })
516
+
517
+ req.pipe(proxyReq)
518
+ }
519
+
520
+ /**
521
+ * 设置 CORS 头
522
+ */
523
+ _setCORSHeaders(res) {
524
+ res.setHeader('Access-Control-Allow-Origin', this.corsOrigin)
525
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
526
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
527
+ }
528
+
529
+ /**
530
+ * 内联 HTML 模板(备用)
531
+ */
532
+ _getInlineTemplate() {
533
+ return `<!DOCTYPE html>
534
+ <html lang="zh-CN">
535
+ <head>
536
+ <meta charset="UTF-8">
537
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
538
+ <title>QuickTVUI Web Dev</title>
539
+ <script>
540
+ var TV_WIDTH = 1920;
541
+ var TV_HEIGHT = 1080;
542
+ var _originalInnerWidth = window.innerWidth;
543
+ var _originalInnerHeight = window.innerHeight;
544
+ Object.defineProperty(window, 'innerWidth', { get: function() { return TV_WIDTH; }, configurable: true });
545
+ Object.defineProperty(window, 'innerHeight', { get: function() { return TV_HEIGHT; }, configurable: true });
546
+ Object.defineProperty(window, 'devicePixelRatio', { get: function() { return 1; }, configurable: true });
547
+ window.__BUNDLE_CONFIG__ = {
548
+ entry: '{{BUNDLE_PATH}}',
549
+ watch: {{WATCH_ENABLED}},
550
+ sseEndpoint: '{{SSE_ENDPOINT}}'
551
+ };
552
+ </script>
553
+ <style>
554
+ * { margin: 0; padding: 0; box-sizing: border-box; }
555
+ html, body { width: 100%; height: 100%; overflow: hidden; background-color: #1a1a1a; }
556
+ #main-container { width: 1920px; height: 1080px; position: absolute; top: 0; left: 0; background-color: #26292F; visibility: hidden; }
557
+ #app { width: 1920px !important; height: 1080px !important; background-color: #26292F; }
558
+ #app > * { width: 1920px !important; height: 1080px !important; position: relative !important; overflow: hidden !important; }
559
+ </style>
560
+ </head>
561
+ <body>
562
+ <div id="main-container"><div id="app"></div></div>
563
+ {{WEB_RUNTIME_SCRIPTS}}
564
+ <script>
565
+ function scaleApp() {
566
+ var c = document.getElementById('main-container');
567
+ if (c) {
568
+ var sx = _originalInnerWidth / TV_WIDTH;
569
+ var sy = _originalInnerHeight / TV_HEIGHT;
570
+ var s = Math.min(sx, sy);
571
+ c.style.transformOrigin = 'top left';
572
+ c.style.transform = 'scale(' + s + ')';
573
+ c.style.marginLeft = ((_originalInnerWidth - TV_WIDTH * s) / 2) + 'px';
574
+ c.style.marginTop = ((_originalInnerHeight - TV_HEIGHT * s) / 2) + 'px';
575
+ c.style.visibility = 'visible';
576
+ }
577
+ }
578
+ scaleApp();
579
+ window.addEventListener('resize', scaleApp);
580
+ </script>
581
+ </body>
582
+ </html>`
583
+ }
584
+ }
585
+
586
+ module.exports = DevServer