Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 254 additions & 66 deletions scripts/dev-hot-reload.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
# npm run dev:watch -- [options]
*/

const { execSync } = require('child_process');
const { readFileSync } = require('fs');
const { execSync, spawn } = require('child_process');
const { readFileSync, readdirSync } = require('fs');
const path = require('path');

// Get major version from .nvmrc (e.g. v22.1.0 -> v22)
const NODE_VERSION = readFileSync('.nvmrc', 'utf8').trim().split('.')[0];
Expand All @@ -30,7 +31,6 @@ const CONFIG = {
'packages/bruno-js/src/',
'packages/bruno-schema/src/'
],
ELECTRON_START_DELAY: 10, // seconds
NODEMON_WATCH_DELAY: 1000 // milliseconds
};

Expand Down Expand Up @@ -68,6 +68,40 @@ function log(level, msg) {
}
}

class ProcessManager {
constructor() {
this._procs = [];
this._shuttingDown = false;
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => this.shutdown(sig));
}
Comment on lines +75 to +77
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SIGHUP is not supported on Windows.

Per the coding guidelines, avoid Unix-only signals without Windows fallbacks. SIGHUP will fail silently or error on Windows. Consider conditionally registering it:

🛠️ Proposed fix
-    for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
+    const signals = process.platform === 'win32'
+      ? ['SIGINT', 'SIGTERM']
+      : ['SIGINT', 'SIGTERM', 'SIGHUP'];
+    for (const sig of signals) {
       process.on(sig, () => this.shutdown(sig));
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => this.shutdown(sig));
}
const signals = process.platform === 'win32'
? ['SIGINT', 'SIGTERM']
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
for (const sig of signals) {
process.on(sig, () => this.shutdown(sig));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-hot-reload.js` around lines 75 - 77, The signal registration loop
in scripts/dev-hot-reload.js currently adds 'SIGHUP' (for const sig of
['SIGINT', 'SIGTERM', 'SIGHUP']) which is unsupported on Windows; update the
registration to skip or conditionally add 'SIGHUP' when process.platform !==
'win32' (or construct the signal list based on platform) and keep the same
handler this.shutdown(sig) for the remaining signals so Windows won’t receive an
unsupported signal registration.

}

register(proc, name) {
this._procs.push({ proc, name });
proc.on('close', code => {
if (!this._shuttingDown && code !== null && code !== 0) {
log(LOG_LEVELS.ERROR, `Process "${name}" exited unexpectedly (code ${code}), shutting down...`);
Comment on lines +81 to +84
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProcessManager.register doesn't attach a listener for the child process error event. If spawning fails (e.g. command missing, permissions), Node will emit error and it can become an unhandled 'error' event, crashing the script without a controlled shutdown. Add proc.on('error', ...) to log the failure and invoke shutdown() (and consider exiting non-zero).

Suggested change
this._procs.push({ proc, name });
proc.on('close', code => {
if (!this._shuttingDown && code !== null && code !== 0) {
log(LOG_LEVELS.ERROR, `Process "${name}" exited unexpectedly (code ${code}), shutting down...`);
this._procs.push({ proc, name });
proc.on('error', err => {
if (!this._shuttingDown) {
log(LOG_LEVELS.ERROR, `Process "${name}" failed to start or crashed with an error: ${err.message}`);
process.exitCode = 1;
this.shutdown('child-error');
}
});
proc.on('close', code => {
if (!this._shuttingDown && code !== null && code !== 0) {
log(LOG_LEVELS.ERROR, `Process "${name}" exited unexpectedly (code ${code}), shutting down...`);
process.exitCode = 1;

Copilot uses AI. Check for mistakes.
this.shutdown('child-exit');
}
});
return proc;
}

shutdown(signal) {
if (this._shuttingDown) return;
this._shuttingDown = true;
log(LOG_LEVELS.INFO, `Received ${signal}, shutting down all processes...`);
for (const { proc } of this._procs) {
try { proc.kill(); } catch { /* already dead */ }
}
setTimeout(() => {
log(LOG_LEVELS.WARN, 'Force-exiting after timeout...');
process.exit(0);
}, 5000).unref();
Comment on lines +91 to +101
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shutdown() always force-exits with status 0 after the timeout, even when shutdown was triggered by an unexpected child exit. This makes it harder for callers/scripts to detect startup failures. Consider tracking a desired exit code (e.g. 1 on 'child-exit' / spawn errors) and using that in process.exit(...).

Copilot uses AI. Check for mistakes.
}
}

// Show help documentation
function showHelp() {
console.log(`
Expand Down Expand Up @@ -136,72 +170,227 @@ function reinstallDependencies() {
log(LOG_LEVELS.SUCCESS, 'Dependencies re-installation completed');
}

// Setup development environment
function startDevelopment() {
log(LOG_LEVELS.INFO, 'Starting development servers...');

const concurrently = require('concurrently');
const watchPaths = CONFIG.ELECTRON_WATCH_PATHS.map(path => `--watch "${path}"`).join(' ');

// concurrently command objects: { command, name, prefixColor, env, cwd, ipc }
const commandObjects = [
{
command: 'npm run watch --workspace=packages/bruno-common',
name: 'common',
prefixColor: 'magenta'
},
{
command: 'npm run watch --workspace=packages/bruno-converters',
name: 'converters',
prefixColor: 'green'
},
{
command: 'npm run watch --workspace=packages/bruno-query',
name: 'query',
prefixColor: 'blue'
},
{
command: 'npm run watch --workspace=packages/bruno-graphql-docs',
name: 'graphql',
prefixColor: 'white'
},
{
command: 'npm run watch --workspace=packages/bruno-requests',
name: 'requests',
prefixColor: 'gray'
},
{
command: 'npm run watch --workspace=packages/bruno-filestore',
name: 'filestore',
prefixColor: '#FA8072'
},
{
command: 'npm run dev:web',
name: 'react',
prefixColor: 'cyan'
},
{
command: `sleep ${CONFIG.ELECTRON_START_DELAY} && nodemon ${watchPaths} --ext js,jsx,ts,tsx --delay ${CONFIG.NODEMON_WATCH_DELAY}ms --exec "npm run dev --workspace=packages/bruno-electron"`,
name: 'electron',
prefixColor: 'yellow',
delay: CONFIG.ELECTRON_START_DELAY
// Packages that have a build script but are launched separately
// and must not enter the watcher startup pipeline.
const DEV_EXCLUDED_PACKAGES = new Set(['@usebruno/app']); // launched via dev:web

/**
* Read every packages/* directory and return a descriptor for each
* @usebruno/* package that has at least a `build` npm script.
*
* @param {string} rootDir - absolute path to repo root
* @returns {{ name: string, dir: string, hasWatch: boolean, internalDeps: string[] }[]}
*/
function discoverPackages(rootDir) {
const pkgsDir = path.join(rootDir, 'packages');
const results = [];

for (const folder of readdirSync(pkgsDir)) {
const pjsonPath = path.join(pkgsDir, folder, 'package.json');
let raw;
try {
raw = JSON.parse(readFileSync(pjsonPath, 'utf8'));
} catch {
continue;
}
];

const { result } = concurrently(commandObjects, {
prefix: '[{name}: {pid}]',
killOthers: ['failure', 'success'],
restartTries: 3,
restartDelay: 1000
const name = raw.name || '';
if (!name.startsWith('@usebruno/')) continue;
if (DEV_EXCLUDED_PACKAGES.has(name)) continue;

const scripts = raw.scripts || {};
if (!('build' in scripts)) continue;

const allDeps = { ...raw.dependencies, ...raw.devDependencies };
const internalDeps = Object.keys(allDeps).filter(k => k.startsWith('@usebruno/'));

results.push({ name, dir: path.join(pkgsDir, folder), hasWatch: 'watch' in scripts, internalDeps });
}

return results;
}

/**
* Topologically sort packages into dependency stages using Kahn's algorithm.
* Deps that are not in the buildable set (no build script) are ignored.
* Throws if a cycle is detected.
*
* @param {{ name: string, internalDeps: string[] }[]} packages
* @returns {typeof packages[]}[] array of stages; packages within a stage are independent
*/
function topoSort(packages) {
const byName = new Map(packages.map(p => [p.name, p]));
const inDegree = new Map(packages.map(p => [p.name, 0]));
const adj = new Map(packages.map(p => [p.name, []]));

for (const pkg of packages) {
for (const dep of pkg.internalDeps) {
if (!byName.has(dep)) continue; // dep has no build script — skip
adj.get(dep).push(pkg.name);
inDegree.set(pkg.name, inDegree.get(pkg.name) + 1);
}
}

const stages = [];
let frontier = packages.filter(p => inDegree.get(p.name) === 0).map(p => p.name);

while (frontier.length > 0) {
stages.push(frontier.map(n => byName.get(n)));
const next = [];
for (const n of frontier) {
for (const neighbour of adj.get(n)) {
inDegree.set(neighbour, inDegree.get(neighbour) - 1);
if (inDegree.get(neighbour) === 0) next.push(neighbour);
}
}
frontier = next;
}

if (stages.reduce((acc, s) => acc + s.length, 0) !== packages.length) {
throw new Error('Cycle detected in @usebruno/* dependency graph');
}

return stages;
}

/**
* Discover all buildable @usebruno/* packages and return them as topo-sorted stages.
*
* @param {string} rootDir
* @returns {ReturnType<typeof topoSort>}
*/
function buildDevPackageGraph(rootDir) {
const packages = discoverPackages(rootDir);
log(LOG_LEVELS.DEBUG, `Discovered ${packages.length} buildable @usebruno/* packages`);
const stages = topoSort(packages);
stages.forEach((s, i) =>
log(LOG_LEVELS.DEBUG, `Stage ${i + 1}: ${s.map(p => p.name).join(', ')}`)
);
return stages;
}

// Color palette for auto-assigning colors to discovered package watchers
const WATCHER_COLORS = [
'\x1b[35m', // magenta
'\x1b[32m', // green
'\x1b[34m', // blue
'\x1b[37m', // white
'\x1b[90m', // gray
'\x1b[91m', // salmon
'\x1b[33m', // yellow
'\x1b[36m', // cyan
];
let _watcherColorIndex = 0;
function nextWatcherColor() {
return WATCHER_COLORS[_watcherColorIndex++ % WATCHER_COLORS.length];
}

// Spawn a process and prefix every line of its output with [name]
function spawnWithPrefix(cmd, args, name, color) {
const prefix = `${color}[${name}]${COLORS.nc} `;
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });

proc.stdout.on('data', data =>
data.toString().split('\n').filter(Boolean).forEach(line =>
process.stdout.write(prefix + line + '\n')
)
);
proc.stderr.on('data', data =>
data.toString().split('\n').filter(Boolean).forEach(line =>
process.stderr.write(prefix + line + '\n')
)
);
Comment on lines +288 to +302
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spawnWithPrefix splits each stdout/stderr chunk by \n and re-adds newlines. Stream chunk boundaries are arbitrary, so this can break lines (partial lines get treated as complete) and interleave prefixes incorrectly. Consider buffering per-stream until a newline is seen (or use readline on the stream) so prefixes are applied to complete lines only.

Suggested change
// Spawn a process and prefix every line of its output with [name]
function spawnWithPrefix(cmd, args, name, color) {
const prefix = `${color}[${name}]${COLORS.nc} `;
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
proc.stdout.on('data', data =>
data.toString().split('\n').filter(Boolean).forEach(line =>
process.stdout.write(prefix + line + '\n')
)
);
proc.stderr.on('data', data =>
data.toString().split('\n').filter(Boolean).forEach(line =>
process.stderr.write(prefix + line + '\n')
)
);
function attachPrefixedOutput(stream, output, prefix) {
let buffer = '';
stream.on('data', data => {
buffer += data.toString();
let newlineIndex = buffer.indexOf('\n');
while (newlineIndex !== -1) {
let line = buffer.slice(0, newlineIndex);
if (line.endsWith('\r')) {
line = line.slice(0, -1);
}
output.write(prefix + line + '\n');
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf('\n');
}
});
stream.on('end', () => {
if (buffer.length > 0) {
output.write(prefix + buffer + '\n');
}
});
}
// Spawn a process and prefix every line of its output with [name]
function spawnWithPrefix(cmd, args, name, color) {
const prefix = `${color}[${name}]${COLORS.nc} `;
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
attachPrefixedOutput(proc.stdout, process.stdout, prefix);
attachPrefixedOutput(proc.stderr, process.stderr, prefix);

Copilot uses AI. Check for mistakes.

return proc;
}

/**
* Start or build all packages in a single topo-sort stage, then resolve.
* - Packages with a `watch` script: spawned as long-lived watchers; waits
* for rollup's "created " signal before resolving.
* - Packages without a `watch` script (e.g. bruno-schema-types): one-time
* build only; not registered with ProcessManager (they exit by design).
*
* All packages in the stage are started concurrently.
*
* @param {{ name: string, dir: string, hasWatch: boolean }[]} stage
* @param {ProcessManager} procManager
* @param {number} stageIndex
*/
async function startWatcherStage(stage, procManager, stageIndex) {
log(LOG_LEVELS.INFO, `Stage ${stageIndex + 1}: ${stage.map(p => p.name).join(', ')}`);

const tasks = stage.map(pkg => {
const shortName = pkg.name.replace('@usebruno/', '');
const workspaceFlag = `--workspace=${pkg.dir}`;
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspaceFlag is built from pkg.dir (an absolute filesystem path). npm workspaces typically expect a workspace name (e.g. @usebruno/common) or a repo-relative path (e.g. packages/bruno-common). If npm doesn't recognize the absolute path, it can fall back to the root watch script (which exists) and recursively re-run this script. Use --workspace=${pkg.name} or --workspace=${path.relative(rootDir, pkg.dir)} instead.

Suggested change
const workspaceFlag = `--workspace=${pkg.dir}`;
const workspaceFlag = `--workspace=${pkg.name}`;

Copilot uses AI. Check for mistakes.

if (pkg.hasWatch) {
const proc = procManager.register(
spawnWithPrefix('npm', ['run', 'watch', workspaceFlag], shortName, nextWatcherColor()),
shortName
);
return new Promise(resolve => {
const onData = data => {
if (data.toString().includes('created ')) {
proc.stdout.off('data', onData);
proc.stderr.off('data', onData);
log(LOG_LEVELS.SUCCESS, `${shortName} — initial watch build done`);
resolve();
}
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
Comment on lines +332 to +342
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The promise used to detect watcher readiness only resolves when output contains created . If the watch process exits early (e.g. rollup config error) before printing that line, this promise never resolves/rejects and startup hangs until the forced shutdown timeout. Consider rejecting on close (non-zero) and/or adding a readiness timeout with a clear error message.

Suggested change
return new Promise(resolve => {
const onData = data => {
if (data.toString().includes('created ')) {
proc.stdout.off('data', onData);
proc.stderr.off('data', onData);
log(LOG_LEVELS.SUCCESS, `${shortName} — initial watch build done`);
resolve();
}
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
return new Promise((resolve, reject) => {
let isReady = false;
const readinessTimeoutMs = 30000;
const cleanup = () => {
clearTimeout(readinessTimeout);
proc.stdout.off('data', onData);
proc.stderr.off('data', onData);
proc.off('close', onClose);
proc.off('error', onError);
};
const onData = data => {
if (data.toString().includes('created ')) {
isReady = true;
cleanup();
log(LOG_LEVELS.SUCCESS, `${shortName} — initial watch build done`);
resolve();
}
};
const onClose = code => {
if (!isReady) {
cleanup();
reject(new Error(`Watch failed for ${shortName} before readiness${code !== null ? ` (exit ${code})` : ''}`));
}
};
const onError = err => {
if (!isReady) {
cleanup();
reject(err);
}
};
const readinessTimeout = setTimeout(() => {
if (!isReady) {
cleanup();
reject(new Error(`Timed out waiting for ${shortName} watch readiness after ${readinessTimeoutMs}ms`));
}
}, readinessTimeoutMs);
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
proc.on('close', onClose);
proc.on('error', onError);

Copilot uses AI. Check for mistakes.
});
Comment on lines +332 to +343
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential hang if watcher output never contains "created ".

This assumes all watchers are rollup-based and emit "created ". If a package uses a different bundler or fails silently, this promise never resolves. Consider adding a timeout or a configurable ready signal.

🔧 Suggested timeout guard
     if (pkg.hasWatch) {
       const proc = procManager.register(
         spawnWithPrefix('npm', ['run', 'watch', workspaceFlag], shortName, nextWatcherColor()),
         shortName
       );
-      return new Promise(resolve => {
+      return new Promise((resolve, reject) => {
+        const timeout = setTimeout(() => {
+          reject(new Error(`${shortName} — timed out waiting for initial build`));
+        }, 60000);
         const onData = data => {
           if (data.toString().includes('created ')) {
             proc.stdout.off('data', onData);
             proc.stderr.off('data', onData);
+            clearTimeout(timeout);
             log(LOG_LEVELS.SUCCESS, `${shortName} — initial watch build done`);
             resolve();
           }
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/dev-hot-reload.js` around lines 332 - 343, The promise waiting for a
watcher ready signal (the new Promise that registers onData on
proc.stdout/proc.stderr and resolves when data.includes('created ')) can hang if
the output never contains "created "; add a timeout guard and optional
configurable ready signal: when creating the promise in
scripts/dev-hot-reload.js, accept/configure a timeoutMs and/or readyPattern (env
var or parameter), start a timer that will reject or resolve (choose behavior)
after timeoutMs, and ensure both the timer and the stdout/stderr listeners
(onData) are cleaned up in all code paths; update references to proc, onData,
log and LOG_LEVELS.SUCCESS/shortName so the handler clears listeners and timer
before resolving on match or finishing on timeout.

} else {
// One-time build — not registered with ProcessManager
log(LOG_LEVELS.INFO, `${shortName} — running one-time build (no watch script)`);
return new Promise((resolve, reject) => {
const proc = spawn('npm', ['run', 'build', workspaceFlag], { stdio: 'inherit' });
proc.on('close', code => {
if (code === 0) {
log(LOG_LEVELS.SUCCESS, `${shortName} — build done`);
resolve();
} else {
reject(new Error(`Build failed for ${shortName} (exit ${code})`));
}
});
proc.on('error', reject);
});
}
});

result
.then(() => log(LOG_LEVELS.SUCCESS, 'All processes completed successfully'))
.catch(err => {
log(LOG_LEVELS.ERROR, 'Development environment failed to start');
console.error(err);
process.exit(1);
});
await Promise.all(tasks);
log(LOG_LEVELS.SUCCESS, `Stage ${stageIndex + 1} complete`);
}

// Setup development environment
async function startDevelopment() {
const rootDir = path.join(__dirname, '..');
const procManager = new ProcessManager();

const stages = buildDevPackageGraph(rootDir);
for (let i = 0; i < stages.length; i++) {
await startWatcherStage(stages[i], procManager, i);
}
Comment on lines +366 to +374
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startDevelopment is declared async and can throw/reject (e.g. cycle detection in topoSort, failed one-time builds). However the current entrypoint doesn't await it, so failures can become unhandled promise rejections instead of being caught by the outer main().catch(...). Either await startDevelopment() in the entrypoint or make startDevelopment non-async and handle errors internally.

Copilot uses AI. Check for mistakes.

log(LOG_LEVELS.SUCCESS, 'All watchers ready — starting React + Electron...');

procManager.register(
spawnWithPrefix('npm', ['run', 'dev:web'], 'react', '\x1b[36m'),
'react'
);

// Build nodemon args without a shell wrapper so signals propagate correctly
const nodemonArgs = [
...CONFIG.ELECTRON_WATCH_PATHS.flatMap(p => ['--watch', p]),
'--ext', 'js,jsx,ts,tsx',
'--delay', `${CONFIG.NODEMON_WATCH_DELAY}ms`,
'--exec', 'npm run dev --workspace=packages/bruno-electron'
];
procManager.register(
spawnWithPrefix('nodemon', nodemonArgs, 'electron', '\x1b[33m'),
'electron'
);
}

// Main function
Expand All @@ -228,7 +417,6 @@ function startDevelopment() {
// Ensure required global packages and node version
ensureNodeVersion(CONFIG.NODE_VERSION);
ensureGlobalPackage('nodemon');
ensureGlobalPackage('concurrently');

// Run setup if requested
if (runSetup) {
Expand Down
Loading