Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

watch: add initial implementation for node --watch #44366

Merged
merged 1 commit into from Sep 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 49 additions & 0 deletions doc/api/cli.md
Expand Up @@ -1577,6 +1577,53 @@ on the number of online processors.
If the value provided is larger than V8's maximum, then the largest value
will be chosen.

### `--watch`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Starts Node.js in watch mode.
When in watch mode, changes in the watched files cause the Node.js process to
restart.
By default, watch mode will watch the entry point
and any required or imported module.
Use `--watch-path` to specify what paths to watch.

This flag cannot be combined with
`--check`, `--eval`, `--interactive`, or the REPL.

```console
$ node --watch index.js
```

### `--watch-path`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Starts Node.js in watch mode and specifies what paths to watch.
When in watch mode, changes in the watched paths cause the Node.js process to
restart.
This will turn off watching of required or imported modules, even when used in
combination with `--watch`.

This flag cannot be combined with
`--check`, `--eval`, `--interactive`, or the REPL.

```console
$ node --watch-path=./src --watch-path=./tests index.js
```

This option is only supported on macOS and Windows.
An `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` exception will be thrown
when the option is used on a platform that does not support it.

### `--zero-fill-buffers`

<!-- YAML
Expand Down Expand Up @@ -1880,6 +1927,8 @@ Node.js options that are allowed are:
* `--use-largepages`
* `--use-openssl-ca`
* `--v8-pool-size`
* `--watch-path`
* `--watch`
* `--zero-fill-buffers`

<!-- node-options-node end -->
Expand Down
49 changes: 17 additions & 32 deletions lib/internal/assert/assertion_error.js
Expand Up @@ -21,15 +21,12 @@ const { inspect } = require('internal/util/inspect');
const {
removeColors,
} = require('internal/util');
const colors = require('internal/util/colors');
const {
validateObject,
} = require('internal/validators');
const { isErrorStackTraceLimitWritable } = require('internal/errors');

let blue = '';
let green = '';
let red = '';
let white = '';

const kReadableOperator = {
deepStrictEqual: 'Expected values to be strictly deep-equal:',
Expand Down Expand Up @@ -169,7 +166,7 @@ function createErrDiff(actual, expected, operator) {
// Only remove lines in case it makes sense to collapse those.
// TODO: Accept env to always show the full error.
if (actualLines.length > 50) {
actualLines[46] = `${blue}...${white}`;
actualLines[46] = `${colors.blue}...${colors.white}`;
while (actualLines.length > 47) {
ArrayPrototypePop(actualLines);
}
Expand All @@ -182,7 +179,7 @@ function createErrDiff(actual, expected, operator) {
// There were at least five identical lines at the end. Mark a couple of
// skipped.
if (i >= 5) {
end = `\n${blue}...${white}${end}`;
end = `\n${colors.blue}...${colors.white}${end}`;
skipped = true;
}
if (other !== '') {
Expand All @@ -193,15 +190,15 @@ function createErrDiff(actual, expected, operator) {
let printedLines = 0;
let identical = 0;
const msg = kReadableOperator[operator] +
`\n${green}+ actual${white} ${red}- expected${white}`;
const skippedMsg = ` ${blue}...${white} Lines skipped`;
`\n${colors.green}+ actual${colors.white} ${colors.red}- expected${colors.white}`;
const skippedMsg = ` ${colors.blue}...${colors.white} Lines skipped`;

let lines = actualLines;
let plusMinus = `${green}+${white}`;
let plusMinus = `${colors.green}+${colors.white}`;
let maxLength = expectedLines.length;
if (actualLines.length < maxLines) {
lines = expectedLines;
plusMinus = `${red}-${white}`;
plusMinus = `${colors.red}-${colors.white}`;
maxLength = actualLines.length;
}

Expand All @@ -216,7 +213,7 @@ function createErrDiff(actual, expected, operator) {
res += `\n ${lines[i - 3]}`;
printedLines++;
} else {
res += `\n${blue}...${white}`;
res += `\n${colors.blue}...${colors.white}`;
skipped = true;
}
}
Expand Down Expand Up @@ -272,7 +269,7 @@ function createErrDiff(actual, expected, operator) {
res += `\n ${actualLines[i - 3]}`;
printedLines++;
} else {
res += `\n${blue}...${white}`;
res += `\n${colors.blue}...${colors.white}`;
skipped = true;
}
}
Expand All @@ -286,8 +283,8 @@ function createErrDiff(actual, expected, operator) {
identical = 0;
// Add the actual line to the result and cache the expected diverging
// line so consecutive diverging lines show up as +++--- and not +-+-+-.
res += `\n${green}+${white} ${actualLine}`;
other += `\n${red}-${white} ${expectedLine}`;
res += `\n${colors.green}+${colors.white} ${actualLine}`;
other += `\n${colors.red}-${colors.white} ${expectedLine}`;
printedLines += 2;
// Lines are identical
} else {
Expand All @@ -306,8 +303,8 @@ function createErrDiff(actual, expected, operator) {
}
// Inspected object to big (Show ~50 rows max)
if (printedLines > 50 && i < maxLines - 2) {
return `${msg}${skippedMsg}\n${res}\n${blue}...${white}${other}\n` +
`${blue}...${white}`;
return `${msg}${skippedMsg}\n${res}\n${colors.blue}...${colors.white}${other}\n` +
`${colors.blue}...${colors.white}`;
}
}

Expand Down Expand Up @@ -347,21 +344,9 @@ class AssertionError extends Error {
if (message != null) {
super(String(message));
} else {
if (process.stderr.isTTY) {
// Reset on each call to make sure we handle dynamically set environment
// variables correct.
if (process.stderr.hasColors()) {
blue = '\u001b[34m';
green = '\u001b[32m';
white = '\u001b[39m';
red = '\u001b[31m';
} else {
blue = '';
green = '';
white = '';
red = '';
}
}
// Reset colors on each call to make sure we handle dynamically set environment
// variables correct.
colors.refresh();
// Prevent the error stack from being visible by duplicating the error
// in a very close way to the original in case both sides are actually
// instances of Error.
Expand Down Expand Up @@ -393,7 +378,7 @@ class AssertionError extends Error {
// Only remove lines in case it makes sense to collapse those.
// TODO: Accept env to always show the full error.
if (res.length > 50) {
res[46] = `${blue}...${white}`;
res[46] = `${colors.blue}...${colors.white}`;
while (res.length > 47) {
ArrayPrototypePop(res);
}
Expand Down
132 changes: 132 additions & 0 deletions lib/internal/main/watch_mode.js
@@ -0,0 +1,132 @@
'use strict';
const {
ArrayPrototypeFilter,
ArrayPrototypeForEach,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePushApply,
ArrayPrototypeSlice,
} = primordials;

const {
prepareMainThreadExecution,
markBootstrapComplete
} = require('internal/process/pre_execution');
const { triggerUncaughtException } = internalBinding('errors');
const { getOptionValue } = require('internal/options');
const { emitExperimentalWarning } = require('internal/util');
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
const { green, blue, red, white, clear } = require('internal/util/colors');

const { spawn } = require('child_process');
const { inspect } = require('util');
const { setTimeout, clearTimeout } = require('timers');
const { resolve } = require('path');
const { once, on } = require('events');


prepareMainThreadExecution(false, false);
markBootstrapComplete();

// TODO(MoLow): Make kill signal configurable
const kKillSignal = 'SIGTERM';
const kShouldFilterModules = getOptionValue('--watch-path').length === 0;
const kWatchedPaths = ArrayPrototypeMap(getOptionValue('--watch-path'), (path) => resolve(path));
const kCommand = ArrayPrototypeSlice(process.argv, 1);
const kCommandStr = inspect(ArrayPrototypeJoin(kCommand, ' '));
const args = ArrayPrototypeFilter(process.execArgv, (arg, i, arr) =>
arg !== '--watch-path' && arr[i - 1] !== '--watch-path' && arg !== '--watch');
ArrayPrototypePushApply(args, kCommand);

const watcher = new FilesWatcher({ throttle: 500, mode: kShouldFilterModules ? 'filter' : 'all' });
ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p));

let graceTimer;
let child;
let exited;

function start() {
exited = false;
const stdio = kShouldFilterModules ? ['inherit', 'inherit', 'inherit', 'ipc'] : undefined;
child = spawn(process.execPath, args, { stdio, env: { ...process.env, WATCH_REPORT_DEPENDENCIES: '1' } });
watcher.watchChildProcessModules(child);
child.once('exit', (code) => {
exited = true;
if (code === 0) {
process.stdout.write(`${blue}Completed running ${kCommandStr}${white}\n`);
} else {
process.stdout.write(`${red}Failed running ${kCommandStr}${white}\n`);
}
});
}

async function killAndWait(signal = kKillSignal, force = false) {
child?.removeAllListeners();
if (!child) {
return;
}
if ((child.killed || exited) && !force) {
return;
}
const onExit = once(child, 'exit');
child.kill(signal);
const { 0: exitCode } = await onExit;
return exitCode;
}

function reportGracefulTermination() {
// Log if process takes more than 500ms to stop.
let reported = false;
clearTimeout(graceTimer);
graceTimer = setTimeout(() => {
reported = true;
process.stdout.write(`${blue}Waiting for graceful termination...${white}\n`);
}, 500).unref();
return () => {
clearTimeout(graceTimer);
if (reported) {
process.stdout.write(`${clear}${green}Gracefully restarted ${kCommandStr}${white}\n`);
}
};
}

async function stop() {
watcher.clearFileFilters();
const clearGraceReport = reportGracefulTermination();
await killAndWait();
clearGraceReport();
}

async function restart() {
process.stdout.write(`${clear}${green}Restarting ${kCommandStr}${white}\n`);

Choose a reason for hiding this comment

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

This "clear" should be at least configurable, as the --preserveWatchOutput option with tsc

await stop();
start();
}

(async () => {
emitExperimentalWarning('Watch mode');

try {
start();

// eslint-disable-next-line no-unused-vars
for await (const _ of on(watcher, 'changed')) {
await restart();
}
} catch (error) {
triggerUncaughtException(error, true /* fromPromise */);
}
})();

// Exiting gracefully to avoid stdout/stderr getting written after
// parent process is killed.
// this is fairly safe since user code cannot run in this process
function signalHandler(signal) {
return async () => {
watcher.clear();
const exitCode = await killAndWait(signal, true);
process.exit(exitCode ?? 0);
};
}
process.on('SIGTERM', signalHandler('SIGTERM'));
process.on('SIGINT', signalHandler('SIGINT'));
10 changes: 10 additions & 0 deletions lib/internal/modules/cjs/loader.js
Expand Up @@ -100,6 +100,7 @@ const {
const { getOptionValue } = require('internal/options');
const preserveSymlinks = getOptionValue('--preserve-symlinks');
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
const shouldReportRequiredModules = process.env.WATCH_REPORT_DEPENDENCIES;
// Do not eagerly grab .manifest, it may be in TDZ
const policy = getOptionValue('--experimental-policy') ?
require('internal/process/policy') :
Expand Down Expand Up @@ -168,6 +169,12 @@ function updateChildren(parent, child, scan) {
ArrayPrototypePush(children, child);
}

function reportModuleToWatchMode(filename) {
if (shouldReportRequiredModules && process.send) {
process.send({ 'watch:require': filename });
}
}

const moduleParentCache = new SafeWeakMap();
function Module(id = '', parent) {
this.id = id;
Expand Down Expand Up @@ -776,6 +783,7 @@ Module._load = function(request, parent, isMain) {
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
reportModuleToWatchMode(filename);
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
Expand Down Expand Up @@ -828,6 +836,8 @@ Module._load = function(request, parent, isMain) {
module.id = '.';
}

reportModuleToWatchMode(filename);

Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;
Expand Down
4 changes: 4 additions & 0 deletions lib/internal/modules/esm/loader.js
Expand Up @@ -474,6 +474,10 @@ class ESMLoader {
getOptionValue('--inspect-brk')
);

if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) {
process.send({ 'watch:import': url });
}

const job = new ModuleJob(
this,
url,
Expand Down
23 changes: 23 additions & 0 deletions lib/internal/util/colors.js
@@ -0,0 +1,23 @@
'use strict';

module.exports = {
blue: '',
green: '',
white: '',
red: '',
clear: '',
hasColors: false,
refresh() {
if (process.stderr.isTTY) {
const hasColors = process.stderr.hasColors();
module.exports.blue = hasColors ? '\u001b[34m' : '';
module.exports.green = hasColors ? '\u001b[32m' : '';
module.exports.white = hasColors ? '\u001b[39m' : '';
module.exports.red = hasColors ? '\u001b[31m' : '';
module.exports.clear = hasColors ? '\u001bc' : '';
module.exports.hasColors = hasColors;
}
}
};

module.exports.refresh();