what-framework-cli 0.11.7 → 0.11.8

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 (3) hide show
  1. package/README.md +20 -4
  2. package/package.json +2 -2
  3. package/src/cli.js +364 -132
package/README.md CHANGED
@@ -27,8 +27,8 @@ what dev --host 0.0.0.0
27
27
  ```
28
28
 
29
29
  Features:
30
- - WebSocket-based HMR with automatic reconnection
31
- - Bare import transforms (`what` -> framework modules)
30
+ - WebSocket-based HMR with automatic reconnection (same-origin only)
31
+ - Bare import transforms (`what-framework`, `what-framework/router`, `what-framework/server` -> `/@what/*.js`)
32
32
  - File-based routing from `src/pages/`
33
33
  - SPA fallback for client-side routing
34
34
  - Server action endpoint
@@ -46,7 +46,11 @@ Output:
46
46
  - Content-hashed filenames for cache busting
47
47
  - Gzipped copies of all JS files
48
48
  - `manifest.json` mapping original filenames to hashed versions
49
- - Bundled framework runtime
49
+ - The framework runtime under `dist/@what/` (unhashed, so app imports keep resolving)
50
+
51
+ The build fails with a non-zero exit code if there is no app to build, if the
52
+ framework runtime cannot be resolved (`npm install what-framework`), or if any
53
+ bare import specifier survives into the output.
50
54
 
51
55
  ### `what preview`
52
56
 
@@ -59,12 +63,23 @@ what preview --port 4000
59
63
 
60
64
  ### `what generate`
61
65
 
62
- Static site generation. Runs a build, then pre-renders all pages.
66
+ Static site generation. Runs a build, then pre-renders every page module in
67
+ `pagesDir` to `dist/<route>/index.html` (running each page's `loader` first and
68
+ collecting its `<Head>` tags). Dynamic routes (`[id].js`) are skipped.
63
69
 
64
70
  ```bash
65
71
  what generate
66
72
  ```
67
73
 
74
+ ### `what start`
75
+
76
+ Run the project's full-stack server (`server.js`, Node adapter + ISR). Scaffold
77
+ one with `npm create what@latest -- --fullstack`.
78
+
79
+ ```bash
80
+ what start
81
+ ```
82
+
68
83
  ### `what init`
69
84
 
70
85
  Create a new project (prefer `npx create-what` for the full scaffolding experience).
@@ -91,6 +106,7 @@ export default {
91
106
  |---|---|---|
92
107
  | `--port` | Server port | `3000` (dev), `4000` (preview) |
93
108
  | `--host` | Server host | `localhost` |
109
+ | `--version` | Print the CLI version | |
94
110
 
95
111
  ## Links
96
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-framework-cli",
3
- "version": "0.11.7",
3
+ "version": "0.11.8",
4
4
  "description": "What Framework CLI - Dev server, build, and deployment tools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "author": "ZVN DEV (https://zvndev.com)",
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "what-framework": "^0.11.7"
25
+ "what-framework": "^0.11.8"
26
26
  },
27
27
  "repository": {
28
28
  "type": "git",
package/src/cli.js CHANGED
@@ -4,48 +4,88 @@
4
4
  // Commands: dev, build, preview, generate
5
5
 
6
6
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync, copyFileSync, realpathSync } from 'fs';
7
- import { join, resolve, relative, extname, basename, normalize } from 'path';
7
+ import { join, resolve, relative, extname, basename, normalize, sep } from 'path';
8
8
  import { createServer } from 'http';
9
9
  import { spawn } from 'child_process';
10
10
  import { createRequire } from 'module';
11
- import { fileURLToPath } from 'url';
11
+ import { fileURLToPath, pathToFileURL } from 'url';
12
12
  import { createHash } from 'crypto';
13
13
  import { gzipSync } from 'zlib';
14
14
 
15
- // Security: Prevent path traversal attacks
15
+ // Security: Prevent path traversal attacks. `userPath` is a URL pathname, so it
16
+ // starts with '/', so it must be joined onto the base as a RELATIVE path, otherwise
17
+ // resolve() would discard the base entirely.
16
18
  function safePath(base, userPath) {
17
19
  try {
20
+ let decoded;
21
+ try {
22
+ decoded = decodeURIComponent(userPath);
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (decoded.includes('\0')) return null;
27
+
18
28
  // Reject paths that contain .. segments (path traversal attempt)
19
- const normalized = normalize(userPath);
29
+ const normalized = normalize(decoded);
20
30
  if (normalized.startsWith('..') || normalized.includes('/..') || normalized.includes('\\..')) {
21
31
  return null;
22
32
  }
23
33
 
34
+ // Never serve dotfiles (.env, .git, ...)
35
+ if (normalized.split(/[\\/]/).some((s) => s.length > 1 && s.startsWith('.'))) {
36
+ return null;
37
+ }
38
+
24
39
  // Get the real base path (resolve symlinks)
25
40
  const realBase = realpathSync(base);
26
41
 
27
- // Resolve the user path against the base
28
- const resolved = resolve(realBase, normalized);
42
+ // Resolve the user path against the base, always relatively
43
+ const rel = normalized.startsWith('/') || normalized.startsWith('\\') ? '.' + normalized : './' + normalized;
44
+ const resolved = resolve(realBase, rel);
45
+ if (!isInside(resolved, realBase)) return null;
29
46
 
30
- // Double-check: ensure resolved path is within base
31
- if (!resolved.startsWith(realBase + '/') && resolved !== realBase) {
32
- return null;
33
- }
47
+ // readFileSync follows symlinks, so the RESOLVED target must be contained too:
48
+ // `public/leak.txt -> ../../.env` passes the textual check but escapes the root.
49
+ const real = realpathSync(resolved);
50
+ if (!isInside(real, realBase)) return null;
34
51
 
35
- return resolved;
52
+ return real;
36
53
  } catch {
37
54
  return null;
38
55
  }
39
56
  }
40
57
 
58
+ function isInside(target, base) {
59
+ return target === base || target.startsWith(base + sep);
60
+ }
61
+
62
+ // WS handshakes are exempt from the same-origin policy, so any page the developer
63
+ // browses could otherwise subscribe to the HMR stream (a live feed of edited file
64
+ // paths). Non-browser clients send no Origin at all; browsers always do.
65
+ function isAllowedOrigin(origin, allowedHosts) {
66
+ if (!origin) return true;
67
+ try {
68
+ const { hostname, port } = new URL(origin);
69
+ return allowedHosts.has(`${hostname}:${port}`);
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
41
75
  // Simple WebSocket implementation using native Node.js APIs (no external deps)
42
76
  class SimpleWebSocketServer {
43
- constructor({ server }) {
77
+ constructor({ server, allowedHosts = new Set() }) {
44
78
  this.clients = new Set();
45
79
  server.on('upgrade', (req, socket, head) => {
46
80
  if (req.headers.upgrade?.toLowerCase() !== 'websocket') return;
47
81
 
48
82
  const key = req.headers['sec-websocket-key'];
83
+ if (!key || !isAllowedOrigin(req.headers.origin, allowedHosts)) {
84
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
85
+ socket.destroy();
86
+ return;
87
+ }
88
+
49
89
  const accept = createHash('sha1')
50
90
  .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
51
91
  .digest('base64');
@@ -153,6 +193,7 @@ class SimpleWebSocket {
153
193
 
154
194
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
155
195
  const cwd = process.cwd();
196
+ const MAX_ACTION_BODY = 1024 * 1024;
156
197
  const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
157
198
 
158
199
  const args = process.argv.slice(2);
@@ -160,8 +201,7 @@ const command = args[0];
160
201
 
161
202
  const commands = { dev, build, preview, generate, start, init };
162
203
 
163
- if (!command || !commands[command]) {
164
- console.log(`
204
+ const help = `
165
205
  what - The closest framework to vanilla JS
166
206
 
167
207
  Usage: what <command>
@@ -170,18 +210,47 @@ if (!command || !commands[command]) {
170
210
  dev Start dev server with HMR
171
211
  build Production build
172
212
  preview Preview production build
173
- generate Static site generation
213
+ generate Static site generation (pre-render src/pages to HTML)
174
214
  start Run the full-stack server (Node adapter + ISR)
175
215
  init Create a new project (same scaffold as npm create what@latest)
176
216
 
177
217
  Options:
178
- --port Dev server port (default: 3000)
179
- --host Dev server host (default: localhost)
180
- `);
181
- process.exit(0);
218
+ --port Dev server port (default: 3000)
219
+ --host Dev server host (default: localhost)
220
+ --version Print the CLI version
221
+ `;
222
+
223
+ function main() {
224
+ if (command === '--version' || command === '-v') {
225
+ console.log(packageVersion);
226
+ return;
227
+ }
228
+ if (!command || command === '--help' || command === '-h') {
229
+ console.log(help);
230
+ return;
231
+ }
232
+ if (!commands[command]) {
233
+ console.error(`\n Unknown command: ${command}`);
234
+ console.error(help);
235
+ process.exit(1);
236
+ }
237
+ commands[command]();
182
238
  }
183
239
 
184
- commands[command]();
240
+ // Guarded so the module can be imported (by tests) without running a command.
241
+ // argv[1] is the bin symlink under node_modules/.bin, hence the realpath compare.
242
+ function isMainModule() {
243
+ if (!process.argv[1]) return false;
244
+ try {
245
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ if (isMainModule()) main();
252
+
253
+ export { safePath, isAllowedOrigin, transformImports, fileToRoute };
185
254
 
186
255
  // --- Dev Server ---
187
256
 
@@ -189,6 +258,7 @@ async function dev() {
189
258
  const port = getFlag('--port', 3000);
190
259
  const host = getFlag('--host', 'localhost');
191
260
  const config = await loadConfigAsync();
261
+ const runtimeDirs = requireRuntimeDirs('what dev');
192
262
 
193
263
  const server = createServer(async (req, res) => {
194
264
  const url = new URL(req.url, `http://${host}:${port}`);
@@ -198,8 +268,22 @@ async function dev() {
198
268
  if (pathname === '/__what_action' && req.method === 'POST') {
199
269
  const actionId = req.headers['x-what-action'];
200
270
  let body = '';
201
- req.on('data', chunk => body += chunk);
271
+ let size = 0;
272
+ let tooLarge = false;
273
+ req.on('data', chunk => {
274
+ if (tooLarge) return;
275
+ size += chunk.length;
276
+ if (size > MAX_ACTION_BODY) {
277
+ tooLarge = true;
278
+ res.writeHead(413, { 'Content-Type': 'application/json' });
279
+ res.end(JSON.stringify({ message: 'Request body too large' }));
280
+ req.destroy();
281
+ return;
282
+ }
283
+ body += chunk;
284
+ });
202
285
  req.on('end', async () => {
286
+ if (tooLarge) return;
203
287
  try {
204
288
  const { args } = JSON.parse(body);
205
289
  // In production, this would call the registered action
@@ -220,11 +304,10 @@ async function dev() {
220
304
 
221
305
  // Serve framework modules
222
306
  if (pathname.startsWith('/@what/')) {
223
- const modName = pathname.slice(7);
224
- const modPath = resolveFrameworkModule(modName);
225
- if (modPath) {
307
+ const mod = resolveFrameworkModule(pathname.slice(7), runtimeDirs);
308
+ if (mod) {
226
309
  res.writeHead(200, { 'Content-Type': 'application/javascript' });
227
- res.end(readFileSync(modPath, 'utf-8'));
310
+ res.end(mod);
228
311
  return;
229
312
  }
230
313
  }
@@ -321,7 +404,10 @@ async function dev() {
321
404
  });
322
405
 
323
406
  // Initialize WebSocket server
324
- const wss = new SimpleWebSocketServer({ server });
407
+ const allowedHosts = new Set(
408
+ ['localhost', '127.0.0.1', '::1', host].map((h) => `${h}:${port}`)
409
+ );
410
+ const wss = new SimpleWebSocketServer({ server, allowedHosts });
325
411
  wss.on('connection', (ws) => {
326
412
  wsClients.add(ws);
327
413
  ws.onclose = () => wsClients.delete(ws);
@@ -359,12 +445,25 @@ async function build() {
359
445
  console.log('\n what build\n');
360
446
  if (useHash) console.log(' Hash: Enabled (cache busting)\n');
361
447
 
362
- mkdirSync(outDir, { recursive: true });
363
-
364
448
  // Collect all source files
365
449
  const srcDir = join(cwd, 'src');
366
450
  const files = collectFiles(srcDir);
367
451
 
452
+ if (files.length === 0) {
453
+ console.error(`
454
+ what build: no app found in ${cwd}
455
+
456
+ Expected a src/ directory containing your entry point (src/main.js and/or
457
+ src/index.html). Scaffold one with \`npm create what@latest\`, or run this
458
+ from your project root.
459
+ `);
460
+ process.exit(1);
461
+ return;
462
+ }
463
+
464
+ const runtimeDirs = requireRuntimeDirs('what build');
465
+ mkdirSync(outDir, { recursive: true });
466
+
368
467
  let totalSize = 0;
369
468
  let gzipSize = 0;
370
469
 
@@ -426,7 +525,24 @@ async function build() {
426
525
  }
427
526
 
428
527
  // Bundle the framework runtime
429
- bundleRuntime(outDir, useHash, hashManifest);
528
+ bundleRuntime(outDir, runtimeDirs);
529
+
530
+ // A bare specifier left in the output is a module no browser can load, so the
531
+ // build must fail rather than hand back an artifact that 404s on first paint.
532
+ const unresolved = findBareSpecifiers(outDir);
533
+ if (unresolved.length > 0) {
534
+ console.error(`\n what build: the output contains imports no browser can resolve:\n`);
535
+ for (const { file, spec } of unresolved.slice(0, 10)) {
536
+ console.error(` ${file}: '${spec}'`);
537
+ }
538
+ if (unresolved.length > 10) console.error(` ...and ${unresolved.length - 10} more`);
539
+ console.error(`
540
+ Import the framework as 'what-framework' (or 'what-framework/router',
541
+ 'what-framework/server') so the build can rewrite it to /@what/*.js.
542
+ `);
543
+ process.exit(1);
544
+ return;
545
+ }
430
546
 
431
547
  // Write manifest for production use
432
548
  if (useHash && Object.keys(hashManifest).length > 0) {
@@ -494,18 +610,77 @@ async function generate() {
494
610
  // First do a normal build
495
611
  await build();
496
612
 
497
- // Then pre-render all pages
498
613
  const pagesDir = join(cwd, config.pagesDir || 'src/pages');
499
- if (existsSync(pagesDir)) {
500
- const pages = collectFiles(pagesDir).filter(f => extname(f) === '.js');
501
- for (const page of pages) {
502
- const route = fileToRoute(relative(pagesDir, page));
503
- console.log(` Pre-rendering: ${route}`);
504
- // In full impl: import page, call renderToString, write HTML
614
+ if (!existsSync(pagesDir)) {
615
+ console.error(`
616
+ what generate: no pages directory at ${relative(cwd, pagesDir)}/
617
+
618
+ Static generation pre-renders every page module in that directory (each one
619
+ exporting a default component, optionally a loader). Create it, or use
620
+ \`what build\` for a client-rendered app.
621
+ `);
622
+ process.exit(1);
623
+ return;
624
+ }
625
+
626
+ const { renderPage } = await import(pathToFileURL(join(requireRuntimeDirs('what generate').server, 'index.js')).href);
627
+ const pages = collectFiles(pagesDir).filter(f => extname(f) === '.js');
628
+ let count = 0;
629
+
630
+ for (const page of pages) {
631
+ const route = fileToRoute(relative(pagesDir, page));
632
+ if (route.includes(':') || route.includes('*')) {
633
+ console.log(` Skipped: ${route} (dynamic route, no params to pre-render)`);
634
+ continue;
635
+ }
636
+
637
+ let html;
638
+ try {
639
+ const mod = await import(pathToFileURL(page).href);
640
+ if (typeof (mod.default || mod) !== 'function') {
641
+ throw new Error('no default-exported component');
642
+ }
643
+ const { body, head } = await renderPage(mod, { params: {}, query: {}, path: route });
644
+ html = staticDocument(body, head);
645
+ } catch (e) {
646
+ console.error(`\n what generate: failed to pre-render ${relative(cwd, page)}\n\n ${e.message}\n`);
647
+ process.exit(1);
648
+ return;
505
649
  }
650
+
651
+ const outPath = route === '/' ? join(outDir, 'index.html') : join(outDir, route.slice(1), 'index.html');
652
+ mkdirSync(join(outPath, '..'), { recursive: true });
653
+ writeFileSync(outPath, html);
654
+ console.log(` Pre-rendered: ${route} -> ${relative(cwd, outPath)}`);
655
+ count++;
656
+ }
657
+
658
+ if (count === 0) {
659
+ console.error(`
660
+ what generate: no static pages found in ${relative(cwd, pagesDir)}/
661
+
662
+ Add a page module (e.g. src/pages/index.js exporting a default component).
663
+ `);
664
+ process.exit(1);
665
+ return;
506
666
  }
507
667
 
508
- console.log('\n Static generation complete.\n');
668
+ console.log(`\n Static generation complete (${count} page${count === 1 ? '' : 's'}).\n`);
669
+ }
670
+
671
+ function staticDocument(body, head) {
672
+ return `<!DOCTYPE html>
673
+ <html lang="en">
674
+ <head>
675
+ <meta charset="UTF-8">
676
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
677
+ ${head || '<title>What App</title>'}
678
+ </head>
679
+ <body>
680
+ <div id="app">${body}</div>
681
+ </body>
682
+ </html>
683
+ `;
509
684
  }
510
685
 
511
686
  // --- Start (full-stack server) ---
@@ -521,7 +696,7 @@ async function start() {
521
696
 
522
697
  if (!existsSync(serverEntry)) {
523
698
  console.error(`
524
- what start no server.js found in ${cwd}
699
+ what start: no server.js found in ${cwd}
525
700
 
526
701
  Full-stack apps run from a server.js (Node adapter + ISR engine). Scaffold one
527
702
  with \`npm create what@latest -- --fullstack\`, or create server.js wiring
@@ -683,11 +858,13 @@ function resolvePageFile(pathname, config) {
683
858
  }
684
859
 
685
860
  function fileToRoute(filepath) {
686
- return '/' + filepath
861
+ const route = '/' + filepath
862
+ .split(sep).join('/')
687
863
  .replace(/\.js$/, '')
688
- .replace(/\/index$/, '')
864
+ .replace(/\[\.\.\.(\w+)\]/g, '*')
689
865
  .replace(/\[(\w+)\]/g, ':$1')
690
- .replace(/\[\.\.\.(\w+)\]/g, '*');
866
+ .replace(/(^|\/)index$/, '');
867
+ return route.length > 1 && route.endsWith('/') ? route.slice(0, -1) : route;
691
868
  }
692
869
 
693
870
  async function renderDevPage(pagePath, pathname, config) {
@@ -791,36 +968,144 @@ function injectDevClient(html) {
791
968
  return html.replace('</body>', devScript + '\n</body>');
792
969
  }
793
970
 
794
- function resolveFrameworkModule(name) {
795
- const whatDir = resolve(__dirname, '../../what/src');
796
- const coreDir = resolve(__dirname, '../../core/src');
797
- const routerDir = resolve(__dirname, '../../router/src');
798
- const serverDir = resolve(__dirname, '../../server/src');
971
+ // The runtime lives in the installed packages, NOT at a path relative to this
972
+ // file: from node_modules/what-framework-cli/src, `../../what/src` points at a
973
+ // package name that was never published. Resolve by package name instead, from
974
+ // the CLI, from what-framework itself (pnpm-style trees), then from the project.
975
+ var _runtimeDirs = null;
976
+ function resolveRuntimeDirs() {
977
+ if (_runtimeDirs) return _runtimeDirs;
978
+
979
+ const cliRequire = createRequire(import.meta.url);
980
+ const requires = [cliRequire];
981
+ try { requires.push(createRequire(cliRequire.resolve('what-framework'))); } catch { /* not resolvable here */ }
982
+ try { requires.push(createRequire(join(cwd, 'package.json'))); } catch { /* no project package.json */ }
983
+
984
+ const dirs = {};
985
+ const missing = [];
986
+ for (const [key, name] of [['core', 'what-core'], ['router', 'what-router'], ['server', 'what-server']]) {
987
+ for (const req of requires) {
988
+ try { dirs[key] = join(req.resolve(name), '..'); break; } catch { /* try the next root */ }
989
+ }
990
+ if (!dirs[key]) missing.push(name);
991
+ }
992
+ if (missing.length > 0) return { missing };
799
993
 
800
- const map = {
801
- 'core.js': join(whatDir, 'index.js'),
802
- 'reactive.js': join(coreDir, 'reactive.js'),
803
- 'router.js': join(whatDir, 'router.js'),
804
- 'server.js': join(whatDir, 'server.js'),
805
- 'islands.js': join(serverDir, 'islands.js'),
806
- };
994
+ _runtimeDirs = dirs;
995
+ return dirs;
996
+ }
807
997
 
808
- return map[name] || null;
998
+ // Same resolution, but a missing runtime is fatal: every command that needs it
999
+ // produces a broken artifact or a 404ing dev server without it.
1000
+ function requireRuntimeDirs(commandName) {
1001
+ const dirs = resolveRuntimeDirs();
1002
+ if (dirs.missing) {
1003
+ console.error(`
1004
+ ${commandName}: could not locate the What Framework runtime (${dirs.missing.join(', ')})
1005
+
1006
+ Install the framework alongside the CLI: npm install what-framework
1007
+ `);
1008
+ process.exit(1);
1009
+ }
1010
+ return dirs;
1011
+ }
1012
+
1013
+ // Entry modules served/emitted under /@what/. Each package's sources are copied
1014
+ // into /@what/<pkg>/, so the entries are one-line re-export shims.
1015
+ const RUNTIME_ENTRIES = {
1016
+ 'core.js': "export * from './core/index.js';",
1017
+ 'reactive.js': "export * from './core/reactive.js';",
1018
+ 'router.js': "export * from './router/index.js';",
1019
+ 'server.js': "export * from './server/index.js';\nexport * from './server/islands.js';",
1020
+ 'islands.js': "export * from './server/islands.js';",
1021
+ 'jsx-runtime.js': "export * from './core/jsx-runtime.js';",
1022
+ 'jsx-dev-runtime.js': "export * from './core/jsx-dev-runtime.js';",
1023
+ };
1024
+
1025
+ // App-facing specifier -> served URL. 'what' is the pre-0.11 package name, kept
1026
+ // so older apps keep building.
1027
+ const IMPORT_MAP = {
1028
+ 'what-framework': '/@what/core.js',
1029
+ 'what-framework/router': '/@what/router.js',
1030
+ 'what-framework/server': '/@what/server.js',
1031
+ 'what-framework/jsx-runtime': '/@what/jsx-runtime.js',
1032
+ 'what-framework/jsx-dev-runtime': '/@what/jsx-dev-runtime.js',
1033
+ 'what-core': '/@what/core.js',
1034
+ 'what-router': '/@what/router.js',
1035
+ 'what-server': '/@what/server.js',
1036
+ 'what-server/islands': '/@what/islands.js',
1037
+ 'what': '/@what/core.js',
1038
+ 'what/router': '/@what/router.js',
1039
+ 'what/server': '/@what/server.js',
1040
+ };
1041
+
1042
+ const SPECIFIER_RE = /(from\s*|import\s*\(\s*)(['"])([^'"]+)\2/g;
1043
+
1044
+ function resolveFrameworkModule(name, runtimeDirs) {
1045
+ if (RUNTIME_ENTRIES[name]) return RUNTIME_ENTRIES[name];
1046
+
1047
+ const slash = name.indexOf('/');
1048
+ if (slash === -1) return null;
1049
+ const dir = runtimeDirs[name.slice(0, slash)];
1050
+ if (!dir) return null;
1051
+ const file = safePath(dir, '/' + name.slice(slash + 1));
1052
+ if (!file || !existsSync(file) || extname(file) !== '.js') return null;
1053
+
1054
+ return rewriteRuntimeImports(readFileSync(file, 'utf-8'), name.split('/').length - 1);
809
1055
  }
810
1056
 
811
1057
  function transformImports(code) {
812
- // Transform: import { x } from 'what' -> from '/@what/core.js'
813
- return code
814
- .replace(/from\s+['"]what['"]/g, "from '/@what/core.js'")
815
- .replace(/from\s+['"]what\/router['"]/g, "from '/@what/router.js'")
816
- .replace(/from\s+['"]what\/server['"]/g, "from '/@what/islands.js'");
1058
+ return code.replace(SPECIFIER_RE, (match, prefix, quote, spec) => {
1059
+ const mapped = IMPORT_MAP[spec];
1060
+ return mapped ? `${prefix}${quote}${mapped}${quote}` : match;
1061
+ });
1062
+ }
1063
+
1064
+ // Rewrites the runtime's own cross-package imports ('what-core',
1065
+ // 'what-server/islands', ...) to relative paths inside /@what/. `depth` is how
1066
+ // many directories below /@what/ the importing file sits.
1067
+ function rewriteRuntimeImports(code, depth) {
1068
+ const prefix = depth > 0 ? '../'.repeat(depth) : './';
1069
+ return code.replace(SPECIFIER_RE, (match, p, quote, spec) => {
1070
+ const target = runtimeTarget(spec);
1071
+ return target ? `${p}${quote}${prefix}${target}${quote}` : match;
1072
+ });
1073
+ }
1074
+
1075
+ function runtimeTarget(spec) {
1076
+ if (spec === 'what-framework') return 'core/index.js';
1077
+ if (spec.startsWith('what-framework/')) {
1078
+ const sub = spec.slice('what-framework/'.length);
1079
+ if (sub === 'router' || sub === 'server') return `${sub}/index.js`;
1080
+ return `core/${sub}.js`;
1081
+ }
1082
+ const m = /^what-(core|router|server)(?:\/(.+))?$/.exec(spec);
1083
+ return m ? `${m[1]}/${m[2] || 'index'}.js` : null;
1084
+ }
1085
+
1086
+ function findBareSpecifiers(dir) {
1087
+ const found = [];
1088
+ for (const file of collectFiles(dir)) {
1089
+ if (extname(file) !== '.js') continue;
1090
+ const code = readFileSync(file, 'utf-8');
1091
+ const re = /(?:^|[;{}\n])\s*(?:import|export)\b[^;'"\n]*from\s*(['"])([^'"]+)\1/g;
1092
+ let m;
1093
+ while ((m = re.exec(code)) !== null) {
1094
+ const spec = m[2];
1095
+ if (/^[./]/.test(spec) || /^(https?:|node:|data:)/.test(spec)) continue;
1096
+ found.push({ file: relative(dir, file), spec });
1097
+ }
1098
+ }
1099
+ return found;
817
1100
  }
818
1101
 
819
1102
  function minifyJS(code) {
820
- // Lightweight minification: strip comments, collapse whitespace
1103
+ // Lightweight minification: strip comments, collapse whitespace.
1104
+ // Line comments are only stripped at line start, and an inline `//` is usually a
1105
+ // URL inside a string ('http://www.w3.org/2000/svg').
821
1106
  return code
822
- .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
823
- .replace(/\/\/[^\n]*/g, '') // line comments
1107
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
1108
+ .replace(/^[ \t]*\/\/[^\n]*$/gm, '') // line comments
824
1109
  .replace(/^\s+/gm, '') // leading whitespace
825
1110
  .replace(/\n\s*\n/g, '\n') // empty lines
826
1111
  .trim();
@@ -844,83 +1129,30 @@ function addHash(filename, hash) {
844
1129
  return `${base}.${hash}${ext}`;
845
1130
  }
846
1131
 
847
- function bundleRuntime(outDir, useHash = false, hashManifest = {}) {
848
- // Copy framework runtime into output for production
849
- const whatDir = resolve(__dirname, '../../what/src');
850
- const coreDir = resolve(__dirname, '../../core/src');
851
- const routerDir = resolve(__dirname, '../../router/src');
852
- const serverDir = resolve(__dirname, '../../server/src');
1132
+ // Copies the framework runtime into the output. Runtime files are NOT
1133
+ // content-hashed: app code imports them by their stable /@what/*.js URL, so a
1134
+ // hashed name would leave that import pointing at a file that does not exist.
1135
+ function bundleRuntime(outDir, runtimeDirs) {
853
1136
  const runtimeDir = join(outDir, '@what');
854
1137
  mkdirSync(runtimeDir, { recursive: true });
855
1138
 
856
- // Core modules
857
- const coreModules = [
858
- 'reactive.js', 'h.js', 'dom.js', 'hooks.js',
859
- 'components.js', 'store.js', 'helpers.js', 'scheduler.js',
860
- 'animation.js', 'a11y.js', 'skeleton.js', 'data.js', 'form.js'
861
- ];
862
-
863
- // Bundle main entry point
864
- const whatFiles = [
865
- { src: join(whatDir, 'index.js'), out: 'core.js' },
866
- { src: join(whatDir, 'router.js'), out: 'router.js' },
867
- { src: join(whatDir, 'server.js'), out: 'server.js' },
868
- ];
869
-
870
- for (const { src, out } of whatFiles) {
871
- if (existsSync(src)) {
872
- let code = readFileSync(src, 'utf-8');
873
- code = minifyJS(code);
874
- let outName = out;
875
-
876
- if (useHash) {
877
- const hash = contentHash(code);
878
- const hashedName = addHash(outName, hash);
879
- hashManifest[`@what/${outName}`] = `@what/${hashedName}`;
880
- outName = hashedName;
881
- }
882
-
883
- writeFileSync(join(runtimeDir, outName), code);
884
- const gzipped = gzipSync(code);
885
- writeFileSync(join(runtimeDir, outName + '.gz'), gzipped);
886
- }
887
- }
888
-
889
- // Bundle core modules
890
- for (const mod of coreModules) {
891
- const src = join(coreDir, mod);
892
- if (existsSync(src)) {
893
- let code = readFileSync(src, 'utf-8');
894
- code = minifyJS(code);
895
- let outName = mod;
1139
+ for (const [pkg, srcDir] of Object.entries(runtimeDirs)) {
1140
+ for (const src of collectFiles(srcDir)) {
1141
+ if (extname(src) !== '.js') continue;
1142
+ const rel = relative(srcDir, src);
1143
+ const outPath = join(runtimeDir, pkg, rel);
1144
+ mkdirSync(join(outPath, '..'), { recursive: true });
896
1145
 
897
- if (useHash) {
898
- const hash = contentHash(code);
899
- const hashedName = addHash(outName, hash);
900
- hashManifest[`@what/${outName}`] = `@what/${hashedName}`;
901
- outName = hashedName;
902
- }
903
-
904
- writeFileSync(join(runtimeDir, outName), code);
905
- const gzipped = gzipSync(code);
906
- writeFileSync(join(runtimeDir, outName + '.gz'), gzipped);
1146
+ const depth = rel.split(sep).length;
1147
+ const code = minifyJS(rewriteRuntimeImports(readFileSync(src, 'utf-8'), depth));
1148
+ writeFileSync(outPath, code);
1149
+ writeFileSync(outPath + '.gz', gzipSync(code));
907
1150
  }
908
1151
  }
909
1152
 
910
- // Bundle router
911
- const routerSrc = join(routerDir, 'index.js');
912
- if (existsSync(routerSrc)) {
913
- let code = readFileSync(routerSrc, 'utf-8');
914
- code = minifyJS(code);
915
- writeFileSync(join(runtimeDir, 'router-impl.js'), code);
916
- }
917
-
918
- // Bundle islands
919
- const islandsSrc = join(serverDir, 'islands.js');
920
- if (existsSync(islandsSrc)) {
921
- let code = readFileSync(islandsSrc, 'utf-8');
922
- code = minifyJS(code);
923
- writeFileSync(join(runtimeDir, 'islands.js'), code);
1153
+ for (const [name, code] of Object.entries(RUNTIME_ENTRIES)) {
1154
+ writeFileSync(join(runtimeDir, name), code + '\n');
1155
+ writeFileSync(join(runtimeDir, name + '.gz'), gzipSync(code + '\n'));
924
1156
  }
925
1157
  }
926
1158