@quicktvui/web-cli 1.0.8 → 2.1.1
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.
- package/README.md +101 -113
- package/bin/qt-web-cli-watch.js +0 -0
- package/bin/qt-web-cli.js +405 -100
- package/lib/BundleWatcher.js +192 -0
- package/lib/DevBuildManager.js +295 -0
- package/lib/DevServer.js +586 -0
- package/lib/HotReloader.js +154 -0
- package/lib/index.js +52 -122
- package/package.json +42 -19
- package/templates/dev-renderer.html +370 -0
package/lib/DevServer.js
ADDED
|
@@ -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
|