@ashraf_mizo/htmlcanvas 1.0.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.
package/server.js ADDED
@@ -0,0 +1,588 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer } from 'ws';
4
+ import { createServer as netCreate } from 'net';
5
+ import { fileURLToPath } from 'url';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import fs from 'fs/promises';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ import chokidar from 'chokidar';
12
+ import multer from 'multer';
13
+ import { chromium } from 'playwright';
14
+ import { hasVarReference, parseInlineStyle } from './editor/cssVars.js';
15
+
16
+ const execAsync = promisify(exec);
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ // ── Config ──────────────────────────────────────────────────────────────────
21
+ const CONFIG_DIR = path.join(os.homedir(), '.htmlcanvas');
22
+ const RECENTS_FILE = path.join(CONFIG_DIR, 'recents.json');
23
+ const COMPONENTS_DIR = path.join(CONFIG_DIR, 'components');
24
+ const UPLOADS_DIR = path.join(CONFIG_DIR, 'uploads');
25
+ const MAX_RECENTS = 10;
26
+
27
+ // ── Multer (image upload) ─────────────────────────────────────────────────────
28
+ const storage = multer.diskStorage({
29
+ destination: UPLOADS_DIR,
30
+ filename: (req, file, cb) => {
31
+ const ext = path.extname(file.originalname) || '';
32
+ cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
33
+ }
34
+ });
35
+ const upload = multer({ storage });
36
+
37
+ // ── Module-level state ───────────────────────────────────────────────────────
38
+ let currentFile = null; // { path, content, filename }
39
+ let annotatedContent = null; // Annotated HTML with data-hc-id attributes (from editor)
40
+ let previewMiddleware = null;
41
+ let fileWatcher = null;
42
+ let wsClients = new Set();
43
+
44
+ // ── Helpers: port selection ──────────────────────────────────────────────────
45
+ function probePort(port) {
46
+ return new Promise((resolve) => {
47
+ const server = netCreate();
48
+ server.once('error', () => resolve(false));
49
+ server.once('listening', () => { server.close(); resolve(true); });
50
+ server.listen(port, '127.0.0.1');
51
+ });
52
+ }
53
+
54
+ async function findAvailablePort(start = 3000, max = 3010) {
55
+ for (let port = start; port <= max; port++) {
56
+ if (await probePort(port)) return port;
57
+ }
58
+ throw new Error(`No available port in range ${start}–${max}`);
59
+ }
60
+
61
+ // ── Helpers: recents ────────────────────────────────────────────────────────
62
+ async function getRecents() {
63
+ try {
64
+ const raw = await fs.readFile(RECENTS_FILE, 'utf-8');
65
+ const paths = JSON.parse(raw);
66
+ if (!Array.isArray(paths)) return [];
67
+ const checks = await Promise.all(
68
+ paths.map(p =>
69
+ fs.access(p).then(() => p).catch(() => null)
70
+ )
71
+ );
72
+ return checks.filter(Boolean);
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ async function addRecent(filePath) {
79
+ const recents = await getRecents();
80
+ const deduped = [filePath, ...recents.filter(p => p !== filePath)].slice(0, MAX_RECENTS);
81
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
82
+ await fs.writeFile(RECENTS_FILE, JSON.stringify(deduped, null, 2), 'utf-8');
83
+ }
84
+
85
+ // ── Helpers: preview directory ───────────────────────────────────────────────
86
+ function setPreviewDirectory(dirPath) {
87
+ previewMiddleware = express.static(dirPath);
88
+ }
89
+
90
+ // ── Helpers: CSS variable audit (Plan 02) ────────────────────────────────────
91
+ /**
92
+ * Scans HTML content for inline style attributes and counts how many CSS
93
+ * property values contain var() references. Diagnostic only — never blocks.
94
+ * @param {string} content - Raw HTML string
95
+ * @returns {number} Count of var() references found across all inline styles
96
+ */
97
+ function auditCSSVariables(content) {
98
+ const styleRegex = /style="([^"]*)"/g;
99
+ let varCount = 0;
100
+ let match;
101
+ while ((match = styleRegex.exec(content)) !== null) {
102
+ const props = parseInlineStyle(match[1]);
103
+ for (const value of Object.values(props)) {
104
+ if (hasVarReference(value)) varCount++;
105
+ }
106
+ }
107
+ return varCount;
108
+ }
109
+
110
+ // ── Helpers: file watcher ────────────────────────────────────────────────────
111
+ function watchFile(filePath) {
112
+ if (fileWatcher) {
113
+ fileWatcher.close();
114
+ fileWatcher = null;
115
+ }
116
+ fileWatcher = chokidar.watch(filePath, { ignoreInitial: true });
117
+ fileWatcher.on('change', () => {
118
+ broadcastToClients({ type: 'file-changed', path: filePath });
119
+ });
120
+ }
121
+
122
+ // ── Helpers: WebSocket broadcast ────────────────────────────────────────────
123
+ function broadcastToClients(message) {
124
+ const data = JSON.stringify(message);
125
+ for (const client of wsClients) {
126
+ if (client.readyState === 1) {
127
+ client.send(data);
128
+ }
129
+ }
130
+ }
131
+
132
+ // ── Express app ─────────────────────────────────────────────────────────────
133
+ const app = express();
134
+ app.use(express.json({ limit: '10mb' }));
135
+
136
+ // Serve editor shell
137
+ app.use('/', express.static(path.join(__dirname, 'editor')));
138
+
139
+ // Preview route — serves the opened file's parent directory
140
+ app.use('/preview', (req, res, next) => {
141
+ if (previewMiddleware) {
142
+ previewMiddleware(req, res, next);
143
+ } else {
144
+ res.status(404).send('No file open');
145
+ }
146
+ });
147
+
148
+ // Uploads route — serves uploaded images from ~/.htmlcanvas/uploads/
149
+ app.use('/uploads', express.static(UPLOADS_DIR));
150
+
151
+ // ── API: set annotated HTML (DOM model with data-hc-id) ──────────────────────
152
+ app.post('/api/set-annotated', (req, res) => {
153
+ const { content } = req.body;
154
+ if (typeof content !== 'string') {
155
+ return res.status(400).json({ error: 'content must be a string' });
156
+ }
157
+ annotatedContent = content;
158
+ res.json({ ok: true });
159
+ });
160
+
161
+ // ── Route: render opened file with asset resolution ─────────────────────────
162
+ app.get('/render', (req, res) => {
163
+ const rawContent = annotatedContent || (currentFile && currentFile.content);
164
+ if (!rawContent) {
165
+ return res.status(404).send('No file open');
166
+ }
167
+ let html = rawContent;
168
+
169
+ if (currentFile.path) {
170
+ // Inject <base> tag for relative asset resolution via /preview/
171
+ const baseHref = '/preview/';
172
+ if (/<head([^>]*)>/i.test(html)) {
173
+ html = html.replace(/<head([^>]*)>/i, '<head$1><base href="' + baseHref + '">');
174
+ } else {
175
+ html = '<base href="' + baseHref + '">' + html;
176
+ }
177
+ // Rewrite root-relative src/href to go through /preview
178
+ html = html.replace(/(src|href)=["']\/(?!\/)/gi, '$1="/preview/');
179
+ // Rewrite root-relative url() in inline/embedded CSS
180
+ html = html.replace(/url\(\s*['"]?\/(?!\/)/gi, "url('/preview/");
181
+ } else {
182
+ console.log('Rendering drag-dropped file — local assets may not resolve');
183
+ }
184
+
185
+ // Inject editor-only styles: spacing between .page slides for clear separation
186
+ const editorStyles = `<style data-hc-editor>
187
+ .page { margin-bottom: 40px !important; box-shadow: 0 2px 16px rgba(0,0,0,0.12) !important; }
188
+ .page:last-child { margin-bottom: 0 !important; }
189
+ body { background: transparent !important; }
190
+ </style>`;
191
+ if (/<head([^>]*)>/i.test(html)) {
192
+ html = html.replace(/<\/head>/i, editorStyles + '</head>');
193
+ } else {
194
+ html = editorStyles + html;
195
+ }
196
+
197
+ res.set('Content-Type', 'text/html; charset=utf-8');
198
+ res.send(html);
199
+ });
200
+
201
+ // ── API: open dialog ─────────────────────────────────────────────────────────
202
+ app.get('/api/open-dialog', async (req, res) => {
203
+ try {
204
+ const tmpScript = path.join(os.tmpdir(), 'hc-open.ps1');
205
+ const ps1 = `
206
+ Add-Type -AssemblyName System.Windows.Forms
207
+ Add-Type @"
208
+ using System;
209
+ using System.Runtime.InteropServices;
210
+ public class Win32 {
211
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
212
+ [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
213
+ }
214
+ "@
215
+
216
+ $dialog = New-Object System.Windows.Forms.OpenFileDialog
217
+ $dialog.Filter = 'HTML Files (*.html)|*.html|All Files (*.*)|*.*'
218
+ $dialog.Title = 'Open HTML File - HTMLCanvas'
219
+
220
+ # Create a hidden topmost owner window to force dialog to front
221
+ $owner = New-Object System.Windows.Forms.Form
222
+ $owner.TopMost = $true
223
+ $owner.ShowInTaskbar = $false
224
+ $owner.WindowState = 'Minimized'
225
+ $owner.Show()
226
+ $owner.Hide()
227
+ [Win32]::SetForegroundWindow($owner.Handle) | Out-Null
228
+ $result = $dialog.ShowDialog($owner)
229
+ $owner.Dispose()
230
+
231
+ if ($result -eq 'OK') { Write-Output $dialog.FileName }
232
+ `;
233
+
234
+ await fs.writeFile(tmpScript, ps1, 'utf-8');
235
+ console.log('Opening file dialog...');
236
+ const { stdout, stderr } = await execAsync(
237
+ `powershell -NoProfile -ExecutionPolicy Bypass -STA -File "${tmpScript}"`,
238
+ { windowsHide: false }
239
+ );
240
+ if (stderr) console.warn('PowerShell stderr:', stderr);
241
+
242
+ const trimmed = stdout.trim();
243
+ if (!trimmed) return res.json({ cancelled: true });
244
+
245
+ const resolved = path.resolve(trimmed);
246
+ const content = await fs.readFile(resolved, 'utf-8');
247
+ await addRecent(resolved);
248
+
249
+ currentFile = { path: resolved, content, filename: path.basename(resolved) };
250
+ annotatedContent = null; // Clear stale annotated content
251
+ setPreviewDirectory(path.dirname(resolved));
252
+ watchFile(resolved);
253
+
254
+ res.json({ path: resolved, content, filename: path.basename(resolved) });
255
+ } catch (err) {
256
+ console.error('Open dialog failed:', err);
257
+ res.status(500).json({ error: `File dialog failed: ${err.message}` });
258
+ }
259
+ });
260
+
261
+ // ── API: get file by path (for recent files) ─────────────────────────────────
262
+ app.get('/api/file', async (req, res) => {
263
+ const raw = req.query.path;
264
+ if (!raw) return res.status(400).json({ error: 'path query param required' });
265
+
266
+ const resolved = path.resolve(decodeURIComponent(raw));
267
+ const content = await fs.readFile(resolved, 'utf-8');
268
+ await addRecent(resolved);
269
+
270
+ currentFile = { path: resolved, content, filename: path.basename(resolved) };
271
+ annotatedContent = null; // Clear stale annotated content
272
+ setPreviewDirectory(path.dirname(resolved));
273
+ watchFile(resolved);
274
+
275
+ res.json({ path: resolved, content, filename: path.basename(resolved) });
276
+ });
277
+
278
+ // ── API: save ────────────────────────────────────────────────────────────────
279
+ app.post('/api/save', async (req, res) => {
280
+ if (!currentFile || !currentFile.path) {
281
+ return res.status(400).json({ error: 'No file with known path is open. Use Export instead.' });
282
+ }
283
+ const { content } = req.body;
284
+ if (typeof content !== 'string') {
285
+ return res.status(400).json({ error: 'content must be a string' });
286
+ }
287
+ await fs.writeFile(currentFile.path, content, 'utf-8');
288
+ currentFile.content = content;
289
+
290
+ // CSS variable audit — informational only (Plan 02)
291
+ const varCount = auditCSSVariables(content);
292
+ console.log(`Saved ${path.basename(currentFile.path)} — ${varCount} CSS variable reference(s) preserved.`);
293
+
294
+ res.json({ ok: true, path: currentFile.path, varReferencesPreserved: varCount });
295
+ });
296
+
297
+ // ── API: export dialog ───────────────────────────────────────────────────────
298
+ app.get('/api/export-dialog', async (req, res) => {
299
+ try {
300
+ const tmpScript = path.join(os.tmpdir(), 'hc-save.ps1');
301
+ const ps1 = `
302
+ Add-Type -AssemblyName System.Windows.Forms
303
+ Add-Type @"
304
+ using System;
305
+ using System.Runtime.InteropServices;
306
+ public class Win32Export {
307
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
308
+ }
309
+ "@
310
+
311
+ $dialog = New-Object System.Windows.Forms.SaveFileDialog
312
+ $dialog.Filter = 'HTML Files (*.html)|*.html'
313
+ $dialog.Title = 'Export As - HTMLCanvas'
314
+ $dialog.FileName = 'export.html'
315
+
316
+ $owner = New-Object System.Windows.Forms.Form
317
+ $owner.TopMost = $true
318
+ $owner.ShowInTaskbar = $false
319
+ $owner.WindowState = 'Minimized'
320
+ $owner.Show()
321
+ $owner.Hide()
322
+ [Win32Export]::SetForegroundWindow($owner.Handle) | Out-Null
323
+ $result = $dialog.ShowDialog($owner)
324
+ $owner.Dispose()
325
+
326
+ if ($result -eq 'OK') { Write-Output $dialog.FileName }
327
+ `;
328
+
329
+ await fs.writeFile(tmpScript, ps1, 'utf-8');
330
+ const { stdout, stderr } = await execAsync(
331
+ `powershell -NoProfile -ExecutionPolicy Bypass -STA -File "${tmpScript}"`,
332
+ { windowsHide: false }
333
+ );
334
+ if (stderr) console.warn('PowerShell stderr:', stderr);
335
+
336
+ const trimmed = stdout.trim();
337
+ if (!trimmed) return res.json({ cancelled: true });
338
+
339
+ const resolved = path.resolve(trimmed);
340
+ res.json({ exportPath: resolved });
341
+ } catch (err) {
342
+ console.error('Export dialog failed:', err);
343
+ res.status(500).json({ error: `File dialog failed: ${err.message}` });
344
+ }
345
+ });
346
+
347
+ // ── API: export ──────────────────────────────────────────────────────────────
348
+ app.post('/api/export', async (req, res) => {
349
+ const { content, exportPath } = req.body;
350
+ if (typeof content !== 'string' || !exportPath) {
351
+ return res.status(400).json({ error: 'content and exportPath required' });
352
+ }
353
+ const resolved = path.resolve(exportPath);
354
+ await fs.writeFile(resolved, content, 'utf-8');
355
+
356
+ // CSS variable audit — informational only (Plan 02)
357
+ const varCount = auditCSSVariables(content);
358
+ console.log(`Exported ${path.basename(resolved)} — ${varCount} CSS variable reference(s) preserved.`);
359
+
360
+ res.json({ ok: true, path: resolved, varReferencesPreserved: varCount });
361
+ });
362
+
363
+ // ── API: export PDF ──────────────────────────────────────────────────────────
364
+ app.post('/api/export-pdf', async (req, res) => {
365
+ const { content } = req.body;
366
+ if (typeof content !== 'string') {
367
+ return res.status(400).json({ error: 'content must be a string' });
368
+ }
369
+
370
+ // Show save dialog for PDF
371
+ let pdfPath;
372
+ try {
373
+ const tmpScript = path.join(os.tmpdir(), 'hc-pdf.ps1');
374
+ const defaultName = currentFile?.filename
375
+ ? currentFile.filename.replace(/\.html?$/i, '.pdf')
376
+ : 'export.pdf';
377
+ const ps1 = `
378
+ Add-Type -AssemblyName System.Windows.Forms
379
+ Add-Type @"
380
+ using System;
381
+ using System.Runtime.InteropServices;
382
+ public class Win32Pdf {
383
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
384
+ }
385
+ "@
386
+
387
+ $dialog = New-Object System.Windows.Forms.SaveFileDialog
388
+ $dialog.Filter = 'PDF Files (*.pdf)|*.pdf'
389
+ $dialog.Title = 'Export as PDF - HTMLCanvas'
390
+ $dialog.FileName = '${defaultName}'
391
+
392
+ $owner = New-Object System.Windows.Forms.Form
393
+ $owner.TopMost = $true
394
+ $owner.ShowInTaskbar = $false
395
+ $owner.WindowState = 'Minimized'
396
+ $owner.Show()
397
+ $owner.Hide()
398
+ [Win32Pdf]::SetForegroundWindow($owner.Handle) | Out-Null
399
+ $result = $dialog.ShowDialog($owner)
400
+ $owner.Dispose()
401
+
402
+ if ($result -eq 'OK') { Write-Output $dialog.FileName }
403
+ `;
404
+ await fs.writeFile(tmpScript, ps1, 'utf-8');
405
+ const { stdout } = await execAsync(
406
+ `powershell -NoProfile -ExecutionPolicy Bypass -STA -File "${tmpScript}"`,
407
+ { windowsHide: false }
408
+ );
409
+ const trimmed = stdout.trim();
410
+ if (!trimmed) return res.json({ cancelled: true });
411
+ pdfPath = path.resolve(trimmed);
412
+ } catch (err) {
413
+ console.error('PDF save dialog failed:', err);
414
+ return res.status(500).json({ error: `File dialog failed: ${err.message}` });
415
+ }
416
+
417
+ // Write HTML to a temp file so Playwright can load it with assets
418
+ let tempHtmlPath = null;
419
+ let browser = null;
420
+ try {
421
+ // If we have a current file, write the HTML next to it so relative assets resolve.
422
+ // Otherwise use a temp directory.
423
+ const baseDir = currentFile?.path ? path.dirname(currentFile.path) : os.tmpdir();
424
+ tempHtmlPath = path.join(baseDir, `_hc_pdf_export_${Date.now()}.html`);
425
+ await fs.writeFile(tempHtmlPath, content, 'utf-8');
426
+
427
+ console.log('Launching Playwright for PDF export...');
428
+ browser = await chromium.launch({ headless: true });
429
+ const page = await browser.newPage();
430
+
431
+ // Load the HTML file with proper base URL for asset resolution
432
+ await page.goto(`file:///${tempHtmlPath.replace(/\\/g, '/')}`, {
433
+ waitUntil: 'networkidle',
434
+ timeout: 30000,
435
+ });
436
+
437
+ // Detect if this is a slide deck (.page elements)
438
+ const pageCount = await page.evaluate(() => document.querySelectorAll('.page').length);
439
+
440
+ let pdfOptions;
441
+ if (pageCount > 0) {
442
+ // Slide deck: get the .page dimensions and use them as the PDF page size
443
+ const pageDims = await page.evaluate(() => {
444
+ const firstPage = document.querySelector('.page');
445
+ if (!firstPage) return null;
446
+ const style = getComputedStyle(firstPage);
447
+ return {
448
+ width: parseFloat(style.width),
449
+ height: parseFloat(style.height),
450
+ };
451
+ });
452
+
453
+ const pdfW = pageDims ? pageDims.width : 794;
454
+ const pdfH = pageDims ? pageDims.height : 1123;
455
+
456
+ pdfOptions = {
457
+ path: pdfPath,
458
+ width: `${pdfW}px`,
459
+ height: `${pdfH}px`,
460
+ printBackground: true,
461
+ margin: { top: 0, right: 0, bottom: 0, left: 0 },
462
+ };
463
+ } else {
464
+ // Regular HTML: use A4
465
+ pdfOptions = {
466
+ path: pdfPath,
467
+ format: 'A4',
468
+ printBackground: true,
469
+ margin: { top: '20px', right: '20px', bottom: '20px', left: '20px' },
470
+ };
471
+ }
472
+
473
+ await page.pdf(pdfOptions);
474
+ console.log(`PDF exported: ${path.basename(pdfPath)} (${pageCount} pages detected)`);
475
+
476
+ res.json({ ok: true, path: pdfPath, pages: pageCount });
477
+ } catch (err) {
478
+ console.error('PDF export failed:', err);
479
+ res.status(500).json({ error: `PDF export failed: ${err.message}` });
480
+ } finally {
481
+ if (browser) await browser.close().catch(() => {});
482
+ // Clean up temp HTML file
483
+ if (tempHtmlPath) {
484
+ await fs.unlink(tempHtmlPath).catch(() => {});
485
+ }
486
+ }
487
+ });
488
+
489
+ // ── API: register drag-and-drop ──────────────────────────────────────────────
490
+ app.post('/api/register-drag', (req, res) => {
491
+ const { content, filename } = req.body;
492
+ if (typeof content !== 'string' || !filename) {
493
+ return res.status(400).json({ error: 'content and filename required' });
494
+ }
495
+ currentFile = { path: null, content, filename };
496
+ res.json({ ok: true, pathKnown: false });
497
+ });
498
+
499
+ // ── API: recents ─────────────────────────────────────────────────────────────
500
+ app.get('/api/recents', async (req, res) => {
501
+ const recents = await getRecents();
502
+ res.json({ recents });
503
+ });
504
+
505
+ // ── API: assets (list sibling asset folder) ──────────────────────────────────
506
+ app.get('/api/assets', async (req, res) => {
507
+ if (!currentFile?.path) return res.json({ assets: [] });
508
+ const fileDir = path.dirname(currentFile.path);
509
+ const candidates = ['assets', 'images', 'img', 'media', 'Photos Assets'];
510
+ let assetDir = null;
511
+ for (const name of candidates) {
512
+ const candidate = path.join(fileDir, name);
513
+ try { await fs.access(candidate); assetDir = candidate; break; } catch {}
514
+ }
515
+ if (!assetDir) return res.json({ assets: [], dir: null });
516
+ const files = await fs.readdir(assetDir);
517
+ const assets = files
518
+ .filter(f => /\.(png|jpg|jpeg|gif|svg|webp|avif)$/i.test(f))
519
+ .map(f => ({
520
+ name: f,
521
+ url: `/preview/${path.basename(assetDir)}/${f}`,
522
+ type: f.toLowerCase().endsWith('.svg') ? 'svg' : 'image'
523
+ }));
524
+ res.json({ assets, dir: path.basename(assetDir) });
525
+ });
526
+
527
+ // ── API: components (GET — list saved components) ─────────────────────────────
528
+ app.get('/api/components', async (req, res) => {
529
+ await fs.mkdir(COMPONENTS_DIR, { recursive: true });
530
+ const files = await fs.readdir(COMPONENTS_DIR);
531
+ const components = await Promise.all(
532
+ files.filter(f => f.endsWith('.html')).map(async f => ({
533
+ name: f.replace('.html', ''),
534
+ html: await fs.readFile(path.join(COMPONENTS_DIR, f), 'utf-8'),
535
+ }))
536
+ );
537
+ res.json({ components });
538
+ });
539
+
540
+ // ── API: components (POST — save component) ───────────────────────────────────
541
+ app.post('/api/components', async (req, res) => {
542
+ const { name, html } = req.body;
543
+ if (!name || !html) return res.status(400).json({ error: 'name and html required' });
544
+ await fs.mkdir(COMPONENTS_DIR, { recursive: true });
545
+ const safeName = name.replace(/[^a-z0-9-_]/gi, '-');
546
+ await fs.writeFile(path.join(COMPONENTS_DIR, `${safeName}.html`), html, 'utf-8');
547
+ res.json({ ok: true, name: safeName });
548
+ });
549
+
550
+ // ── API: upload (POST — image upload via multer) ──────────────────────────────
551
+ app.post('/api/upload', (req, res) => {
552
+ upload.single('file')(req, res, (err) => {
553
+ if (err) {
554
+ console.error('Upload error:', err);
555
+ return res.status(500).json({ error: `Upload failed: ${err.message}` });
556
+ }
557
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
558
+ const servePath = `/uploads/${req.file.filename}`;
559
+ res.json({ url: servePath, originalName: req.file.originalname });
560
+ });
561
+ });
562
+
563
+ // ── Global error handler ─────────────────────────────────────────────────────
564
+ app.use((err, req, res, _next) => {
565
+ console.error(err);
566
+ res.status(500).json({ error: err.message });
567
+ });
568
+
569
+ // ── Startup ──────────────────────────────────────────────────────────────────
570
+ await fs.mkdir(UPLOADS_DIR, { recursive: true });
571
+ const port = await findAvailablePort();
572
+ const httpServer = createServer(app);
573
+
574
+ // WebSocket server
575
+ const wss = new WebSocketServer({ server: httpServer });
576
+ wss.on('connection', (ws) => {
577
+ wsClients.add(ws);
578
+ ws.on('close', () => wsClients.delete(ws));
579
+ });
580
+
581
+ httpServer.listen(port, () => {
582
+ console.log(`HTMLCanvas running at http://localhost:${port}`);
583
+ try {
584
+ exec(`start http://localhost:${port}`);
585
+ } catch (e) {
586
+ console.warn('Could not auto-open browser:', e.message);
587
+ }
588
+ });