what-framework-cli 0.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.
Files changed (2) hide show
  1. package/package.json +31 -0
  2. package/src/cli.js +888 -0
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "what-framework-cli",
3
+ "version": "0.1.0",
4
+ "description": "What Framework CLI - Dev server, build, and deployment tools",
5
+ "type": "module",
6
+ "bin": {
7
+ "what": "./src/cli.js"
8
+ },
9
+ "main": "src/cli.js",
10
+ "exports": {
11
+ ".": "./src/cli.js"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "keywords": [
17
+ "cli",
18
+ "dev-server",
19
+ "build-tool",
20
+ "what-framework"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "what-framework": "^0.1.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/aspect/what-fw.git"
30
+ }
31
+ }
package/src/cli.js ADDED
@@ -0,0 +1,888 @@
1
+ #!/usr/bin/env node
2
+
3
+ // What Framework - CLI
4
+ // Commands: dev, build, preview, generate
5
+
6
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync, copyFileSync, realpathSync } from 'fs';
7
+ import { join, resolve, relative, extname, basename, normalize } from 'path';
8
+ import { createServer } from 'http';
9
+ import { fileURLToPath } from 'url';
10
+ import { createHash } from 'crypto';
11
+ import { gzipSync } from 'zlib';
12
+
13
+ // Security: Prevent path traversal attacks
14
+ function safePath(base, userPath) {
15
+ try {
16
+ // Reject paths that contain .. segments (path traversal attempt)
17
+ const normalized = normalize(userPath);
18
+ if (normalized.startsWith('..') || normalized.includes('/..') || normalized.includes('\\..')) {
19
+ return null;
20
+ }
21
+
22
+ // Get the real base path (resolve symlinks)
23
+ const realBase = realpathSync(base);
24
+
25
+ // Resolve the user path against the base
26
+ const resolved = resolve(realBase, normalized);
27
+
28
+ // Double-check: ensure resolved path is within base
29
+ if (!resolved.startsWith(realBase + '/') && resolved !== realBase) {
30
+ return null;
31
+ }
32
+
33
+ return resolved;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // Simple WebSocket implementation using native Node.js APIs (no external deps)
40
+ class SimpleWebSocketServer {
41
+ constructor({ server }) {
42
+ this.clients = new Set();
43
+ server.on('upgrade', (req, socket, head) => {
44
+ if (req.headers.upgrade?.toLowerCase() !== 'websocket') return;
45
+
46
+ const key = req.headers['sec-websocket-key'];
47
+ const accept = createHash('sha1')
48
+ .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
49
+ .digest('base64');
50
+
51
+ socket.write([
52
+ 'HTTP/1.1 101 Switching Protocols',
53
+ 'Upgrade: websocket',
54
+ 'Connection: Upgrade',
55
+ `Sec-WebSocket-Accept: ${accept}`,
56
+ '', ''
57
+ ].join('\r\n'));
58
+
59
+ const client = new SimpleWebSocket(socket);
60
+ this.clients.add(client);
61
+ client.onclose = () => this.clients.delete(client);
62
+ this.onconnection?.(client);
63
+ });
64
+ }
65
+ on(event, handler) {
66
+ if (event === 'connection') this.onconnection = handler;
67
+ }
68
+ }
69
+
70
+ class SimpleWebSocket {
71
+ constructor(socket) {
72
+ this.socket = socket;
73
+ this.socket.on('close', () => this.onclose?.());
74
+ this.socket.on('error', () => this.onclose?.());
75
+ this.socket.on('data', (data) => this._handleData(data));
76
+ }
77
+
78
+ _handleData(buffer) {
79
+ // Simple WebSocket frame parsing (text frames only)
80
+ try {
81
+ const firstByte = buffer[0];
82
+ const opcode = firstByte & 0x0f;
83
+ if (opcode === 0x08) { this.socket.end(); return; } // Close frame
84
+
85
+ const secondByte = buffer[1];
86
+ let payloadLength = secondByte & 0x7f;
87
+ let offset = 2;
88
+
89
+ if (payloadLength === 126) {
90
+ payloadLength = buffer.readUInt16BE(2);
91
+ offset = 4;
92
+ } else if (payloadLength === 127) {
93
+ payloadLength = Number(buffer.readBigUInt64BE(2));
94
+ offset = 10;
95
+ }
96
+
97
+ const masked = (secondByte & 0x80) !== 0;
98
+ let maskKey;
99
+ if (masked) {
100
+ maskKey = buffer.slice(offset, offset + 4);
101
+ offset += 4;
102
+ }
103
+
104
+ let payload = buffer.slice(offset, offset + payloadLength);
105
+ if (masked) {
106
+ for (let i = 0; i < payload.length; i++) {
107
+ payload[i] ^= maskKey[i % 4];
108
+ }
109
+ }
110
+
111
+ if (opcode === 0x01) { // Text frame
112
+ this.onmessage?.({ data: payload.toString('utf8') });
113
+ }
114
+ } catch (e) {}
115
+ }
116
+
117
+ send(data) {
118
+ try {
119
+ const payload = Buffer.from(data, 'utf8');
120
+ const length = payload.length;
121
+ let header;
122
+
123
+ if (length < 126) {
124
+ header = Buffer.alloc(2);
125
+ header[0] = 0x81; // FIN + text opcode
126
+ header[1] = length;
127
+ } else if (length < 65536) {
128
+ header = Buffer.alloc(4);
129
+ header[0] = 0x81;
130
+ header[1] = 126;
131
+ header.writeUInt16BE(length, 2);
132
+ } else {
133
+ header = Buffer.alloc(10);
134
+ header[0] = 0x81;
135
+ header[1] = 127;
136
+ header.writeBigUInt64BE(BigInt(length), 2);
137
+ }
138
+
139
+ this.socket.write(Buffer.concat([header, payload]));
140
+ } catch (e) {}
141
+ }
142
+
143
+ close() {
144
+ try {
145
+ const closeFrame = Buffer.from([0x88, 0x00]);
146
+ this.socket.write(closeFrame);
147
+ this.socket.end();
148
+ } catch (e) {}
149
+ }
150
+ }
151
+
152
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
153
+ const cwd = process.cwd();
154
+
155
+ const args = process.argv.slice(2);
156
+ const command = args[0];
157
+
158
+ const commands = { dev, build, preview, generate, init };
159
+
160
+ if (!command || !commands[command]) {
161
+ console.log(`
162
+ what - The closest framework to vanilla JS
163
+
164
+ Usage: what <command>
165
+
166
+ Commands:
167
+ dev Start dev server with HMR
168
+ build Production build
169
+ preview Preview production build
170
+ generate Static site generation
171
+ init Create a new project
172
+
173
+ Options:
174
+ --port Dev server port (default: 3000)
175
+ --host Dev server host (default: localhost)
176
+ `);
177
+ process.exit(0);
178
+ }
179
+
180
+ commands[command]();
181
+
182
+ // --- Dev Server ---
183
+
184
+ async function dev() {
185
+ const port = getFlag('--port', 3000);
186
+ const host = getFlag('--host', 'localhost');
187
+ const config = loadConfig();
188
+
189
+ const server = createServer(async (req, res) => {
190
+ const url = new URL(req.url, `http://${host}:${port}`);
191
+ let pathname = url.pathname;
192
+
193
+ // Handle server actions
194
+ if (pathname === '/__what_action' && req.method === 'POST') {
195
+ const actionId = req.headers['x-what-action'];
196
+ let body = '';
197
+ req.on('data', chunk => body += chunk);
198
+ req.on('end', async () => {
199
+ try {
200
+ const { args } = JSON.parse(body);
201
+ // In production, this would call the registered action
202
+ // For dev, we'll return a placeholder response
203
+ res.writeHead(200, { 'Content-Type': 'application/json' });
204
+ res.end(JSON.stringify({
205
+ _action: actionId,
206
+ _dev: true,
207
+ message: 'Server actions require production build with action registration',
208
+ }));
209
+ } catch (e) {
210
+ res.writeHead(400, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({ message: e.message }));
212
+ }
213
+ });
214
+ return;
215
+ }
216
+
217
+ // Serve framework modules
218
+ if (pathname.startsWith('/@what/')) {
219
+ const modName = pathname.slice(7);
220
+ const modPath = resolveFrameworkModule(modName);
221
+ if (modPath) {
222
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
223
+ res.end(readFileSync(modPath, 'utf-8'));
224
+ return;
225
+ }
226
+ }
227
+
228
+ // Serve static files from public/
229
+ const publicDir = join(cwd, 'public');
230
+ const publicPath = existsSync(publicDir) ? safePath(publicDir, pathname) : null;
231
+ if (publicPath && existsSync(publicPath) && statSync(publicPath).isFile()) {
232
+ serveFile(res, publicPath);
233
+ return;
234
+ }
235
+
236
+ // Serve source files (JS, CSS) with transforms
237
+ const srcDir = join(cwd, 'src');
238
+ const srcPath = existsSync(srcDir) ? safePath(srcDir, pathname) : null;
239
+ if (srcPath && existsSync(srcPath) && statSync(srcPath).isFile()) {
240
+ const ext = extname(srcPath);
241
+ if (ext === '.js' || ext === '.mjs') {
242
+ res.writeHead(200, {
243
+ 'Content-Type': 'application/javascript',
244
+ 'Cache-Control': 'no-cache',
245
+ });
246
+ let code = readFileSync(srcPath, 'utf-8');
247
+ // Transform bare imports to /@what/ paths
248
+ code = transformImports(code);
249
+ res.end(code);
250
+ return;
251
+ }
252
+ serveFile(res, srcPath);
253
+ return;
254
+ }
255
+
256
+ // Try pages directory for route matching
257
+ const page = resolvePageFile(pathname, config);
258
+ if (page) {
259
+ res.writeHead(200, { 'Content-Type': 'text/html' });
260
+ res.end(await renderDevPage(page, pathname, config));
261
+ return;
262
+ }
263
+
264
+ // SPA fallback: serve index.html for all routes
265
+ const indexPath = join(cwd, 'src', 'index.html');
266
+ if (existsSync(indexPath)) {
267
+ res.writeHead(200, { 'Content-Type': 'text/html' });
268
+ let html = readFileSync(indexPath, 'utf-8');
269
+ html = injectDevClient(html);
270
+ res.end(html);
271
+ return;
272
+ }
273
+
274
+ res.writeHead(404);
275
+ res.end('Not found');
276
+ });
277
+
278
+ // WebSocket server for HMR (zero dependencies)
279
+ const wsClients = new Set();
280
+
281
+ server.listen(port, host, () => {
282
+ console.log(`\n what dev server\n`);
283
+ console.log(` Local: http://${host}:${port}`);
284
+ console.log(` Mode: ${config.mode || 'hybrid'}`);
285
+ console.log(` Pages: ${config.pagesDir || 'src/pages'}`);
286
+ console.log(` HMR: WebSocket (instant reload)\n`);
287
+ });
288
+
289
+ // Initialize WebSocket server
290
+ const wss = new SimpleWebSocketServer({ server });
291
+ wss.on('connection', (ws) => {
292
+ wsClients.add(ws);
293
+ ws.onclose = () => wsClients.delete(ws);
294
+ });
295
+
296
+ // Watch for file changes with instant WebSocket notification
297
+ if (config.hmr !== false) {
298
+ watchFiles(cwd, (changedFiles) => {
299
+ const message = JSON.stringify({
300
+ type: 'update',
301
+ files: changedFiles,
302
+ timestamp: Date.now(),
303
+ });
304
+
305
+ // Notify all connected clients instantly
306
+ for (const client of wsClients) {
307
+ try {
308
+ client.send(message);
309
+ } catch (e) {
310
+ wsClients.delete(client);
311
+ }
312
+ }
313
+ });
314
+ }
315
+ }
316
+
317
+ // --- Build ---
318
+
319
+ async function build() {
320
+ const config = loadConfig();
321
+ const outDir = join(cwd, config.outDir || 'dist');
322
+ const useHash = config.hash !== false;
323
+ const hashManifest = {};
324
+
325
+ console.log('\n what build\n');
326
+ if (useHash) console.log(' Hash: Enabled (cache busting)\n');
327
+
328
+ mkdirSync(outDir, { recursive: true });
329
+
330
+ // Collect all source files
331
+ const srcDir = join(cwd, 'src');
332
+ const files = collectFiles(srcDir);
333
+
334
+ let totalSize = 0;
335
+ let gzipSize = 0;
336
+
337
+ for (const file of files) {
338
+ const rel = relative(srcDir, file);
339
+ const ext = extname(file);
340
+ let outPath = join(outDir, rel);
341
+
342
+ mkdirSync(join(outDir, relative(srcDir, join(file, '..'))), { recursive: true });
343
+
344
+ if (ext === '.js' || ext === '.mjs') {
345
+ let code = readFileSync(file, 'utf-8');
346
+ code = transformImports(code);
347
+ code = minifyJS(code);
348
+
349
+ // Add content hash to filename
350
+ if (useHash && !rel.includes('index')) {
351
+ const hash = contentHash(code);
352
+ const hashedName = addHash(rel, hash);
353
+ outPath = join(outDir, hashedName);
354
+ hashManifest[rel] = hashedName;
355
+ }
356
+
357
+ writeFileSync(outPath, code);
358
+ totalSize += code.length;
359
+
360
+ // Create gzipped version
361
+ const gzipped = gzipSync(code);
362
+ writeFileSync(outPath + '.gz', gzipped);
363
+ gzipSize += gzipped.length;
364
+ } else if (ext === '.html') {
365
+ let html = readFileSync(file, 'utf-8');
366
+ html = minifyHTML(html);
367
+
368
+ // Replace references with hashed versions
369
+ if (useHash) {
370
+ for (const [original, hashed] of Object.entries(hashManifest)) {
371
+ html = html.replace(new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), hashed);
372
+ }
373
+ }
374
+ writeFileSync(outPath, html);
375
+ totalSize += html.length;
376
+ } else {
377
+ copyFileSync(file, outPath);
378
+ totalSize += statSync(file).size;
379
+ }
380
+ }
381
+
382
+ // Copy public dir
383
+ const publicDir = join(cwd, 'public');
384
+ if (existsSync(publicDir)) {
385
+ const pubFiles = collectFiles(publicDir);
386
+ for (const file of pubFiles) {
387
+ const rel = relative(publicDir, file);
388
+ const outPath = join(outDir, rel);
389
+ mkdirSync(join(outDir, relative(publicDir, join(file, '..'))), { recursive: true });
390
+ copyFileSync(file, outPath);
391
+ }
392
+ }
393
+
394
+ // Bundle the framework runtime
395
+ bundleRuntime(outDir, useHash, hashManifest);
396
+
397
+ // Write manifest for production use
398
+ if (useHash && Object.keys(hashManifest).length > 0) {
399
+ writeFileSync(
400
+ join(outDir, 'manifest.json'),
401
+ JSON.stringify(hashManifest, null, 2)
402
+ );
403
+ }
404
+
405
+ console.log(` Output: ${relative(cwd, outDir)}/`);
406
+ console.log(` Size: ${formatSize(totalSize)} (${formatSize(gzipSize)} gzip)`);
407
+ console.log(` Files: ${files.length}`);
408
+ if (useHash) {
409
+ console.log(` Hashed: ${Object.keys(hashManifest).length} files`);
410
+ }
411
+ console.log();
412
+ }
413
+
414
+ // --- Preview ---
415
+
416
+ function preview() {
417
+ const config = loadConfig();
418
+ const outDir = join(cwd, config.outDir || 'dist');
419
+ const port = getFlag('--port', 4000);
420
+
421
+ if (!existsSync(outDir)) {
422
+ console.error(' No build found. Run `what build` first.');
423
+ process.exit(1);
424
+ }
425
+
426
+ const server = createServer((req, res) => {
427
+ let pathname = new URL(req.url, `http://localhost:${port}`).pathname;
428
+ if (pathname === '/') pathname = '/index.html';
429
+
430
+ // Security: Prevent path traversal
431
+ const filePath = safePath(outDir, pathname);
432
+ if (filePath && existsSync(filePath) && statSync(filePath).isFile()) {
433
+ serveFile(res, filePath);
434
+ } else {
435
+ // SPA fallback
436
+ const indexPath = join(outDir, 'index.html');
437
+ if (existsSync(indexPath)) {
438
+ serveFile(res, indexPath);
439
+ } else {
440
+ res.writeHead(404);
441
+ res.end('Not found');
442
+ }
443
+ }
444
+ });
445
+
446
+ server.listen(port, () => {
447
+ console.log(`\n what preview\n`);
448
+ console.log(` Local: http://localhost:${port}\n`);
449
+ });
450
+ }
451
+
452
+ // --- Static Generation ---
453
+
454
+ async function generate() {
455
+ const config = loadConfig();
456
+ const outDir = join(cwd, config.outDir || 'dist');
457
+
458
+ console.log('\n what generate (SSG)\n');
459
+
460
+ // First do a normal build
461
+ await build();
462
+
463
+ // Then pre-render all pages
464
+ const pagesDir = join(cwd, config.pagesDir || 'src/pages');
465
+ if (existsSync(pagesDir)) {
466
+ const pages = collectFiles(pagesDir).filter(f => extname(f) === '.js');
467
+ for (const page of pages) {
468
+ const route = fileToRoute(relative(pagesDir, page));
469
+ console.log(` Pre-rendering: ${route}`);
470
+ // In full impl: import page, call renderToString, write HTML
471
+ }
472
+ }
473
+
474
+ console.log('\n Static generation complete.\n');
475
+ }
476
+
477
+ // --- Init ---
478
+
479
+ function init() {
480
+ const name = args[1] || 'my-what-app';
481
+ const dir = join(cwd, name);
482
+
483
+ if (existsSync(dir)) {
484
+ console.error(` Directory "${name}" already exists.`);
485
+ process.exit(1);
486
+ }
487
+
488
+ console.log(`\n Creating ${name}...\n`);
489
+
490
+ mkdirSync(join(dir, 'src/pages'), { recursive: true });
491
+ mkdirSync(join(dir, 'src/components'), { recursive: true });
492
+ mkdirSync(join(dir, 'public'), { recursive: true });
493
+
494
+ writeFileSync(join(dir, 'what.config.js'), `export default {
495
+ mode: 'hybrid', // 'static' | 'server' | 'client' | 'hybrid'
496
+ };
497
+ `);
498
+
499
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({
500
+ name,
501
+ private: true,
502
+ type: 'module',
503
+ scripts: {
504
+ dev: 'what dev',
505
+ build: 'what build',
506
+ preview: 'what preview',
507
+ generate: 'what generate',
508
+ },
509
+ dependencies: {
510
+ 'what-fw': '^0.1.0',
511
+ },
512
+ }, null, 2));
513
+
514
+ console.log(` Done! Next steps:\n`);
515
+ console.log(` cd ${name}`);
516
+ console.log(` npm install`);
517
+ console.log(` npm run dev\n`);
518
+ }
519
+
520
+ // --- Helpers ---
521
+
522
+ function getFlag(name, defaultValue) {
523
+ const idx = args.indexOf(name);
524
+ if (idx === -1) return defaultValue;
525
+ const val = args[idx + 1];
526
+ return typeof defaultValue === 'number' ? Number(val) : val;
527
+ }
528
+
529
+ function loadConfig() {
530
+ const configPath = join(cwd, 'what.config.js');
531
+ // Simple sync config load (no dynamic import in this context)
532
+ if (existsSync(configPath)) {
533
+ try {
534
+ // Read and extract basic config
535
+ const src = readFileSync(configPath, 'utf-8');
536
+ const match = src.match(/export default\s*(\{[\s\S]*?\})/);
537
+ if (match) {
538
+ return new Function(`return ${match[1]}`)();
539
+ }
540
+ } catch (e) { /* use defaults */ }
541
+ }
542
+ return { mode: 'hybrid', pagesDir: 'src/pages', outDir: 'dist' };
543
+ }
544
+
545
+ function collectFiles(dir) {
546
+ const files = [];
547
+ if (!existsSync(dir)) return files;
548
+ for (const entry of readdirSync(dir)) {
549
+ if (entry.startsWith('.')) continue;
550
+ const full = join(dir, entry);
551
+ if (statSync(full).isDirectory()) {
552
+ files.push(...collectFiles(full));
553
+ } else {
554
+ files.push(full);
555
+ }
556
+ }
557
+ return files;
558
+ }
559
+
560
+ function resolvePageFile(pathname, config) {
561
+ const pagesDir = join(cwd, config.pagesDir || 'src/pages');
562
+ if (!existsSync(pagesDir)) return null;
563
+
564
+ // Try exact match
565
+ const exact = join(pagesDir, pathname + '.js');
566
+ if (existsSync(exact)) return exact;
567
+
568
+ // Try index
569
+ const index = join(pagesDir, pathname, 'index.js');
570
+ if (existsSync(index)) return index;
571
+
572
+ return null;
573
+ }
574
+
575
+ function fileToRoute(filepath) {
576
+ return '/' + filepath
577
+ .replace(/\.js$/, '')
578
+ .replace(/\/index$/, '')
579
+ .replace(/\[(\w+)\]/g, ':$1')
580
+ .replace(/\[\.\.\.(\w+)\]/g, '*');
581
+ }
582
+
583
+ async function renderDevPage(pagePath, pathname, config) {
584
+ const route = relative(join(cwd, config.pagesDir || 'src/pages'), pagePath);
585
+ return `<!DOCTYPE html>
586
+ <html lang="en">
587
+ <head>
588
+ <meta charset="UTF-8">
589
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
590
+ <title>What App</title>
591
+ </head>
592
+ <body>
593
+ <div id="app"></div>
594
+ <script type="module">
595
+ import { mount } from '/@what/core.js';
596
+ const mod = await import('/pages/${route}');
597
+ const Page = mod.default || mod;
598
+ mount(Page(), '#app');
599
+ </script>
600
+ </body>
601
+ </html>`;
602
+ }
603
+
604
+ function injectDevClient(html) {
605
+ const devScript = `<script type="module">
606
+ // What HMR client - WebSocket with polling fallback
607
+ const wsUrl = 'ws://' + location.host;
608
+ let ws = null;
609
+ let reconnectTimer = null;
610
+ let reconnectAttempts = 0;
611
+ const maxReconnectAttempts = 10;
612
+
613
+ function connect() {
614
+ try {
615
+ ws = new WebSocket(wsUrl);
616
+
617
+ ws.onopen = () => {
618
+ console.log('[what] HMR connected');
619
+ reconnectAttempts = 0;
620
+ };
621
+
622
+ ws.onmessage = (event) => {
623
+ try {
624
+ const data = JSON.parse(event.data);
625
+ if (data.type === 'update') {
626
+ console.log('[what] Files changed:', data.files.join(', '));
627
+ // Smart reload: check if we can hot-swap or need full reload
628
+ const needsFullReload = data.files.some(f =>
629
+ f.endsWith('.html') || f.includes('/pages/') || f.includes('index.')
630
+ );
631
+ if (needsFullReload) {
632
+ location.reload();
633
+ } else {
634
+ // For CSS and some JS, we could do hot updates
635
+ // For now, reload but this is where HMR logic would go
636
+ location.reload();
637
+ }
638
+ }
639
+ } catch (e) {}
640
+ };
641
+
642
+ ws.onclose = () => {
643
+ ws = null;
644
+ scheduleReconnect();
645
+ };
646
+
647
+ ws.onerror = () => {
648
+ ws?.close();
649
+ };
650
+ } catch (e) {
651
+ scheduleReconnect();
652
+ }
653
+ }
654
+
655
+ function scheduleReconnect() {
656
+ if (reconnectTimer || reconnectAttempts >= maxReconnectAttempts) return;
657
+ reconnectAttempts++;
658
+ const delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts), 10000);
659
+ reconnectTimer = setTimeout(() => {
660
+ reconnectTimer = null;
661
+ connect();
662
+ }, delay);
663
+ }
664
+
665
+ // Initial connection
666
+ connect();
667
+
668
+ // Fallback: if no WebSocket update in 5s, poll
669
+ let lastActivity = Date.now();
670
+ setInterval(() => {
671
+ if (ws?.readyState === WebSocket.OPEN) {
672
+ lastActivity = Date.now();
673
+ } else if (Date.now() - lastActivity > 5000) {
674
+ // Polling fallback
675
+ fetch('/__what_hmr?t=' + Date.now()).then(r => r.json()).then(data => {
676
+ if (data.reload) location.reload();
677
+ }).catch(() => {});
678
+ }
679
+ }, 2000);
680
+ </script>`;
681
+ return html.replace('</body>', devScript + '\n</body>');
682
+ }
683
+
684
+ function resolveFrameworkModule(name) {
685
+ const whatDir = resolve(__dirname, '../../what/src');
686
+ const coreDir = resolve(__dirname, '../../core/src');
687
+ const routerDir = resolve(__dirname, '../../router/src');
688
+ const serverDir = resolve(__dirname, '../../server/src');
689
+
690
+ const map = {
691
+ 'core.js': join(whatDir, 'index.js'),
692
+ 'reactive.js': join(coreDir, 'reactive.js'),
693
+ 'router.js': join(whatDir, 'router.js'),
694
+ 'server.js': join(whatDir, 'server.js'),
695
+ 'islands.js': join(serverDir, 'islands.js'),
696
+ };
697
+
698
+ return map[name] || null;
699
+ }
700
+
701
+ function transformImports(code) {
702
+ // Transform: import { x } from 'what' -> from '/@what/core.js'
703
+ return code
704
+ .replace(/from\s+['"]what['"]/g, "from '/@what/core.js'")
705
+ .replace(/from\s+['"]what\/router['"]/g, "from '/@what/router.js'")
706
+ .replace(/from\s+['"]what\/server['"]/g, "from '/@what/islands.js'");
707
+ }
708
+
709
+ function minifyJS(code) {
710
+ // Lightweight minification: strip comments, collapse whitespace
711
+ return code
712
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
713
+ .replace(/\/\/[^\n]*/g, '') // line comments
714
+ .replace(/^\s+/gm, '') // leading whitespace
715
+ .replace(/\n\s*\n/g, '\n') // empty lines
716
+ .trim();
717
+ }
718
+
719
+ function minifyHTML(html) {
720
+ return html
721
+ .replace(/<!--[\s\S]*?-->/g, '') // comments
722
+ .replace(/\s{2,}/g, ' ') // collapse whitespace
723
+ .replace(/>\s+</g, '><') // between tags
724
+ .trim();
725
+ }
726
+
727
+ function contentHash(content) {
728
+ return createHash('md5').update(content).digest('hex').slice(0, 8);
729
+ }
730
+
731
+ function addHash(filename, hash) {
732
+ const ext = extname(filename);
733
+ const base = filename.slice(0, -ext.length);
734
+ return `${base}.${hash}${ext}`;
735
+ }
736
+
737
+ function bundleRuntime(outDir, useHash = false, hashManifest = {}) {
738
+ // Copy framework runtime into output for production
739
+ const whatDir = resolve(__dirname, '../../what/src');
740
+ const coreDir = resolve(__dirname, '../../core/src');
741
+ const routerDir = resolve(__dirname, '../../router/src');
742
+ const serverDir = resolve(__dirname, '../../server/src');
743
+ const runtimeDir = join(outDir, '@what');
744
+ mkdirSync(runtimeDir, { recursive: true });
745
+
746
+ // Core modules
747
+ const coreModules = [
748
+ 'reactive.js', 'h.js', 'dom.js', 'hooks.js',
749
+ 'components.js', 'store.js', 'helpers.js', 'scheduler.js',
750
+ 'animation.js', 'a11y.js', 'skeleton.js', 'data.js', 'form.js'
751
+ ];
752
+
753
+ // Bundle main entry point
754
+ const whatFiles = [
755
+ { src: join(whatDir, 'index.js'), out: 'core.js' },
756
+ { src: join(whatDir, 'router.js'), out: 'router.js' },
757
+ { src: join(whatDir, 'server.js'), out: 'server.js' },
758
+ ];
759
+
760
+ for (const { src, out } of whatFiles) {
761
+ if (existsSync(src)) {
762
+ let code = readFileSync(src, 'utf-8');
763
+ code = minifyJS(code);
764
+ let outName = out;
765
+
766
+ if (useHash) {
767
+ const hash = contentHash(code);
768
+ const hashedName = addHash(outName, hash);
769
+ hashManifest[`@what/${outName}`] = `@what/${hashedName}`;
770
+ outName = hashedName;
771
+ }
772
+
773
+ writeFileSync(join(runtimeDir, outName), code);
774
+ const gzipped = gzipSync(code);
775
+ writeFileSync(join(runtimeDir, outName + '.gz'), gzipped);
776
+ }
777
+ }
778
+
779
+ // Bundle core modules
780
+ for (const mod of coreModules) {
781
+ const src = join(coreDir, mod);
782
+ if (existsSync(src)) {
783
+ let code = readFileSync(src, 'utf-8');
784
+ code = minifyJS(code);
785
+ let outName = mod;
786
+
787
+ if (useHash) {
788
+ const hash = contentHash(code);
789
+ const hashedName = addHash(outName, hash);
790
+ hashManifest[`@what/${outName}`] = `@what/${hashedName}`;
791
+ outName = hashedName;
792
+ }
793
+
794
+ writeFileSync(join(runtimeDir, outName), code);
795
+ const gzipped = gzipSync(code);
796
+ writeFileSync(join(runtimeDir, outName + '.gz'), gzipped);
797
+ }
798
+ }
799
+
800
+ // Bundle router
801
+ const routerSrc = join(routerDir, 'index.js');
802
+ if (existsSync(routerSrc)) {
803
+ let code = readFileSync(routerSrc, 'utf-8');
804
+ code = minifyJS(code);
805
+ writeFileSync(join(runtimeDir, 'router-impl.js'), code);
806
+ }
807
+
808
+ // Bundle islands
809
+ const islandsSrc = join(serverDir, 'islands.js');
810
+ if (existsSync(islandsSrc)) {
811
+ let code = readFileSync(islandsSrc, 'utf-8');
812
+ code = minifyJS(code);
813
+ writeFileSync(join(runtimeDir, 'islands.js'), code);
814
+ }
815
+ }
816
+
817
+ function serveFile(res, filepath) {
818
+ const ext = extname(filepath);
819
+ const types = {
820
+ '.html': 'text/html',
821
+ '.js': 'application/javascript',
822
+ '.mjs': 'application/javascript',
823
+ '.css': 'text/css',
824
+ '.json': 'application/json',
825
+ '.png': 'image/png',
826
+ '.jpg': 'image/jpeg',
827
+ '.svg': 'image/svg+xml',
828
+ '.ico': 'image/x-icon',
829
+ '.woff2': 'font/woff2',
830
+ '.woff': 'font/woff',
831
+ };
832
+ res.writeHead(200, {
833
+ 'Content-Type': types[ext] || 'application/octet-stream',
834
+ 'Cache-Control': 'no-cache',
835
+ });
836
+ res.end(readFileSync(filepath));
837
+ }
838
+
839
+ function formatSize(bytes) {
840
+ if (bytes < 1024) return bytes + ' B';
841
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
842
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
843
+ }
844
+
845
+ function watchFiles(dir, onChange) {
846
+ // Simple polling watcher with change tracking — no native deps
847
+ const files = new Map();
848
+ let initialized = false;
849
+
850
+ function scan() {
851
+ const current = collectFiles(join(dir, 'src'));
852
+ const changedFiles = [];
853
+
854
+ for (const f of current) {
855
+ try {
856
+ const mtime = statSync(f).mtimeMs;
857
+ if (files.get(f) !== mtime) {
858
+ if (initialized) {
859
+ changedFiles.push(relative(dir, f));
860
+ }
861
+ files.set(f, mtime);
862
+ }
863
+ } catch (e) {
864
+ // File was deleted during scan
865
+ }
866
+ }
867
+
868
+ // Detect deleted files
869
+ for (const [f] of files) {
870
+ if (!current.includes(f)) {
871
+ files.delete(f);
872
+ if (initialized) {
873
+ changedFiles.push(relative(dir, f) + ' (deleted)');
874
+ }
875
+ }
876
+ }
877
+
878
+ if (changedFiles.length > 0) {
879
+ onChange(changedFiles);
880
+ }
881
+
882
+ initialized = true;
883
+ }
884
+
885
+ scan();
886
+ // Poll every 100ms for more responsive HMR
887
+ setInterval(scan, 100);
888
+ }