@microlee666/dom-to-pptx 1.1.4

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/cli/server.js ADDED
@@ -0,0 +1,808 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * dom-to-pptx HTTP Server - 单节点高并发服务 + MCP 支持
5
+ *
6
+ * 启动: node server.js
7
+ * 端口: 3000 (可通过 PORT 环境变量修改)
8
+ *
9
+ * API:
10
+ * POST /convert - 转换 HTML 为 PPTX (JSON 格式)
11
+ * POST /upload - 上传 HTML 文件并返回 PPTX 文件
12
+ * GET /health - 健康检查
13
+ * GET /stats - 服务状态
14
+ *
15
+ * MCP:
16
+ * POST /mcp - MCP Streamable HTTP 端点
17
+ * GET /mcp - MCP SSE 端点 (用于服务器推送)
18
+ * DELETE /mcp - MCP 会话终止
19
+ */
20
+
21
+ const http = require('http');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { chromium } = require('playwright');
25
+ const { URL } = require('url');
26
+ const crypto = require('crypto');
27
+
28
+ // MCP SDK
29
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
30
+ const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
31
+ const { z } = require('zod');
32
+
33
+ // 预加载 dom-to-pptx bundle (避免每次从 CDN 加载)
34
+ const DOM_TO_PPTX_SCRIPT = fs.readFileSync(
35
+ path.join(__dirname, 'dom-to-pptx.bundle.js'),
36
+ 'utf8'
37
+ );
38
+
39
+ // ============== 配置 ==============
40
+ const CONFIG = {
41
+ port: parseInt(process.env.PORT) || 3000,
42
+ // 浏览器池
43
+ pool: {
44
+ min: 2, // 最小浏览器数
45
+ max: parseInt(process.env.POOL_MAX) || 5, // 最大浏览器数
46
+ idleTimeout: 60000, // 空闲超时 (ms)
47
+ },
48
+ // 转换
49
+ convert: {
50
+ timeout: 60000, // 单次转换超时 (ms)
51
+ queueMax: 100, // 最大排队数
52
+ },
53
+ viewport: {
54
+ width: 1920,
55
+ height: 1080,
56
+ },
57
+ };
58
+
59
+ // ============== 浏览器池 ==============
60
+ class BrowserPool {
61
+ constructor(options) {
62
+ this.min = options.min;
63
+ this.max = options.max;
64
+ this.idleTimeout = options.idleTimeout;
65
+
66
+ this.available = []; // 可用的浏览器
67
+ this.inUse = new Set(); // 使用中的浏览器
68
+ this.pending = []; // 等待获取浏览器的请求
69
+ this.closed = false;
70
+
71
+ // 统计
72
+ this.stats = {
73
+ created: 0,
74
+ destroyed: 0,
75
+ acquired: 0,
76
+ released: 0,
77
+ timeouts: 0,
78
+ };
79
+ }
80
+
81
+ async init() {
82
+ console.log(`🚀 初始化浏览器池 (min=${this.min}, max=${this.max})...`);
83
+ const promises = [];
84
+ for (let i = 0; i < this.min; i++) {
85
+ promises.push(this._createBrowser());
86
+ }
87
+ const browsers = await Promise.all(promises);
88
+ this.available.push(...browsers);
89
+ console.log(`✅ 浏览器池就绪,当前 ${this.available.length} 个实例`);
90
+ }
91
+
92
+ async _createBrowser() {
93
+ const browser = await chromium.launch({
94
+ headless: true,
95
+ });
96
+
97
+ browser._poolCreatedAt = Date.now();
98
+ browser._poolLastUsed = Date.now();
99
+ this.stats.created++;
100
+
101
+ return browser;
102
+ }
103
+
104
+ async acquire(timeout = 30000) {
105
+ if (this.closed) {
106
+ throw new Error('Pool is closed');
107
+ }
108
+
109
+ // 有可用的,直接返回
110
+ if (this.available.length > 0) {
111
+ const browser = this.available.pop();
112
+ browser._poolLastUsed = Date.now();
113
+ this.inUse.add(browser);
114
+ this.stats.acquired++;
115
+ return browser;
116
+ }
117
+
118
+ // 没达到上限,创建新的
119
+ const total = this.available.length + this.inUse.size;
120
+ if (total < this.max) {
121
+ const browser = await this._createBrowser();
122
+ this.inUse.add(browser);
123
+ this.stats.acquired++;
124
+ return browser;
125
+ }
126
+
127
+ // 达到上限,排队等待
128
+ return new Promise((resolve, reject) => {
129
+ const timer = setTimeout(() => {
130
+ const idx = this.pending.findIndex(p => p.resolve === resolve);
131
+ if (idx !== -1) {
132
+ this.pending.splice(idx, 1);
133
+ }
134
+ this.stats.timeouts++;
135
+ reject(new Error('Acquire browser timeout'));
136
+ }, timeout);
137
+
138
+ this.pending.push({ resolve, reject, timer });
139
+ });
140
+ }
141
+
142
+ async release(browser) {
143
+ if (!this.inUse.has(browser)) {
144
+ return;
145
+ }
146
+
147
+ this.inUse.delete(browser);
148
+ this.stats.released++;
149
+ browser._poolLastUsed = Date.now();
150
+
151
+ // 检查浏览器是否还健康
152
+ let healthy = true;
153
+ try {
154
+ const contexts = browser.contexts();
155
+ // 关闭多余 context,保留一个
156
+ for (let i = 1; i < contexts.length; i++) {
157
+ await contexts[i].close().catch(() => {});
158
+ }
159
+ } catch {
160
+ healthy = false;
161
+ }
162
+
163
+ if (!healthy) {
164
+ await this._destroyBrowser(browser);
165
+ return;
166
+ }
167
+
168
+ // 有等待的请求,直接分配
169
+ if (this.pending.length > 0) {
170
+ const { resolve, timer } = this.pending.shift();
171
+ clearTimeout(timer);
172
+ browser._poolLastUsed = Date.now();
173
+ this.inUse.add(browser);
174
+ this.stats.acquired++;
175
+ resolve(browser);
176
+ return;
177
+ }
178
+
179
+ // 放回可用池
180
+ this.available.push(browser);
181
+ }
182
+
183
+ async _destroyBrowser(browser) {
184
+ try {
185
+ await browser.close();
186
+ } catch {}
187
+ this.stats.destroyed++;
188
+ }
189
+
190
+ getStats() {
191
+ return {
192
+ available: this.available.length,
193
+ inUse: this.inUse.size,
194
+ pending: this.pending.length,
195
+ total: this.available.length + this.inUse.size,
196
+ ...this.stats,
197
+ };
198
+ }
199
+
200
+ async close() {
201
+ this.closed = true;
202
+
203
+ // 拒绝所有等待的请求
204
+ for (const { reject, timer } of this.pending) {
205
+ clearTimeout(timer);
206
+ reject(new Error('Pool is closing'));
207
+ }
208
+ this.pending = [];
209
+
210
+ // 关闭所有浏览器
211
+ const all = [...this.available, ...this.inUse];
212
+ await Promise.all(all.map(b => this._destroyBrowser(b)));
213
+ this.available = [];
214
+ this.inUse.clear();
215
+ }
216
+ }
217
+
218
+ // ============== 转换器 ==============
219
+ class Converter {
220
+ constructor(pool) {
221
+ this.pool = pool;
222
+ this.queue = 0;
223
+ this.stats = {
224
+ total: 0,
225
+ success: 0,
226
+ failed: 0,
227
+ };
228
+ }
229
+
230
+ async convert(options) {
231
+ const { html, url, selector = '.slide', viewport } = options;
232
+
233
+ if (this.queue >= CONFIG.convert.queueMax) {
234
+ throw new Error('Server is busy, try again later');
235
+ }
236
+
237
+ this.queue++;
238
+ this.stats.total++;
239
+
240
+ let browser;
241
+ try {
242
+ browser = await this.pool.acquire(CONFIG.convert.timeout);
243
+ const result = await this._doConvert(browser, { html, url, selector, viewport });
244
+ this.stats.success++;
245
+ return result;
246
+ } catch (err) {
247
+ this.stats.failed++;
248
+ throw err;
249
+ } finally {
250
+ this.queue--;
251
+ if (browser) {
252
+ await this.pool.release(browser);
253
+ }
254
+ }
255
+ }
256
+
257
+ async _doConvert(browser, { html, url, selector, viewport }) {
258
+ const context = await browser.newContext({
259
+ viewport: viewport || CONFIG.viewport,
260
+ });
261
+
262
+ const page = await context.newPage();
263
+
264
+ try {
265
+ // 设置超时
266
+ page.setDefaultTimeout(CONFIG.convert.timeout);
267
+
268
+ // 加载内容 (使用 domcontentloaded 更快)
269
+ if (url) {
270
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
271
+ } else if (html) {
272
+ await page.setContent(html, { waitUntil: 'domcontentloaded' });
273
+ } else {
274
+ throw new Error('Missing html or url parameter');
275
+ }
276
+
277
+ // 短暂等待渲染
278
+ await page.waitForTimeout(100);
279
+
280
+ // 注入本地 dom-to-pptx (无需网络请求)
281
+ await page.evaluate((script) => {
282
+ const scriptEl = document.createElement('script');
283
+ scriptEl.textContent = script;
284
+ document.head.appendChild(scriptEl);
285
+ }, DOM_TO_PPTX_SCRIPT);
286
+
287
+ // 确认加载成功
288
+ await page.waitForFunction(() => typeof window.domToPptx !== 'undefined', {
289
+ timeout: 5000,
290
+ });
291
+
292
+ // 检查选择器
293
+ const elementExists = await page.evaluate((sel) => {
294
+ return document.querySelectorAll(sel).length > 0;
295
+ }, selector);
296
+
297
+ let finalSelector = selector;
298
+ if (!elementExists) {
299
+ const fallbacks = ['.slide', '#slide', '[class*="slide"]', 'body > div:first-child', 'body'];
300
+ for (const fb of fallbacks) {
301
+ const exists = await page.evaluate((sel) => document.querySelectorAll(sel).length > 0, fb);
302
+ if (exists) {
303
+ finalSelector = fb;
304
+ break;
305
+ }
306
+ }
307
+ }
308
+
309
+ // 转换
310
+ const pptxBase64 = await page.evaluate(async (sel) => {
311
+ const elements = Array.from(document.querySelectorAll(sel));
312
+ if (elements.length === 0) throw new Error('Element not found: ' + sel);
313
+ const target = elements.length === 1 ? elements[0] : elements;
314
+ const blob = await window.domToPptx.exportToPptx(target, { skipDownload: true });
315
+
316
+ return new Promise((resolve, reject) => {
317
+ const reader = new FileReader();
318
+ reader.onloadend = () => resolve(reader.result.split(',')[1]);
319
+ reader.onerror = reject;
320
+ reader.readAsDataURL(blob);
321
+ });
322
+ }, finalSelector);
323
+
324
+ return {
325
+ success: true,
326
+ data: pptxBase64,
327
+ selector: finalSelector,
328
+ };
329
+
330
+ } finally {
331
+ await context.close().catch(() => {});
332
+ }
333
+ }
334
+
335
+ getStats() {
336
+ return {
337
+ queue: this.queue,
338
+ ...this.stats,
339
+ };
340
+ }
341
+ }
342
+
343
+ // ============== MCP Server ==============
344
+ function createMcpServer(converter) {
345
+ const server = new McpServer({
346
+ name: 'dom-to-pptx',
347
+ version: '1.0.0',
348
+ });
349
+
350
+ // 注册 convert_html_to_pptx 工具 (使用 zod schema)
351
+ server.tool(
352
+ 'convert_html_to_pptx',
353
+ '将 HTML 内容或 URL 转换为 PowerPoint (PPTX) 文件。返回 base64 编码的 PPTX 数据。',
354
+ {
355
+ html: z.string().optional().describe('HTML 内容字符串。与 url 参数二选一。'),
356
+ url: z.string().optional().describe('要转换的网页 URL。与 html 参数二选一。'),
357
+ selector: z.string().optional().default('.slide').describe('要转换为幻灯片的元素选择器,默认为 ".slide"'),
358
+ viewportWidth: z.number().optional().describe('视口宽度,默认 1920'),
359
+ viewportHeight: z.number().optional().describe('视口高度,默认 1080'),
360
+ },
361
+ async (args) => {
362
+ const { html, url, selector = '.slide', viewportWidth, viewportHeight } = args;
363
+
364
+ console.log('MCP tool called with args:', JSON.stringify(args).slice(0, 200));
365
+
366
+ if (!html && !url) {
367
+ return {
368
+ content: [{ type: 'text', text: JSON.stringify({ success: false, error: '必须提供 html 或 url 参数' }) }],
369
+ isError: true,
370
+ };
371
+ }
372
+
373
+ const viewport = (viewportWidth || viewportHeight) ? {
374
+ width: viewportWidth || 1920,
375
+ height: viewportHeight || 1080,
376
+ } : undefined;
377
+
378
+ try {
379
+ const result = await converter.convert({ html, url, selector, viewport });
380
+ return {
381
+ content: [{
382
+ type: 'text',
383
+ text: JSON.stringify({
384
+ success: true,
385
+ message: 'PPTX 转换成功',
386
+ selector: result.selector,
387
+ data: result.data,
388
+ dataLength: result.data.length,
389
+ }),
390
+ }],
391
+ };
392
+ } catch (error) {
393
+ return {
394
+ content: [{ type: 'text', text: JSON.stringify({ success: false, error: error.message }) }],
395
+ isError: true,
396
+ };
397
+ }
398
+ }
399
+ );
400
+
401
+ return server;
402
+ }
403
+
404
+ // ============== HTTP 服务 ==============
405
+ function parseBody(req) {
406
+ return new Promise((resolve, reject) => {
407
+ let body = '';
408
+ req.on('data', chunk => {
409
+ body += chunk;
410
+ if (body.length > 10 * 1024 * 1024) { // 10MB limit
411
+ reject(new Error('Request body too large'));
412
+ }
413
+ });
414
+ req.on('end', () => {
415
+ try {
416
+ resolve(body ? JSON.parse(body) : {});
417
+ } catch {
418
+ reject(new Error('Invalid JSON'));
419
+ }
420
+ });
421
+ req.on('error', reject);
422
+ });
423
+ }
424
+
425
+ /**
426
+ * 解析 multipart/form-data 请求
427
+ * @param {http.IncomingMessage} req
428
+ * @returns {Promise<{fields: Object, files: Array<{name: string, filename: string, contentType: string, data: Buffer}>}>}
429
+ */
430
+ function parseMultipart(req) {
431
+ return new Promise((resolve, reject) => {
432
+ const contentType = req.headers['content-type'] || '';
433
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
434
+
435
+ if (!boundaryMatch) {
436
+ reject(new Error('Missing boundary in content-type'));
437
+ return;
438
+ }
439
+
440
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
441
+ const chunks = [];
442
+ let totalSize = 0;
443
+ const maxSize = 50 * 1024 * 1024; // 50MB limit
444
+
445
+ req.on('data', chunk => {
446
+ totalSize += chunk.length;
447
+ if (totalSize > maxSize) {
448
+ reject(new Error('File too large (max 50MB)'));
449
+ req.destroy();
450
+ return;
451
+ }
452
+ chunks.push(chunk);
453
+ });
454
+
455
+ req.on('end', () => {
456
+ try {
457
+ const buffer = Buffer.concat(chunks);
458
+ const result = { fields: {}, files: [] };
459
+
460
+ // 分割各部分
461
+ const boundaryBuffer = Buffer.from('--' + boundary);
462
+ const parts = [];
463
+ let start = 0;
464
+ let idx;
465
+
466
+ while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
467
+ if (start > 0) {
468
+ // 去除前面的 \r\n
469
+ let partStart = start;
470
+ let partEnd = idx - 2; // 去除末尾的 \r\n
471
+ if (partEnd > partStart) {
472
+ parts.push(buffer.subarray(partStart, partEnd));
473
+ }
474
+ }
475
+ start = idx + boundaryBuffer.length;
476
+ // 跳过可能的 \r\n
477
+ if (buffer[start] === 0x0d && buffer[start + 1] === 0x0a) {
478
+ start += 2;
479
+ }
480
+ }
481
+
482
+ // 解析每个部分
483
+ for (const part of parts) {
484
+ // 找到头部和内容的分隔 (\r\n\r\n)
485
+ const headerEnd = part.indexOf('\r\n\r\n');
486
+ if (headerEnd === -1) continue;
487
+
488
+ const headerStr = part.subarray(0, headerEnd).toString('utf8');
489
+ const content = part.subarray(headerEnd + 4);
490
+
491
+ // 解析头部
492
+ const headers = {};
493
+ for (const line of headerStr.split('\r\n')) {
494
+ const colonIdx = line.indexOf(':');
495
+ if (colonIdx !== -1) {
496
+ const key = line.slice(0, colonIdx).trim().toLowerCase();
497
+ const value = line.slice(colonIdx + 1).trim();
498
+ headers[key] = value;
499
+ }
500
+ }
501
+
502
+ const disposition = headers['content-disposition'] || '';
503
+ const nameMatch = disposition.match(/name="([^"]+)"/);
504
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
505
+
506
+ if (!nameMatch) continue;
507
+
508
+ const fieldName = nameMatch[1];
509
+
510
+ if (filenameMatch) {
511
+ // 这是一个文件
512
+ result.files.push({
513
+ name: fieldName,
514
+ filename: filenameMatch[1],
515
+ contentType: headers['content-type'] || 'application/octet-stream',
516
+ data: content,
517
+ });
518
+ } else {
519
+ // 这是一个普通字段
520
+ result.fields[fieldName] = content.toString('utf8');
521
+ }
522
+ }
523
+
524
+ resolve(result);
525
+ } catch (err) {
526
+ reject(new Error('Failed to parse multipart data: ' + err.message));
527
+ }
528
+ });
529
+
530
+ req.on('error', reject);
531
+ });
532
+ }
533
+
534
+ function sendJson(res, status, data) {
535
+ res.writeHead(status, {
536
+ 'Content-Type': 'application/json',
537
+ 'Access-Control-Allow-Origin': '*',
538
+ });
539
+ res.end(JSON.stringify(data));
540
+ }
541
+
542
+ function sendBinary(res, buffer, filename) {
543
+ res.writeHead(200, {
544
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
545
+ 'Content-Disposition': `attachment; filename="${filename}"`,
546
+ 'Content-Length': buffer.length,
547
+ 'Access-Control-Allow-Origin': '*',
548
+ });
549
+ res.end(buffer);
550
+ }
551
+
552
+ async function startServer() {
553
+ // 初始化
554
+ const pool = new BrowserPool(CONFIG.pool);
555
+ await pool.init();
556
+
557
+ const converter = new Converter(pool);
558
+ const startTime = Date.now();
559
+
560
+ // MCP 会话管理
561
+ const mcpTransports = new Map(); // sessionId -> transport
562
+
563
+ const server = http.createServer(async (req, res) => {
564
+ const url = new URL(req.url, `http://${req.headers.host}`);
565
+ const pathname = url.pathname;
566
+
567
+ // CORS
568
+ if (req.method === 'OPTIONS') {
569
+ res.writeHead(204, {
570
+ 'Access-Control-Allow-Origin': '*',
571
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
572
+ 'Access-Control-Allow-Headers': 'Content-Type, mcp-session-id',
573
+ });
574
+ res.end();
575
+ return;
576
+ }
577
+
578
+ try {
579
+ // ============== MCP 端点 ==============
580
+ if (pathname === '/mcp') {
581
+ // 添加 CORS 头
582
+ res.setHeader('Access-Control-Allow-Origin', '*');
583
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
584
+
585
+ if (req.method === 'POST') {
586
+ // 检查是否有现有会话
587
+ const sessionId = req.headers['mcp-session-id'];
588
+ let transport = sessionId ? mcpTransports.get(sessionId) : null;
589
+
590
+ if (!transport) {
591
+ // 创建新的 MCP 会话
592
+ transport = new StreamableHTTPServerTransport({
593
+ sessionIdGenerator: () => crypto.randomUUID(),
594
+ onsessioninitialized: (newSessionId) => {
595
+ mcpTransports.set(newSessionId, transport);
596
+ console.log(`🔗 MCP 会话已创建: ${newSessionId}`);
597
+ },
598
+ });
599
+
600
+ // 创建并连接 MCP server
601
+ const mcpServer = createMcpServer(converter);
602
+ await mcpServer.connect(transport);
603
+
604
+ // 会话关闭时清理
605
+ transport.onclose = () => {
606
+ const sid = transport.sessionId;
607
+ if (sid && mcpTransports.has(sid)) {
608
+ mcpTransports.delete(sid);
609
+ console.log(`🔌 MCP 会话已关闭: ${sid}`);
610
+ }
611
+ };
612
+ }
613
+
614
+ // 处理请求
615
+ await transport.handleRequest(req, res);
616
+ return;
617
+ }
618
+
619
+ if (req.method === 'GET') {
620
+ // SSE 连接 (用于服务器推送)
621
+ const sessionId = req.headers['mcp-session-id'];
622
+ const transport = sessionId ? mcpTransports.get(sessionId) : null;
623
+
624
+ if (!transport) {
625
+ sendJson(res, 400, { error: 'Missing or invalid session ID. Send a POST request first.' });
626
+ return;
627
+ }
628
+
629
+ await transport.handleRequest(req, res);
630
+ return;
631
+ }
632
+
633
+ if (req.method === 'DELETE') {
634
+ // 终止会话
635
+ const sessionId = req.headers['mcp-session-id'];
636
+ const transport = sessionId ? mcpTransports.get(sessionId) : null;
637
+
638
+ if (transport) {
639
+ await transport.close();
640
+ mcpTransports.delete(sessionId);
641
+ console.log(`🗑️ MCP 会话已删除: ${sessionId}`);
642
+ }
643
+
644
+ res.writeHead(204);
645
+ res.end();
646
+ return;
647
+ }
648
+
649
+ sendJson(res, 405, { error: 'Method not allowed' });
650
+ return;
651
+ }
652
+
653
+ // ============== 原有 API ==============
654
+ // 健康检查
655
+ if (pathname === '/health' && req.method === 'GET') {
656
+ const poolStats = pool.getStats();
657
+ const healthy = poolStats.available > 0 || poolStats.inUse < CONFIG.pool.max;
658
+ sendJson(res, healthy ? 200 : 503, {
659
+ status: healthy ? 'ok' : 'unhealthy',
660
+ uptime: Math.floor((Date.now() - startTime) / 1000),
661
+ mcp: {
662
+ activeSessions: mcpTransports.size,
663
+ },
664
+ });
665
+ return;
666
+ }
667
+
668
+ // 统计信息
669
+ if (pathname === '/stats' && req.method === 'GET') {
670
+ sendJson(res, 200, {
671
+ uptime: Math.floor((Date.now() - startTime) / 1000),
672
+ pool: pool.getStats(),
673
+ converter: converter.getStats(),
674
+ mcp: {
675
+ activeSessions: mcpTransports.size,
676
+ },
677
+ });
678
+ return;
679
+ }
680
+
681
+ // 转换 API
682
+ if (pathname === '/convert' && req.method === 'POST') {
683
+ const body = await parseBody(req);
684
+ const format = url.searchParams.get('format') || 'base64';
685
+
686
+ const result = await converter.convert({
687
+ html: body.html,
688
+ url: body.url,
689
+ selector: body.selector,
690
+ viewport: body.viewport,
691
+ });
692
+
693
+ if (format === 'binary') {
694
+ const buffer = Buffer.from(result.data, 'base64');
695
+ sendBinary(res, buffer, body.filename || 'output.pptx');
696
+ } else {
697
+ sendJson(res, 200, result);
698
+ }
699
+ return;
700
+ }
701
+
702
+ // 文件上传 API
703
+ if (pathname === '/upload' && req.method === 'POST') {
704
+ const contentType = req.headers['content-type'] || '';
705
+
706
+ if (!contentType.includes('multipart/form-data')) {
707
+ sendJson(res, 400, { error: 'Content-Type must be multipart/form-data' });
708
+ return;
709
+ }
710
+
711
+ const { fields, files } = await parseMultipart(req);
712
+
713
+ // 查找上传的 HTML 文件
714
+ const htmlFile = files.find(f => f.name === 'file' || f.name === 'html');
715
+ if (!htmlFile) {
716
+ sendJson(res, 400, { error: 'Missing file field. Use "file" or "html" as field name.' });
717
+ return;
718
+ }
719
+
720
+ const htmlContent = htmlFile.data.toString('utf8');
721
+ const selector = fields.selector || '.slide';
722
+ const outputFilename = fields.filename ||
723
+ htmlFile.filename.replace(/\.(html?|htm)$/i, '.pptx') ||
724
+ 'output.pptx';
725
+
726
+ // 解析 viewport
727
+ let viewport;
728
+ if (fields.viewport) {
729
+ try {
730
+ viewport = JSON.parse(fields.viewport);
731
+ } catch {
732
+ // 忽略无效的 viewport
733
+ }
734
+ }
735
+
736
+ const result = await converter.convert({
737
+ html: htmlContent,
738
+ selector,
739
+ viewport,
740
+ });
741
+
742
+ // 上传接口直接返回文件
743
+ const buffer = Buffer.from(result.data, 'base64');
744
+ sendBinary(res, buffer, outputFilename);
745
+ return;
746
+ }
747
+
748
+ // 404
749
+ sendJson(res, 404, { error: 'Not found' });
750
+
751
+ } catch (err) {
752
+ console.error(`❌ Error: ${err.message}`);
753
+ sendJson(res, 500, { error: err.message });
754
+ }
755
+ });
756
+
757
+ // 优雅关闭
758
+ const shutdown = async (signal) => {
759
+ console.log(`\n📴 收到 ${signal},正在关闭...`);
760
+
761
+ // 关闭所有 MCP 会话
762
+ for (const [sessionId, transport] of mcpTransports) {
763
+ try {
764
+ await transport.close();
765
+ console.log(`🔌 MCP 会话已关闭: ${sessionId}`);
766
+ } catch {}
767
+ }
768
+ mcpTransports.clear();
769
+
770
+ server.close(() => {
771
+ console.log('🔌 HTTP 服务已停止');
772
+ });
773
+
774
+ await pool.close();
775
+ console.log('🌐 浏览器池已关闭');
776
+ process.exit(0);
777
+ };
778
+
779
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
780
+ process.on('SIGINT', () => shutdown('SIGINT'));
781
+
782
+ server.listen(CONFIG.port, () => {
783
+ console.log(`
784
+ ╔═══════════════════════════════════════════════════╗
785
+ ║ dom-to-pptx Server 已启动 ║
786
+ ╠═══════════════════════════════════════════════════╣
787
+ ║ 地址: http://localhost:${CONFIG.port.toString().padEnd(28)}║
788
+ ║ 浏览器池: ${CONFIG.pool.min}-${CONFIG.pool.max} 个实例${' '.repeat(23)}║
789
+ ╠═══════════════════════════════════════════════════╣
790
+ ║ HTTP API: ║
791
+ ║ POST /convert - 转换 HTML 为 PPTX (JSON) ║
792
+ ║ POST /upload - 上传文件并返回 PPTX ║
793
+ ║ GET /health - 健康检查 ║
794
+ ║ GET /stats - 服务状态 ║
795
+ ╠═══════════════════════════════════════════════════╣
796
+ ║ MCP (Streamable HTTP): ║
797
+ ║ POST /mcp - MCP 请求端点 ║
798
+ ║ GET /mcp - MCP SSE 端点 ║
799
+ ║ DELETE /mcp - 终止 MCP 会话 ║
800
+ ╚═══════════════════════════════════════════════════╝
801
+ `);
802
+ });
803
+ }
804
+
805
+ startServer().catch(err => {
806
+ console.error('启动失败:', err);
807
+ process.exit(1);
808
+ });