react-client 1.0.31 → 1.0.33

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.
@@ -1,13 +1,14 @@
1
+ // src/cli/commands/dev.ts
1
2
  /**
2
- * dev.ts — dev server for react-client
3
+ * dev.ts — Vite-like dev server for react-client
3
4
  *
4
- * - prebundles deps into .react-client/deps
5
- * - serves /@modules/<dep>
6
- * - serves /src/* with esbuild transform & inline sourcemap
7
- * - /@source-map returns a snippet for overlay mapping
8
- * - HMR broadcast via BroadcastManager (ws)
9
- *
10
- * Keep this file linted & typed. Avoids manual react-dom/client hacks.
5
+ * Features:
6
+ * - prebundles deps into .react-client/deps (persistent)
7
+ * - serves /@modules/<dep> (prebundled or on-demand esbuild bundle)
8
+ * - serves /src/* with esbuild transform + inline sourcemap
9
+ * - /@source-map returns a snippet for overlay mapping
10
+ * - HMR broadcast via BroadcastManager (ws)
11
+ * - plugin system: onTransform, onHotUpdate, onServe, onServerStart
11
12
  */
12
13
  import esbuild from 'esbuild';
13
14
  import connect from 'connect';
@@ -22,8 +23,6 @@ import chalk from 'chalk';
22
23
  import { execSync } from 'child_process';
23
24
  import { loadReactClientConfig } from '../../utils/loadConfig';
24
25
  import { BroadcastManager } from '../../server/broadcastManager';
25
- import { createRequire } from 'module';
26
- const require = createRequire(import.meta.url);
27
26
  const RUNTIME_OVERLAY_ROUTE = '/@runtime/overlay';
28
27
  function jsContentType() {
29
28
  return 'application/javascript; charset=utf-8';
@@ -174,33 +173,32 @@ export default async function dev() {
174
173
  const userConfig = (await loadReactClientConfig(root));
175
174
  const appRoot = path.resolve(root, userConfig.root || '.');
176
175
  const defaultPort = userConfig.server?.port ?? 2202;
177
- // cache dir for prebundled deps
178
176
  const cacheDir = path.join(appRoot, '.react-client', 'deps');
179
177
  await fs.ensureDir(cacheDir);
180
178
  // Detect entry (main.tsx / main.jsx)
181
- const possible = ['src/main.tsx', 'src/main.jsx'].map((p) => path.join(appRoot, p));
182
- const entry = possible.find((p) => fs.existsSync(p));
179
+ const possibleEntries = ['src/main.tsx', 'src/main.jsx'].map((p) => path.join(appRoot, p));
180
+ const entry = possibleEntries.find((p) => fs.existsSync(p));
183
181
  if (!entry) {
184
182
  console.error(chalk.red('❌ Entry not found: src/main.tsx or src/main.jsx'));
185
183
  process.exit(1);
186
184
  }
187
185
  const indexHtml = path.join(appRoot, 'index.html');
188
- // Select port
186
+ // Port
189
187
  const availablePort = await detectPort(defaultPort);
190
188
  const port = availablePort;
191
189
  if (availablePort !== defaultPort) {
192
- const response = await prompts({
190
+ const res = await prompts({
193
191
  type: 'confirm',
194
192
  name: 'useNewPort',
195
193
  message: `Port ${defaultPort} is occupied. Use ${availablePort} instead?`,
196
194
  initial: true,
197
195
  });
198
- if (!response.useNewPort) {
196
+ if (!res.useNewPort) {
199
197
  console.log('🛑 Dev server cancelled.');
200
198
  process.exit(0);
201
199
  }
202
200
  }
203
- // Ensure react-refresh runtime available (used by many templates)
201
+ // ensure react-refresh runtime exists (templates often import it)
204
202
  try {
205
203
  require.resolve('react-refresh/runtime');
206
204
  }
@@ -213,10 +211,10 @@ export default async function dev() {
213
211
  });
214
212
  }
215
213
  catch {
216
- console.warn(chalk.yellow('⚠️ automatic install of react-refresh failed; continuing without it.'));
214
+ console.warn(chalk.yellow('⚠️ auto-install failed — please install react-refresh manually.'));
217
215
  }
218
216
  }
219
- // Plugin system (core + user)
217
+ // Core plugins
220
218
  const corePlugins = [
221
219
  {
222
220
  name: 'css-hmr',
@@ -225,10 +223,10 @@ export default async function dev() {
225
223
  const escaped = JSON.stringify(code);
226
224
  return `
227
225
  const css = ${escaped};
228
- const style = document.createElement("style");
226
+ const style = document.createElement('style');
229
227
  style.textContent = css;
230
228
  document.head.appendChild(style);
231
- import.meta.hot?.accept();
229
+ if (import.meta.hot) import.meta.hot.accept();
232
230
  `;
233
231
  }
234
232
  return code;
@@ -260,30 +258,31 @@ export default async function dev() {
260
258
  await analyzeGraph(resolved, seen);
261
259
  }
262
260
  catch {
263
- // bare dependency (node_modules) - track name
261
+ // bare dependency
264
262
  seen.add(dep);
265
263
  }
266
264
  }
267
265
  }
268
266
  catch {
269
- // ignore unreadable files
267
+ // ignore unreadable
270
268
  }
271
269
  return seen;
272
270
  }
273
- // Prebundle dependencies into cache dir (parallel)
271
+ // --- Prebundle missing deps (parallel)
274
272
  async function prebundleDeps(deps) {
275
273
  if (!deps.size)
276
274
  return;
277
- const existingFiles = await fs.readdir(cacheDir);
278
- const existing = new Set(existingFiles.map((f) => f.replace(/\.js$/, '')));
279
- const missing = [...deps].filter((d) => !existing.has(d));
280
- if (!missing.length)
275
+ const cached = new Set((await fs.readdir(cacheDir)).map((f) => f.replace(/\.js$/, '')));
276
+ const missing = [...deps].filter((d) => !cached.has(d.replace(/\//g, '_')));
277
+ if (!missing.length) {
278
+ console.log(chalk.green('✅ All dependencies prebundled.'));
281
279
  return;
280
+ }
282
281
  console.log(chalk.cyan('📦 Prebundling:'), missing.join(', '));
283
282
  await Promise.all(missing.map(async (dep) => {
284
283
  try {
285
284
  const entryPoint = require.resolve(dep, { paths: [appRoot] });
286
- const outFile = path.join(cacheDir, normalizeCacheKey(dep) + '.js');
285
+ const outFile = path.join(cacheDir, dep.replace(/\//g, '_') + '.js');
287
286
  await esbuild.build({
288
287
  entryPoints: [entryPoint],
289
288
  bundle: true,
@@ -300,10 +299,10 @@ export default async function dev() {
300
299
  }
301
300
  }));
302
301
  }
303
- // Build initial prebundle graph from entry
302
+ // initial prebundle
304
303
  const depsSet = await analyzeGraph(entry);
305
304
  await prebundleDeps(depsSet);
306
- // Watch package.json for changes to re-prebundle
305
+ // re-prebundle on package.json changes
307
306
  const pkgPath = path.join(appRoot, 'package.json');
308
307
  if (await fs.pathExists(pkgPath)) {
309
308
  chokidar.watch(pkgPath).on('change', async () => {
@@ -430,10 +429,10 @@ const overlayId = "__rc_error_overlay__";
430
429
  if (!url.startsWith('/@source-map'))
431
430
  return next();
432
431
  try {
433
- const parsed = new URL(req.url ?? '', `http://localhost:${port}`);
432
+ const full = req.url ?? '';
433
+ const parsed = new URL(full, `http://localhost:${port}`);
434
434
  const file = parsed.searchParams.get('file') ?? '';
435
- const lineStr = parsed.searchParams.get('line') ?? '0';
436
- const lineNum = Number(lineStr) || 0;
435
+ const lineNum = Number(parsed.searchParams.get('line') ?? '0') || 0;
437
436
  if (!file) {
438
437
  res.writeHead(400);
439
438
  return res.end('{}');
@@ -464,39 +463,45 @@ const overlayId = "__rc_error_overlay__";
464
463
  res.end(JSON.stringify({ error: err.message }));
465
464
  }
466
465
  }));
467
- // --- Serve /src/* files (on-the-fly transform + bare import rewrite)
466
+ // Serve /src/* files (transform on the fly)
468
467
  app.use((async (req, res, next) => {
469
468
  const url = req.url ?? '';
470
469
  if (!url.startsWith('/src/') && !url.endsWith('.css'))
471
470
  return next();
472
471
  const raw = decodeURIComponent((req.url ?? '').split('?')[0]);
473
- const filePath = path.join(appRoot, raw.replace(/^\//, ''));
474
- // Try file extensions if not exact file
472
+ const filePathBase = path.join(appRoot, raw.replace(/^\//, ''));
475
473
  const exts = ['', '.tsx', '.ts', '.jsx', '.js', '.css'];
476
474
  let found = '';
477
475
  for (const ext of exts) {
478
- if (await fs.pathExists(filePath + ext)) {
479
- found = filePath + ext;
476
+ if (await fs.pathExists(filePathBase + ext)) {
477
+ found = filePathBase + ext;
480
478
  break;
481
479
  }
482
480
  }
483
481
  if (!found)
484
482
  return next();
485
483
  try {
484
+ // serve cached transform if present
485
+ if (transformCache.has(found)) {
486
+ res.setHeader('Content-Type', 'application/javascript');
487
+ res.end(transformCache.get(found));
488
+ return;
489
+ }
486
490
  let code = await fs.readFile(found, 'utf8');
487
- // rewrite bare imports -> /@modules/<dep>
491
+ // rewrite bare imports to /@modules/*
488
492
  code = code
489
493
  .replace(/\bfrom\s+['"]([^'".\/][^'"]*)['"]/g, (_m, dep) => `from "/@modules/${dep}"`)
490
494
  .replace(/\bimport\(['"]([^'".\/][^'"]*)['"]\)/g, (_m, dep) => `import("/@modules/${dep}")`);
491
- // run plugin transforms
495
+ // plugin transforms
492
496
  for (const p of plugins) {
493
497
  if (p.onTransform) {
498
+ // eslint-disable-next-line no-await-in-loop
494
499
  const out = await p.onTransform(code, found);
495
500
  if (typeof out === 'string')
496
501
  code = out;
497
502
  }
498
503
  }
499
- // choose loader by extension
504
+ // choose loader by ext
500
505
  const ext = path.extname(found).toLowerCase();
501
506
  const loader = ext === '.ts' ? 'ts' : ext === '.tsx' ? 'tsx' : ext === '.jsx' ? 'jsx' : 'js';
502
507
  const result = await esbuild.transform(code, {
@@ -514,7 +519,7 @@ const overlayId = "__rc_error_overlay__";
514
519
  res.end(`// transform error: ${e.message}`);
515
520
  }
516
521
  }));
517
- // --- Serve index.html with overlay + HMR client injection
522
+ // Serve index.html with HMR + overlay injection
518
523
  app.use((async (req, res, next) => {
519
524
  const url = req.url ?? '';
520
525
  if (url !== '/' && url !== '/index.html')
@@ -525,7 +530,7 @@ const overlayId = "__rc_error_overlay__";
525
530
  }
526
531
  try {
527
532
  let html = await fs.readFile(indexHtml, 'utf8');
528
- // inject overlay runtime and HMR client if not already present
533
+ // inject runtime overlay and HMR client if not present
529
534
  if (!html.includes(RUNTIME_OVERLAY_ROUTE)) {
530
535
  html = html.replace('</body>', `\n<script type="module" src="${RUNTIME_OVERLAY_ROUTE}"></script>\n<script type="module">
531
536
  const ws = new WebSocket("ws://" + location.host);
@@ -548,30 +553,29 @@ const overlayId = "__rc_error_overlay__";
548
553
  res.end(`// html read error: ${err.message}`);
549
554
  }
550
555
  }));
551
- // --- HMR WebSocket server
556
+ // HMR WebSocket
552
557
  const server = http.createServer(app);
553
558
  const broadcaster = new BroadcastManager(server);
554
- // Watch files and trigger plugin onHotUpdate + broadcast HMR message
559
+ // Watcher: src
555
560
  const watcher = chokidar.watch(path.join(appRoot, 'src'), { ignoreInitial: true });
556
561
  watcher.on('change', async (file) => {
557
562
  transformCache.delete(file);
558
- // plugin hook onHotUpdate optionally
563
+ // plugin hook onHotUpdate
559
564
  for (const p of plugins) {
560
565
  if (p.onHotUpdate) {
561
566
  try {
567
+ // eslint-disable-next-line no-await-in-loop
562
568
  await p.onHotUpdate(file, {
563
- // plugin only needs broadcast in most cases
564
- broadcast: (msg) => {
565
- broadcaster.broadcast(msg);
566
- },
569
+ broadcast: (m) => broadcaster.broadcast(m),
567
570
  });
568
571
  }
569
572
  catch (err) {
573
+ // plugin errors shouldn't crash server
574
+ // eslint-disable-next-line no-console
570
575
  console.warn('plugin onHotUpdate error:', err.message);
571
576
  }
572
577
  }
573
578
  }
574
- // default: broadcast update for changed file
575
579
  broadcaster.broadcast({
576
580
  type: 'update',
577
581
  path: '/' + path.relative(appRoot, file).replace(/\\/g, '/'),
@@ -581,20 +585,39 @@ const overlayId = "__rc_error_overlay__";
581
585
  server.listen(port, async () => {
582
586
  const url = `http://localhost:${port}`;
583
587
  console.log(chalk.cyan.bold('\n🚀 React Client Dev Server'));
588
+ console.log(chalk.gray('──────────────────────────────'));
584
589
  console.log(chalk.green(`⚡ Running at: ${url}`));
590
+ // open if not explicitly disabled
585
591
  if (userConfig.server?.open !== false) {
586
592
  try {
587
593
  await open(url);
588
594
  }
589
595
  catch {
590
- // ignore open errors
596
+ /* ignore */
597
+ }
598
+ }
599
+ const ctx = {
600
+ root: appRoot,
601
+ outDir: cacheDir,
602
+ app,
603
+ wss: broadcaster.wss,
604
+ httpServer: server,
605
+ broadcast: (m) => broadcaster.broadcast(m),
606
+ };
607
+ // plugin onServe / onServerStart hooks
608
+ for (const p of plugins) {
609
+ if (p.onServe) {
610
+ await p.onServe(ctx);
611
+ }
612
+ if (p.onServerStart) {
613
+ await p.onServerStart(ctx);
591
614
  }
592
615
  }
593
616
  });
594
617
  // graceful shutdown
595
618
  process.on('SIGINT', async () => {
596
619
  console.log(chalk.red('\n🛑 Shutting down...'));
597
- await watcher.close();
620
+ watcher.close();
598
621
  broadcaster.close();
599
622
  server.close();
600
623
  process.exit(0);
@@ -1,8 +1,9 @@
1
- import { WebSocketServer, WebSocket as NodeWebSocket } from 'ws';
1
+ // src/server/broadcastManager.ts
2
+ import { WebSocketServer, WebSocket } from 'ws';
2
3
  import chalk from 'chalk';
3
4
  /**
4
- * BroadcastManager — Shared WebSocket utility for dev, preview, and SSR servers.
5
- * Generic over message type T which defaults to HMRMessage.
5
+ * BroadcastManager — shared WebSocket utility for dev/preview/ssr servers.
6
+ * Uses `ws` WebSocket instances (Node) not DOM WebSocket.
6
7
  */
7
8
  export class BroadcastManager {
8
9
  constructor(server) {
@@ -10,39 +11,58 @@ export class BroadcastManager {
10
11
  this.wss = new WebSocketServer({ server });
11
12
  this.wss.on('connection', (ws) => {
12
13
  this.clients.add(ws);
14
+ console.log(chalk.gray('🔌 HMR client connected'));
13
15
  ws.on('close', () => {
14
16
  this.clients.delete(ws);
17
+ console.log(chalk.gray('❎ HMR client disconnected'));
15
18
  });
16
19
  ws.on('error', (err) => {
17
- console.error(chalk.red('⚠️ WebSocket error:'), err.message);
20
+ console.error(chalk.red('⚠️ WebSocket error:'), err?.message ?? err);
18
21
  });
19
22
  });
20
23
  }
24
+ /**
25
+ * Broadcast a message to all connected clients.
26
+ */
21
27
  broadcast(msg) {
22
28
  const data = JSON.stringify(msg);
23
29
  for (const client of this.clients) {
24
- if (client.readyState === NodeWebSocket.OPEN) {
25
- client.send(data);
30
+ // ws.OPEN === 1
31
+ if (client.readyState === WebSocket.OPEN) {
32
+ try {
33
+ client.send(data);
34
+ }
35
+ catch {
36
+ // ignore send errors per-client
37
+ }
26
38
  }
27
39
  }
28
40
  }
41
+ /**
42
+ * Send a message to a single client (ws instance from 'ws').
43
+ */
29
44
  send(ws, msg) {
30
- if (ws.readyState === NodeWebSocket.OPEN) {
45
+ if (ws.readyState === WebSocket.OPEN) {
31
46
  ws.send(JSON.stringify(msg));
32
47
  }
33
48
  }
34
- getClientCount() {
35
- return this.clients.size;
36
- }
49
+ /**
50
+ * Close all WebSocket connections and server.
51
+ */
37
52
  close() {
38
- console.log(chalk.red('🛑 Closing WebSocket connections...'));
39
- this.wss.close();
53
+ try {
54
+ console.log(chalk.red('🛑 Closing WebSocket connections...'));
55
+ this.wss.close();
56
+ }
57
+ catch {
58
+ /* ignore */
59
+ }
40
60
  for (const ws of this.clients) {
41
61
  try {
42
62
  ws.close();
43
63
  }
44
64
  catch {
45
- // ignore
65
+ // ignore per-client close errors
46
66
  }
47
67
  }
48
68
  this.clients.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-client",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "react-client is a lightweight CLI and runtime for building React apps with fast iteration.",
5
5
  "license": "MIT",
6
6
  "author": "Venkatesh Sundaram",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "sideEffects": false,
40
40
  "files": [
41
- "dist",
41
+ "dist/**/*",
42
42
  "templates"
43
43
  ],
44
44
  "workspaces": [