Skip to content

Commit d9ae7b5

Browse files
authored
Merge pull request #39 from kaifcoder/feature/unified-hotreload
Feature/unified hotreload
2 parents f93e100 + 4e95f28 commit d9ae7b5

File tree

7 files changed

+310
-30
lines changed

7 files changed

+310
-30
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Building a production-style polyglot microservice environment normally requires
5353
Use it to prototype architectures, onboard teams faster, or spin up reproducible demos / PoCs.
5454

5555
## Features
56+
5657
- 🚀 Rapid polyglot monorepo scaffolding (Node.js, Python/FastAPI, Go, Java Spring Boot, Next.js)
5758
- 🧩 Optional presets: Turborepo, Nx, or Basic runner
5859
- 🐳 Automatic Dockerfile + Docker Compose generation
@@ -61,6 +62,7 @@ Use it to prototype architectures, onboard teams faster, or spin up reproducible
6162
- 📦 Shared package (`packages/shared`) for cross-service JS utilities
6263
- 🧪 Vitest test setup for the CLI itself
6364
- 🌈 Colorized dev logs & health probing for Node/frontend services
65+
- 🔥 Unified hot reload aggregator (`create-polyglot hot`) for Node, Next.js, Python (uvicorn), Go, and Java (Spring Boot)
6466
- 🔌 Plugin skeleton generation (`create-polyglot add plugin <name>`)
6567
- 📄 Single source of truth: `polyglot.json`
6668
- ✅ Safe guards: port collision checks, reserved name checks, graceful fallbacks
@@ -69,6 +71,7 @@ Use it to prototype architectures, onboard teams faster, or spin up reproducible
6971
## Quick Start
7072
Scaffold a workspace named `my-org` with multiple services:
7173

74+
| `create-polyglot hot [--services <subset>] [--dry-run]` | Unified hot reload (restart / HMR) across services. |
7275
```bash
7376
npx create-polyglot init my-org -s node,python,go,java,frontend --git --yes
7477
```
@@ -77,6 +80,20 @@ Then run everything (Node + frontend locally):
7780
```bash
7881
create-polyglot dev
7982
```
83+
Unified hot reload (auto restart / HMR):
84+
```bash
85+
create-polyglot hot
86+
```
87+
88+
Dry run (see what would execute without starting processes):
89+
```bash
90+
create-polyglot hot --dry-run
91+
```
92+
93+
Limit to a subset (by names or types):
94+
```bash
95+
create-polyglot hot --services node,python
96+
```
8097

8198
Or via Docker Compose:
8299
```bash

bin/index.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'path';
88
import { renderServicesTable } from './lib/ui.js';
99
import { runDev } from './lib/dev.js';
1010
import { startAdminDashboard } from './lib/admin.js';
11+
import { runHotReload } from './lib/hotreload.js';
1112

1213
const program = new Command();
1314

@@ -164,6 +165,22 @@ program
164165
process.exit(1);
165166
}
166167
});
168+
169+
// Unified hot reload aggregator
170+
program
171+
.command('hot')
172+
.description('Unified hot reload across services (auto-restart / HMR)')
173+
.option('-s, --services <list>', 'Subset of services (comma names or types)')
174+
.option('--dry-run', 'Show what would run without starting processes')
175+
.action(async (opts) => {
176+
try {
177+
const filter = opts.services ? opts.services.split(',').map(s => s.trim()).filter(Boolean) : [];
178+
await runHotReload({ servicesFilter: filter, dryRun: !!opts.dryRun });
179+
} catch (e) {
180+
console.error(chalk.red('Failed to start hot reload:'), e.message);
181+
process.exit(1);
182+
}
183+
});
167184

168185
program.parse();
169186

bin/lib/hotreload.js

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { spawn } from 'node:child_process';
4+
import chalk from 'chalk';
5+
6+
/*
7+
Unified Hot Reload Aggregator
8+
----------------------------
9+
Goal: Provide a single command that watches source files for all supported
10+
service types and restarts their dev process (or equivalent) on changes.
11+
12+
Supported service types & strategy:
13+
- node: restart on .js/.mjs/.cjs/.ts changes inside service dir (excluding node_modules).
14+
If service has dev script using nodemon/ts-node-dev already, we just run it.
15+
- frontend (Next.js): rely on next dev internal HMR (no restart). We'll watch config files
16+
(next.config.*, .env*) and trigger a manual restart if they change.
17+
- python (FastAPI): use uvicorn with --reload if available; if requirements specify uvicorn,
18+
we spawn `uvicorn app.main:app --reload --port <port>` instead of existing dev script.
19+
- go: detect main.go; use `go run .` and restart on .go file changes.
20+
- java (Spring Boot): use `mvn spring-boot:run` and restart on changes to src/ (requires JDK & Maven).
21+
For performance we debounce restarts.
22+
23+
Edge cases:
24+
- Missing runtime tool (e.g., mvn not installed) -> warn and skip hot reload for that service.
25+
- Large flurries of changes -> debounce restart (default 400ms).
26+
- Service without supported pattern -> skip with yellow message.
27+
28+
Exposed function: runHotReload({ servicesFilter, dryRun })
29+
*/
30+
31+
const DEBOUNCE_MS = 400;
32+
33+
function colorFor(name) {
34+
const colors = [chalk.cyan, chalk.magenta, chalk.green, chalk.blue, chalk.yellow, chalk.redBright];
35+
let sum = 0; for (let i=0;i<name.length;i++) sum += name.charCodeAt(i);
36+
return colors[sum % colors.length];
37+
}
38+
39+
export async function runHotReload({ servicesFilter = [], dryRun = false } = {}) {
40+
const cwd = process.cwd();
41+
const cfgPath = path.join(cwd, 'polyglot.json');
42+
if (!fs.existsSync(cfgPath)) {
43+
console.error(chalk.red('polyglot.json not found. Run inside a generated workspace.'));
44+
process.exit(1);
45+
}
46+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
47+
let services = cfg.services || [];
48+
if (servicesFilter.length) {
49+
const filterSet = new Set(servicesFilter);
50+
services = services.filter(s => filterSet.has(s.name) || filterSet.has(s.type));
51+
}
52+
if (!services.length) {
53+
console.log(chalk.yellow('No matching services for hot reload.'));
54+
return;
55+
}
56+
57+
console.log(chalk.cyan(`\n🔥 Unified hot reload starting (${services.length} services)...`));
58+
59+
const watchers = [];
60+
const processes = new Map();
61+
62+
function spawnService(svc) {
63+
const svcPath = path.join(cwd, svc.path);
64+
if (!fs.existsSync(svcPath)) {
65+
console.log(chalk.yellow(`Skipping ${svc.name} (path missing)`));
66+
return;
67+
}
68+
const color = colorFor(svc.name);
69+
let cmd, args, watchGlobs, restartStrategy;
70+
switch (svc.type) {
71+
case 'node': {
72+
const pkgPath = path.join(svcPath, 'package.json');
73+
if (!fs.existsSync(pkgPath)) { console.log(chalk.yellow(`Skipping ${svc.name} (no package.json)`)); return; }
74+
let pkg; try { pkg = JSON.parse(fs.readFileSync(pkgPath,'utf-8')); } catch { console.log(chalk.yellow(`Skipping ${svc.name} (invalid package.json)`)); return; }
75+
const script = pkg.scripts?.dev || pkg.scripts?.start;
76+
if (!script) { console.log(chalk.yellow(`Skipping ${svc.name} (no dev/start script)`)); return; }
77+
// Prefer existing nodemon usage; else run node and restart manually.
78+
const usesNodemon = /nodemon/.test(script);
79+
if (usesNodemon) {
80+
cmd = detectPM(svcPath);
81+
args = ['run', pkg.scripts.dev ? 'dev' : 'start'];
82+
watchGlobs = []; // nodemon handles its own watching
83+
restartStrategy = 'internal';
84+
} else {
85+
cmd = detectPM(svcPath);
86+
args = ['run', pkg.scripts.dev ? 'dev' : 'start'];
87+
watchGlobs = ['**/*.js','**/*.mjs','**/*.cjs','**/*.ts','!node_modules/**'];
88+
restartStrategy = 'respawn';
89+
}
90+
break;
91+
}
92+
case 'frontend': {
93+
// Next.js handles HMR internally; only restart if config changes.
94+
cmd = detectPM(svcPath); args = ['run','dev'];
95+
watchGlobs = ['next.config.*','*.env','*.env.*'];
96+
restartStrategy = 'internal+config-restart';
97+
break;
98+
}
99+
case 'python': {
100+
// Use uvicorn --reload directly if possible.
101+
cmd = 'uvicorn';
102+
args = ['app.main:app','--reload','--port', String(svc.port)];
103+
watchGlobs = ['app/**/*.py','*.py'];
104+
restartStrategy = 'respawn';
105+
break;
106+
}
107+
case 'go': {
108+
cmd = 'go'; args = ['run','.'];
109+
watchGlobs = ['**/*.go'];
110+
restartStrategy = 'respawn';
111+
break;
112+
}
113+
case 'java': {
114+
// Spring Boot dev run; restart on src changes.
115+
cmd = 'mvn'; args = ['spring-boot:run'];
116+
watchGlobs = ['src/main/java/**/*.java','src/main/resources/**/*'];
117+
restartStrategy = 'respawn';
118+
break;
119+
}
120+
default:
121+
console.log(chalk.yellow(`Skipping ${svc.name} (unsupported type ${svc.type})`));
122+
return;
123+
}
124+
125+
if (dryRun) {
126+
console.log(chalk.gray(`[dry-run] ${svc.name}: ${cmd} ${args.join(' ')} (${restartStrategy})`));
127+
return;
128+
}
129+
130+
const child = spawn(cmd, args, { cwd: svcPath, env: { ...process.env, PORT: String(svc.port) }, shell: true });
131+
processes.set(svc.name, { child, svc, watchGlobs, restartStrategy, svcPath });
132+
child.stdout.on('data', d => process.stdout.write(color(`[${svc.name}] `) + d.toString()));
133+
child.stderr.on('data', d => process.stderr.write(color(`[${svc.name}] `) + d.toString()));
134+
child.on('exit', code => {
135+
process.stdout.write(color(`[${svc.name}] exited (${code})`)+"\n");
136+
});
137+
138+
if (watchGlobs && watchGlobs.length) {
139+
// Minimal glob watching without external deps: recursive fs watch + filter.
140+
// NOTE: macOS recursive watch limitations; we manually walk tree initially.
141+
const fileList = listFilesRecursive(svcPath);
142+
const matcher = buildMatcher(watchGlobs);
143+
const pending = { timeout: null };
144+
145+
function scheduleRestart() {
146+
if (pending.timeout) clearTimeout(pending.timeout);
147+
pending.timeout = setTimeout(() => {
148+
const meta = processes.get(svc.name);
149+
if (!meta) return;
150+
console.log(color(`↻ Restarting ${svc.name} due to changes...`));
151+
meta.child.kill('SIGINT');
152+
spawnService(svc); // respawn fresh
153+
}, DEBOUNCE_MS);
154+
}
155+
156+
// Initial watchers per directory
157+
const dirs = new Set(fileList.map(f => path.dirname(f)));
158+
for (const dir of dirs) {
159+
try {
160+
const w = fs.watch(dir, { persistent: true }, (evt, fileName) => {
161+
if (!fileName) return;
162+
const rel = path.relative(svcPath, path.join(dir, fileName));
163+
if (matcher(rel)) {
164+
scheduleRestart();
165+
}
166+
});
167+
watchers.push(w);
168+
} catch {}
169+
}
170+
}
171+
}
172+
173+
// Spawn all initially
174+
for (const svc of services) spawnService(svc);
175+
176+
if (!dryRun) {
177+
console.log(chalk.blue('Hot reload active. Press Ctrl+C to exit.'));
178+
process.on('SIGINT', () => {
179+
for (const { child } of processes.values()) child.kill('SIGINT');
180+
for (const w of watchers) try { w.close(); } catch {}
181+
process.exit(0);
182+
});
183+
}
184+
}
185+
186+
function listFilesRecursive(root) {
187+
const out = [];
188+
function walk(p) {
189+
let stats; try { stats = fs.statSync(p); } catch { return; }
190+
if (stats.isDirectory()) {
191+
const entries = fs.readdirSync(p);
192+
for (const e of entries) walk(path.join(p, e));
193+
} else {
194+
out.push(p);
195+
}
196+
}
197+
walk(root);
198+
return out;
199+
}
200+
201+
// Very small glob matcher supporting *, **, suffix patterns and exclusion !prefix.
202+
function buildMatcher(globs) {
203+
const positives = globs.filter(g => !g.startsWith('!'));
204+
const negatives = globs.filter(g => g.startsWith('!')).map(g => g.slice(1));
205+
return rel => {
206+
if (negatives.some(n => minimatchBasic(rel, n))) return false;
207+
return positives.some(p => minimatchBasic(rel, p));
208+
};
209+
}
210+
211+
function minimatchBasic(rel, pattern) {
212+
// Convert pattern to regex roughly; handle **/, *, and dotfiles.
213+
let regex = pattern
214+
.replace(/[.+^${}()|\-]/g, r => `\\${r}`)
215+
.replace(/\\\*\*\//g, '(?:.+/)?') // /**/ style
216+
.replace(/\*\*/g, '.*')
217+
.replace(/\*/g, '[^/]*');
218+
return new RegExp(`^${regex}$`).test(rel);
219+
}
220+
221+
function detectPM(root) {
222+
if (fs.existsSync(path.join(root,'pnpm-lock.yaml'))) return 'pnpm';
223+
if (fs.existsSync(path.join(root,'yarn.lock'))) return 'yarn';
224+
if (fs.existsSync(path.join(root,'bun.lockb'))) return 'bun';
225+
return 'npm';
226+
}

0 commit comments

Comments
 (0)