From 0f7a7f7de7e7ddea8e9d11ecc183aabed89d244e Mon Sep 17 00:00:00 2001 From: Kyle Herock Date: Sun, 27 Mar 2022 20:43:35 -0400 Subject: [PATCH] Port to ESM --- .eslintrc | 13 +--- README.md | 1 + bin/node-dev | 13 ---- bin/node-dev.js | 8 ++ lib/{cfg.js => cfg.cjs} | 3 +- lib/clear.js | 6 +- lib/cli.js | 10 +-- lib/hook.js | 60 --------------- lib/ignore.js | 6 +- lib/index.js | 52 +++++++------ lib/ipc.cjs | 52 +++++++++++++ lib/ipc.js | 43 ++++++++++- lib/loaders/get-format.mjs | 2 +- lib/loaders/ipc.mjs | 5 -- lib/loaders/load.mjs | 2 +- lib/{log.js => log.cjs} | 0 lib/notify.js | 8 +- lib/{ => require}/dedupe.js | 0 lib/require/package.json | 3 + lib/require/patch-fork.js | 11 +++ lib/require/patch-vm.js | 25 ++++++ lib/require/patch-worker.js | 14 ++++ lib/require/patch.js | 77 +++++++++++++++++++ lib/require/suppress-experimental-warnings.js | 23 ++++++ lib/suppress-experimental-warnings.js | 25 ------ lib/wrap.js | 71 ----------------- package.json | 17 +++- test/cli.js | 4 +- test/fixture/package.json | 3 + test/index.js | 8 +- test/log.js | 6 +- test/run.js | 4 +- test/spawn/argv.js | 4 +- test/spawn/caught.js | 4 +- test/spawn/clear.js | 6 +- test/spawn/cli-require.js | 4 +- test/spawn/cluster.js | 4 +- test/spawn/conceal.js | 4 +- test/spawn/errors.js | 4 +- test/spawn/esmodule.js | 4 +- test/spawn/exit-code.js | 4 +- test/spawn/experimental-warnings.js | 4 +- test/spawn/expose-gc.js | 4 +- test/spawn/extension-options.js | 4 +- test/spawn/graceful-ipc.js | 4 +- test/spawn/index.js | 48 ++++++------ test/spawn/inspect.js | 4 +- test/spawn/kill-fork.js | 4 +- test/spawn/no-such-module.js | 4 +- test/spawn/node-env.js | 4 +- test/spawn/relay-stdin.js | 4 +- test/spawn/require-extensions.js | 4 +- test/spawn/restart-twice.js | 4 +- test/spawn/sigterm.js | 4 +- test/spawn/timestamps.js | 4 +- test/spawn/typescript.js | 4 +- test/spawn/uncaught.js | 4 +- test/utils.js | 27 ++++--- 58 files changed, 419 insertions(+), 329 deletions(-) delete mode 100755 bin/node-dev create mode 100755 bin/node-dev.js rename lib/{cfg.js => cfg.cjs} (96%) delete mode 100644 lib/hook.js create mode 100644 lib/ipc.cjs delete mode 100644 lib/loaders/ipc.mjs rename lib/{log.js => log.cjs} (100%) rename lib/{ => require}/dedupe.js (100%) create mode 100644 lib/require/package.json create mode 100644 lib/require/patch-fork.js create mode 100644 lib/require/patch-vm.js create mode 100644 lib/require/patch-worker.js create mode 100644 lib/require/patch.js create mode 100644 lib/require/suppress-experimental-warnings.js delete mode 100644 lib/suppress-experimental-warnings.js delete mode 100755 lib/wrap.js create mode 100644 test/fixture/package.json diff --git a/.eslintrc b/.eslintrc index 230d696..77d0cd8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,20 +6,13 @@ "extends": ["eslint:recommended", "plugin:import/errors", "plugin:import/warnings"], "plugins": ["import"], "parserOptions": { - "ecmaVersion": 11 + "ecmaVersion": 13, + "sourceType": "module" }, "rules": { "eqeqeq": "error", "no-trailing-spaces": "error", "prefer-arrow-callback": "error", "semi": "error" - }, - "overrides": [ - { - "files": ["./**/*.mjs"], - "parserOptions": { - "sourceType": "module" - } - } - ] + } } diff --git a/README.md b/README.md index f47cde8..dc9fdf5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ There are a couple of command-line options that can be used to control which fil - `--respawn` - Keep watching for changes after the script has exited - `--timestamp` - The timestamp format to use for logging restarts - `--vm` - Load files using Node's VM +- `--worker` - Hook into worker_threads.Worker constructor ## Passing arguments to node diff --git a/bin/node-dev b/bin/node-dev deleted file mode 100755 index a239f82..0000000 --- a/bin/node-dev +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env node - -const dev = require('../lib'); -const cli = require('../lib/cli'); - -const { - script, - scriptArgs, - nodeArgs, - opts -} = cli(process.argv); - -dev(script, scriptArgs, nodeArgs, opts); diff --git a/bin/node-dev.js b/bin/node-dev.js new file mode 100755 index 0000000..ac6b462 --- /dev/null +++ b/bin/node-dev.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import dev from '../lib/index.js'; +import cli from '../lib/cli.js'; + +const { script, scriptArgs, nodeArgs, opts } = cli(process.argv); + +dev(script, scriptArgs, nodeArgs, opts); diff --git a/lib/cfg.js b/lib/cfg.cjs similarity index 96% rename from lib/cfg.js rename to lib/cfg.cjs index 37f698a..4d52327 100644 --- a/lib/cfg.js +++ b/lib/cfg.cjs @@ -19,7 +19,8 @@ const defaultConfig = { poll: false, respawn: false, timestamp: 'HH:MM:ss', - vm: true + vm: true, + worker: true }; function read(dir) { diff --git a/lib/clear.js b/lib/clear.js index 9b80825..304a991 100644 --- a/lib/clear.js +++ b/lib/clear.js @@ -1,4 +1,2 @@ -const control = '\u001bc'; -const clearFactory = clear => (clear ? () => process.stdout.write(control) : () => {}); - -module.exports = { clearFactory, control }; +export const control = '\u001bc'; +export const clearFactory = clear => (clear ? () => process.stdout.write(control) : () => {}); diff --git a/lib/cli.js b/lib/cli.js index 4edc038..5e30d16 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,8 +1,8 @@ -const assert = require('assert'); -const minimist = require('minimist'); -const { resolve } = require('path'); +import assert from 'assert'; +import minimist from 'minimist'; +import { resolve } from 'path'; -const { getConfig } = require('./cfg'); +import { getConfig } from './cfg.cjs'; const arrayify = v => (Array.isArray(v) ? [...v] : [v]); const argify = key => ({ arg: `--${key}`, key }); @@ -55,7 +55,7 @@ const unknownFactory = args => arg => { key && !nodeDevNumber.includes(key) && args.push({ arg, key }); }; -module.exports = argv => { +export default argv => { const nodeCustomArgs = []; const args = argv.slice(2).filter(nodeCustomFactory(nodeCustomArgs)); diff --git a/lib/hook.js b/lib/hook.js deleted file mode 100644 index a7059dc..0000000 --- a/lib/hook.js +++ /dev/null @@ -1,60 +0,0 @@ -const vm = require('vm'); - -module.exports = (patchVM, callback) => { - // Hook into Node's `require(...)` - updateHooks(); - - // Patch the vm module to watch files executed via one of these methods: - if (patchVM) { - patch(vm, 'createScript', 1); - patch(vm, 'runInThisContext', 1); - patch(vm, 'runInNewContext', 2); - patch(vm, 'runInContext', 2); - } - - /** - * Patch the specified method to watch the file at the given argument - * index. - */ - function patch(obj, method, optionsArgIndex) { - const orig = obj[method]; - if (!orig) return; - obj[method] = function () { - const opts = arguments[optionsArgIndex]; - let file = null; - if (opts) { - file = typeof opts === 'string' ? opts : opts.filename; - } - if (file) callback(file); - return orig.apply(this, arguments); - }; - } - - /** - * (Re-)install hooks for all registered file extensions. - */ - function updateHooks() { - Object.keys(require.extensions).forEach(ext => { - const fn = require.extensions[ext]; - if (typeof fn === 'function' && fn.name !== 'nodeDevHook') { - require.extensions[ext] = createHook(fn); - } - }); - } - - /** - * Returns a function that can be put into `require.extensions` in order to - * invoke the callback when a module is required for the first time. - */ - function createHook(handler) { - return function nodeDevHook(module, filename) { - if (!module.loaded) callback(module.filename); - - // Invoke the original handler - handler(module, filename); - - // Make sure the module did not hijack the handler - updateHooks(); - }; - } -}; diff --git a/lib/ignore.js b/lib/ignore.js index a3e3078..db27d30 100644 --- a/lib/ignore.js +++ b/lib/ignore.js @@ -18,7 +18,5 @@ const getPrefix = mod => { const isPrefixOf = value => prefix => value.startsWith(prefix); -const configureDeps = deps => required => deps !== -1 && getLevel(required) > deps; -const configureIgnore = ignore => required => ignore.some(isPrefixOf(required)); - -module.exports = { configureDeps, configureIgnore }; +export const configureDeps = deps => required => deps !== -1 && getLevel(required) > deps; +export const configureIgnore = ignore => required => ignore.some(isPrefixOf(required)); diff --git a/lib/index.js b/lib/index.js index f087999..d0a9720 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,15 +1,18 @@ -const { fork } = require('child_process'); -const filewatcher = require('filewatcher'); -const semver = require('semver'); -const { pathToFileURL } = require('url'); - -const { clearFactory } = require('./clear'); -const { configureDeps, configureIgnore } = require('./ignore'); -const ipc = require('./ipc'); -const logFactory = require('./log'); -const notifyFactory = require('./notify'); - -module.exports = function ( +import { fork } from 'child_process'; +import filewatcher from 'filewatcher'; +import { createRequire } from 'module'; +import semver from 'semver'; + +import { clearFactory } from './clear.js'; +import { configureDeps, configureIgnore } from './ignore.js'; +import ipc from './ipc.cjs'; +import logFactory from './log.cjs'; +import notifyFactory from './notify.js'; + +const require = createRequire(import.meta.url); +const resolveHook = hook => require.resolve('./require/' + hook); + +export default function ( script, scriptArgs, nodeArgs, @@ -18,13 +21,16 @@ module.exports = function ( debounce, dedupe, deps, + fork: patchFork, graceful_ipc: gracefulIPC, ignore, interval, notify: notifyEnabled, poll: forcePolling, respawn, - timestamp + timestamp, + vm: patchVm, + worker: patchWorker } ) { if (!script) { @@ -52,9 +58,6 @@ module.exports = function ( const isIgnored = configureIgnore(ignore); const isTooDeep = configureDeps(deps); - // Run ./dedupe.js as preload script - if (dedupe) process.env.NODE_DEV_PRELOAD = require.resolve('./dedupe'); - const watcher = filewatcher({ debounce, forcePolling, interval }); let isPaused = false; @@ -89,20 +92,25 @@ module.exports = function ( function start() { isPaused = false; - const args = nodeArgs.slice(); + const execArgv = nodeArgs.slice(); - args.push(`--require=${require.resolve('./wrap')}`); + execArgv.push(`--require=${resolveHook('suppress-experimental-warnings')}`); + if (dedupe) execArgv.push(`--require=${resolveHook('dedupe')}`); + execArgv.push(`--require=${resolveHook('patch')}`); + if (patchFork) execArgv.push(`--require=${resolveHook('patch-fork')}`); + if (patchVm) execArgv.push(`--require=${resolveHook('patch-vm')}`); + if (patchWorker) execArgv.push(`--require=${resolveHook('patch-worker')}`); const loaderName = semver.satisfies(process.version, '>=16.12.0') ? 'load' : 'get-format'; - const loaderURL = pathToFileURL(require.resolve(`./loaders/${loaderName}.mjs`)); + const loaderURL = new URL(`./loaders/${loaderName}.mjs`, import.meta.url); - args.push(`--experimental-loader=${loaderURL.href}`); + execArgv.push(`--experimental-loader=${loaderURL.href}`); child = fork(script, scriptArgs, { cwd: process.cwd(), env: process.env, - execArgv: args + execArgv }); if (respawn) { @@ -155,4 +163,4 @@ module.exports = function ( clearOutput(); start(); -}; +} diff --git a/lib/ipc.cjs b/lib/ipc.cjs new file mode 100644 index 0000000..96a4fa3 --- /dev/null +++ b/lib/ipc.cjs @@ -0,0 +1,52 @@ +const { once } = require('events'); +const { Worker } = require('worker_threads'); + +const logFactory = require('./log.cjs'); + +const cmd = 'NODE_DEV'; +const log = logFactory({}); +let nodeDevPort; + +exports.on = (src, prop, cb) => { + src.on('internalMessage', m => { + if (m.cmd === cmd && prop in m) cb(m); + }); +}; + +exports.relay = src => { + if (src instanceof Worker) { + // create a separate message channel for node-dev + const { port1, port2 } = new MessageChannel(); + port1.unref(); + port1.on('message', exports.send); + src.postMessage({ cmd, port: port2 }, [port2]); + } else { + src.on('internalMessage', m => { + if (process.connected && m.cmd === cmd) process.send(m); + }); + } +}; + +exports.send = m => { + if (process.connected) { + process.send({ ...m, cmd }); + } else if (nodeDevPort) { + // this has doesn't seem to have a race condition in testing + // but just in case, the log statement below should notify of it + nodeDevPort.postMessage({ ...m, cmd }); + } else { + log.warn( + `node-dev: The module ${m.required} was imported from an orphaned child process or worker thread` + ); + } +}; + +exports.receiveMessagePort = async src => { + // the first message received by this thread should contain the parent port + const [m] = await once(src, 'message'); + if (m && m.cmd === cmd) { + nodeDevPort = m.port; + } else { + log.warn('node-dev: unexpected message on the parentPort', m); + } +}; diff --git a/lib/ipc.js b/lib/ipc.js index c37ab44..13dd097 100644 --- a/lib/ipc.js +++ b/lib/ipc.js @@ -1,4 +1,11 @@ +const { once } = require('events'); +const { MessageChannel, Worker } = require('worker_threads'); + +const logFactory = require('./log.cjs'); + +let nodeDevPort; const cmd = 'NODE_DEV'; +const log = logFactory({}); exports.on = (src, prop, cb) => { src.on('internalMessage', m => { @@ -7,11 +14,39 @@ exports.on = (src, prop, cb) => { }; exports.relay = src => { - src.on('internalMessage', m => { - if (process.connected && m.cmd === cmd) process.send(m); - }); + if (src instanceof Worker) { + // create a separate message channel for node-dev + const { port1, port2 } = new MessageChannel(); + port1.unref(); + port1.on('message', exports.send); + src.postMessage({ cmd, port: port2 }, [port2]); + } else { + src.on('internalMessage', m => { + if (process.connected && m.cmd === cmd) process.send(m); + }); + } }; exports.send = m => { - if (process.connected) process.send({ ...m, cmd }); + if (nodeDevPort) { + // this has doesn't seem to have a race condition in testing + // but just in case, the log statement below should notify of it + nodeDevPort.postMessage({ ...m, cmd }); + } else if (process.connected) { + process.send({ ...m, cmd }); + } else { + log.warn( + `node-dev: The module ${m.required} was imported from an orphaned child process or worker thread` + ); + } +}; + +exports.receiveMessagePort = async src => { + // the first message received by this thread should contain the parent port + const [m] = await once(src, 'message'); + if (m && m.cmd === cmd) { + nodeDevPort = m.port; + } else { + log.warn('node-dev: unexpected message on the parentPort', m); + } }; diff --git a/lib/loaders/get-format.mjs b/lib/loaders/get-format.mjs index 8e72a4a..5940a3c 100644 --- a/lib/loaders/get-format.mjs +++ b/lib/loaders/get-format.mjs @@ -1,6 +1,6 @@ import { createRequire } from 'module'; import { fileURLToPath } from 'url'; -import { send } from './ipc.mjs'; +import { send } from '../ipc.cjs'; const require = createRequire(import.meta.url); diff --git a/lib/loaders/ipc.mjs b/lib/loaders/ipc.mjs deleted file mode 100644 index 61c48c4..0000000 --- a/lib/loaders/ipc.mjs +++ /dev/null @@ -1,5 +0,0 @@ -const cmd = 'NODE_DEV'; - -export const send = m => { - if (process.connected) process.send({ ...m, cmd }); -}; diff --git a/lib/loaders/load.mjs b/lib/loaders/load.mjs index 2772f60..90909d5 100644 --- a/lib/loaders/load.mjs +++ b/lib/loaders/load.mjs @@ -1,6 +1,6 @@ import { createRequire } from 'module'; import { fileURLToPath } from 'url'; -import { send } from './ipc.mjs'; +import { send } from '../ipc.cjs'; const require = createRequire(import.meta.url); diff --git a/lib/log.js b/lib/log.cjs similarity index 100% rename from lib/log.js rename to lib/log.cjs diff --git a/lib/notify.js b/lib/notify.js index aadd579..7d525c1 100644 --- a/lib/notify.js +++ b/lib/notify.js @@ -1,9 +1,11 @@ -const notifier = require('node-notifier'); +import notifier from 'node-notifier'; +import { fileURLToPath } from 'url'; -const iconLevelPath = level => require.resolve(`../icons/node_${level}.png`); +const iconLevelPath = level => + fileURLToPath(new URL(`../icons/node_${level}.png`, import.meta.url)); // Writes a message to the console and optionally displays a desktop notification. -module.exports = (notifyEnabled, log) => { +export default (notifyEnabled, log) => { return (title = 'node-dev', message, level = 'info') => { log[level](`${title}: ${message}`); diff --git a/lib/dedupe.js b/lib/require/dedupe.js similarity index 100% rename from lib/dedupe.js rename to lib/require/dedupe.js diff --git a/lib/require/package.json b/lib/require/package.json new file mode 100644 index 0000000..c9a4422 --- /dev/null +++ b/lib/require/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} \ No newline at end of file diff --git a/lib/require/patch-fork.js b/lib/require/patch-fork.js new file mode 100644 index 0000000..b210da2 --- /dev/null +++ b/lib/require/patch-fork.js @@ -0,0 +1,11 @@ +const childProcess = require('child_process'); +const { relay } = require('../ipc.cjs'); + +// Overwrite child_process.fork() so that we can hook into forked processes +// too. We also need to relay messages about required files to the parent. +const { fork } = childProcess; +childProcess.fork = (modulePath, args, options) => { + const child = fork(modulePath, args, options); + relay(child); + return child; +}; diff --git a/lib/require/patch-vm.js b/lib/require/patch-vm.js new file mode 100644 index 0000000..6d0aa93 --- /dev/null +++ b/lib/require/patch-vm.js @@ -0,0 +1,25 @@ +const vm = require('vm'); +const { send } = require('../ipc.cjs'); + +patch(vm, 'createScript', 1); +patch(vm, 'runInThisContext', 1); +patch(vm, 'runInNewContext', 2); +patch(vm, 'runInContext', 2); + +/** + * Patch the specified method to watch the file at the given argument + * index. + */ +function patch(obj, method, optionsArgIndex) { + const orig = obj[method]; + if (!orig) return; + obj[method] = function () { + const opts = arguments[optionsArgIndex]; + let file = null; + if (opts) { + file = typeof opts === 'string' ? opts : opts.filename; + } + if (file) send({ required: file }); + return orig.apply(this, arguments); + }; +} diff --git a/lib/require/patch-worker.js b/lib/require/patch-worker.js new file mode 100644 index 0000000..1e95a26 --- /dev/null +++ b/lib/require/patch-worker.js @@ -0,0 +1,14 @@ +const workerThreads = require('worker_threads'); +const { receiveMessagePort, relay } = require('../ipc.cjs'); + +const { Worker } = workerThreads; +workerThreads.Worker = class NodeDevWorker extends Worker { + constructor(...args) { + super(...args); + relay(this); + } +}; + +if (workerThreads.parentPort) { + receiveMessagePort(workerThreads.parentPort); +} diff --git a/lib/require/patch.js b/lib/require/patch.js new file mode 100644 index 0000000..c9802f7 --- /dev/null +++ b/lib/require/patch.js @@ -0,0 +1,77 @@ +const { Module } = require('module'); +const { dirname, extname } = require('path'); +const { isMainThread, getEnvironmentData, setEnvironmentData } = require('worker_threads'); +const { getConfig } = require('../cfg.cjs'); +const { send } = require('../ipc.cjs'); + +// Error handler that displays a notification and logs the stack to stderr: +process.on('uncaughtException', err => { + // Sometimes uncaught exceptions are not errors + const { message, name, stack } = + err instanceof Error ? err : new Error(`uncaughtException ${err}`); + + console.error(stack); + + // If there's a custom uncaughtException handler expect it to terminate + // the process. + const willTerminate = process.listenerCount('uncaughtException') > 1; + + send({ error: name, message, willTerminate }); +}); + +if (isMainThread) { + // When using worker threads, each thread inherits execArgv and re-evaulates + // --require scripts. Worker threads do not inherit argv, so we copy argv[1] + // to the environment for those processes to use. + setEnvironmentData('NODE_DEV_SCRIPT', require.resolve(process.argv[1])); +} + +const script = getEnvironmentData('NODE_DEV_SCRIPT'); + +// Check if a module is registered for this extension +const ext = extname(script).slice(1); +const { extensions } = getConfig(script); + +const register = extensions[ext]; + +// Support extensions where 'require' returns a function that accepts options +if (typeof register === 'object' && register.name) { + const fn = require(require.resolve(register.name, { paths: [dirname(script)] })); + if (typeof fn === 'function' && register.options) { + // require returned a function, call it with options + fn(register.options); + } +} else if (typeof register === 'string') { + require(require.resolve(register, { paths: [dirname(script)] })); +} + +// Hook into Node's `require(...)` +updateHooks(); + +/** + * (Re-)install hooks for all registered file extensions. + */ +function updateHooks() { + Object.keys(Module._extensions).forEach(ext => { + const fn = Module._extensions[ext]; + if (typeof fn === 'function' && fn.name !== 'nodeDevHook') { + Module._extensions[ext] = createHook(fn); + } + }); +} + +/** + * Returns a function that can be put into `require.extensions` in order to + * invoke the callback when a module is required for the first time. + */ +function createHook(handler) { + return function nodeDevHook(module, filename) { + if (!module.loaded) send({ required: module.filename }); + + // Invoke the original handler + handler(module, filename); + + // Make sure the module did not hijack the handler + updateHooks(); + }; +} diff --git a/lib/require/suppress-experimental-warnings.js b/lib/require/suppress-experimental-warnings.js new file mode 100644 index 0000000..5de3583 --- /dev/null +++ b/lib/require/suppress-experimental-warnings.js @@ -0,0 +1,23 @@ +// Source: https://github.com/nodejs/node/issues/30810#issue-533506790 + +const { emitWarning, emit } = process; + +process.emitWarning = (warning, ...args) => { + if (args[0] === 'ExperimentalWarning') { + return; + } + + if (args[0] && typeof args[0] === 'object' && args[0].type === 'ExperimentalWarning') { + return; + } + + return emitWarning(warning, ...args); +}; + +process.emit = (...args) => { + if (args[1]?.name === 'ExperimentalWarning') { + return; + } + + return emit.call(process, ...args); +}; diff --git a/lib/suppress-experimental-warnings.js b/lib/suppress-experimental-warnings.js deleted file mode 100644 index a881277..0000000 --- a/lib/suppress-experimental-warnings.js +++ /dev/null @@ -1,25 +0,0 @@ -// Source: https://github.com/nodejs/node/issues/30810#issue-533506790 - -module.exports = p => { - const { emitWarning, emit } = p; - - p.emitWarning = (warning, ...args) => { - if (args[0] === 'ExperimentalWarning') { - return; - } - - if (args[0] && typeof args[0] === 'object' && args[0].type === 'ExperimentalWarning') { - return; - } - - return emitWarning(warning, ...args); - }; - - p.emit = (...args) => { - if (args[1]?.name === 'ExperimentalWarning') { - return; - } - - return emit.call(p, ...args); - }; -}; diff --git a/lib/wrap.js b/lib/wrap.js deleted file mode 100755 index 71d1a63..0000000 --- a/lib/wrap.js +++ /dev/null @@ -1,71 +0,0 @@ -const { dirname, extname } = require('path'); -const childProcess = require('child_process'); -const { isMainThread } = require('worker_threads'); - -const { getConfig } = require('./cfg'); -const hook = require('./hook'); -const { relay, send } = require('./ipc'); -const suppressExperimentalWarnings = require('./suppress-experimental-warnings'); - -// Experimental warnings need to be suppressed in worker threads as well, since -// their process inherits the Node arguments from the main thread. -suppressExperimentalWarnings(process); - -// When using worker threads, each thread appears to require this file through -// the shared Node arguments (--require), so filter them out here and only run -// on the main thread. -if (!isMainThread) return; - -const script = require.resolve(process.argv[1]); -const { extensions, fork, vm } = getConfig(script); - -if (process.env.NODE_DEV_PRELOAD) { - require(process.env.NODE_DEV_PRELOAD); -} - -// We want to exit on SIGTERM, but defer to existing SIGTERM handlers. -process.once('SIGTERM', () => process.listenerCount('SIGTERM') || process.exit(0)); - -if (fork) { - // Overwrite child_process.fork() so that we can hook into forked processes - // too. We also need to relay messages about required files to the parent. - const originalFork = childProcess.fork; - childProcess.fork = (modulePath, args, options) => { - const child = originalFork(modulePath, args, options); - relay(child); - return child; - }; -} - -// Error handler that displays a notification and logs the stack to stderr: -process.on('uncaughtException', err => { - // Sometimes uncaught exceptions are not errors - const { message, name, stack } = - err instanceof Error ? err : new Error(`uncaughtException ${err}`); - - console.error(stack); - - // If there's a custom uncaughtException handler expect it to terminate - // the process. - const willTerminate = process.listenerCount('uncaughtException') > 1; - - send({ error: name, message, willTerminate }); -}); - -// Hook into require() and notify the parent process about required files -hook(vm, required => send({ required })); - -// Check if a module is registered for this extension -const ext = extname(script).slice(1); -const mod = extensions[ext]; - -// Support extensions where 'require' returns a function that accepts options -if (typeof mod === 'object' && mod.name) { - const fn = require(require.resolve(mod.name, { paths: [dirname(script)] })); - if (typeof fn === 'function' && mod.options) { - // require returned a function, call it with options - fn(mod.options); - } -} else if (typeof mod === 'string') { - require(require.resolve(mod, { paths: [dirname(script)] })); -} diff --git a/package.json b/package.json index 15cdda9..36521b6 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,24 @@ "url": "http://github.com/fgnass/node-dev.git" }, "license": "MIT", + "type": "module", "bin": { - "node-dev": "bin/node-dev" + "node-dev": "bin/node-dev.js" }, "main": "./lib", + "exports": { + ".": "./lib/index.js", + "./package.json": "./package.json" + }, + "files": [ + "icons/", + "lib/" + ], "engines": { "node": ">=14" }, "scripts": { - "lint": "eslint lib test bin/node-dev", + "lint": "eslint lib test bin", "test": "node test", "prepare": "husky install" }, @@ -54,7 +63,7 @@ "typescript": "^4.6.3" }, "lint-staged": { - "*.{js,mjs}": "eslint --cache --fix", - "*.{js,md}": "prettier --write" + "*.{js,cjs,mjs}": "eslint --cache --fix", + "*.{js,cjs,mjs,md}": "prettier --write" } } \ No newline at end of file diff --git a/test/cli.js b/test/cli.js index 64bfa4d..a708151 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const cli = require('../lib/cli.js'); +import cli from '../lib/cli.js'; tap.test('notify is enabled by default', t => { const { diff --git a/test/fixture/package.json b/test/fixture/package.json new file mode 100644 index 0000000..c9a4422 --- /dev/null +++ b/test/fixture/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} \ No newline at end of file diff --git a/test/index.js b/test/index.js index d34e931..f2daba2 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,4 @@ -require('./cli'); -require('./log'); -require('./run'); -require('./spawn'); +import './cli.js'; +import './log.js'; +import './run.js'; +import './spawn/index.js'; diff --git a/test/log.js b/test/log.js index 8fc1c4c..9524f94 100644 --- a/test/log.js +++ b/test/log.js @@ -1,7 +1,7 @@ -const tap = require('tap'); +import tap from 'tap'; -const { defaultConfig } = require('../lib/cfg'); -const logFactory = require('../lib/log'); +import { defaultConfig } from '../lib/cfg.cjs'; +import logFactory from '../lib/log.cjs'; const noColorCfg = { ...defaultConfig, noColor: true }; diff --git a/test/run.js b/test/run.js index b41d5ca..bbe94d1 100644 --- a/test/run.js +++ b/test/run.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('./utils'); +import { spawn, touchFile } from './utils.js'; const run = (cmd, exit) => { return spawn(cmd, out => { diff --git a/test/spawn/argv.js b/test/spawn/argv.js index e534e38..87a5086 100644 --- a/test/spawn/argv.js +++ b/test/spawn/argv.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should not show up in argv', t => { spawn('argv.js foo', out => { diff --git a/test/spawn/caught.js b/test/spawn/caught.js index 5226fde..d55b378 100644 --- a/test/spawn/caught.js +++ b/test/spawn/caught.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should ignore caught errors', t => { spawn('catch-no-such-module.js', out => { diff --git a/test/spawn/clear.js b/test/spawn/clear.js index 718d9fa..4222723 100644 --- a/test/spawn/clear.js +++ b/test/spawn/clear.js @@ -1,8 +1,8 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; -const { control } = require('../../lib/clear'); +import { control } from '../../lib/clear.js'; const reClear = new RegExp(control); diff --git a/test/spawn/cli-require.js b/test/spawn/cli-require.js index ac84963..d2375ad 100644 --- a/test/spawn/cli-require.js +++ b/test/spawn/cli-require.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('Supports require from the command-line (ts-node/register)', t => { spawn('--require=ts-node/register typescript/index.ts', out => { diff --git a/test/spawn/cluster.js b/test/spawn/cluster.js index c616a19..bcc9b31 100644 --- a/test/spawn/cluster.js +++ b/test/spawn/cluster.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('Restart the cluster', t => { if (process.platform === 'win32') { diff --git a/test/spawn/conceal.js b/test/spawn/conceal.js index afd2c89..0686a59 100644 --- a/test/spawn/conceal.js +++ b/test/spawn/conceal.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should conceal the wrapper', t => { // require.main should be main.js not wrap.js! diff --git a/test/spawn/errors.js b/test/spawn/errors.js index 1c4d47f..6861736 100644 --- a/test/spawn/errors.js +++ b/test/spawn/errors.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('should handle errors', t => { spawn('error.js', out => { diff --git a/test/spawn/esmodule.js b/test/spawn/esmodule.js index 1b6b816..ae122e1 100644 --- a/test/spawn/esmodule.js +++ b/test/spawn/esmodule.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('Supports ECMAScript modules with experimental-specifier-resolution', t => { spawn('--experimental-specifier-resolution=node resolution.mjs', out => { diff --git a/test/spawn/exit-code.js b/test/spawn/exit-code.js index b399c0e..c89b607 100644 --- a/test/spawn/exit-code.js +++ b/test/spawn/exit-code.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should pass through the exit code', t => { spawn('exit.js').on('exit', code => { diff --git a/test/spawn/experimental-warnings.js b/test/spawn/experimental-warnings.js index 2b3956a..aeee183 100644 --- a/test/spawn/experimental-warnings.js +++ b/test/spawn/experimental-warnings.js @@ -1,5 +1,5 @@ -const tap = require('tap'); -const { spawn } = require('../utils'); +import tap from 'tap'; +import { spawn } from '../utils.js'; tap.test('Should suppress experimental warning spam', t => { spawn('env.js', out => { diff --git a/test/spawn/expose-gc.js b/test/spawn/expose-gc.js index ccecf8d..317f3a4 100644 --- a/test/spawn/expose-gc.js +++ b/test/spawn/expose-gc.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should pass unknown args to node binary', t => { spawn('--expose_gc gc.js foo', out => { diff --git a/test/spawn/extension-options.js b/test/spawn/extension-options.js index 49cd5d2..36a35b1 100644 --- a/test/spawn/extension-options.js +++ b/test/spawn/extension-options.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should pass options to extensions according to .node-dev.json', t => { spawn('extension-options', out => { diff --git a/test/spawn/graceful-ipc.js b/test/spawn/graceful-ipc.js index d8e9d4d..0aa06de 100644 --- a/test/spawn/graceful-ipc.js +++ b/test/spawn/graceful-ipc.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('should send IPC message when configured', t => { spawn('--graceful_ipc=node-dev:restart ipc-server.js', out => { diff --git a/test/spawn/index.js b/test/spawn/index.js index fce6975..e345686 100644 --- a/test/spawn/index.js +++ b/test/spawn/index.js @@ -1,24 +1,24 @@ -require('./argv'); -require('./caught'); -require('./clear'); -require('./cli-require'); -require('./cluster'); -require('./conceal'); -require('./errors'); -require('./esmodule'); -require('./exit-code'); -require('./experimental-warnings'); -require('./expose-gc'); -require('./extension-options'); -require('./graceful-ipc'); -require('./inspect'); -require('./kill-fork'); -require('./no-such-module'); -require('./node-env'); -require('./relay-stdin'); -require('./require-extensions'); -require('./restart-twice'); -require('./sigterm'); -require('./timestamps'); -require('./typescript'); -require('./uncaught'); +import './argv.js'; +import './caught.js'; +import './clear.js'; +import './cli-require.js'; +import './cluster.js'; +import './conceal.js'; +import './errors.js'; +import './esmodule.js'; +import './exit-code.js'; +import './experimental-warnings.js'; +import './expose-gc.js'; +import './extension-options.js'; +import './graceful-ipc.js'; +import './inspect.js'; +import './kill-fork.js'; +import './no-such-module.js'; +import './node-env.js'; +import './relay-stdin.js'; +import './require-extensions.js'; +import './restart-twice.js'; +import './sigterm.js'; +import './timestamps.js'; +import './typescript.js'; +import './uncaught.js'; diff --git a/test/spawn/inspect.js b/test/spawn/inspect.js index 8a7b587..fc194c4 100644 --- a/test/spawn/inspect.js +++ b/test/spawn/inspect.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('Supports --inspect', t => { spawn('--inspect server.js', out => { diff --git a/test/spawn/kill-fork.js b/test/spawn/kill-fork.js index aa7825a..a836136 100644 --- a/test/spawn/kill-fork.js +++ b/test/spawn/kill-fork.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should kill the forked processes', t => { spawn('pid.js', out => { diff --git a/test/spawn/no-such-module.js b/test/spawn/no-such-module.js index e17bb75..fa29641 100644 --- a/test/spawn/no-such-module.js +++ b/test/spawn/no-such-module.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should watch if no such module', t => { let passed = false; diff --git a/test/spawn/node-env.js b/test/spawn/node-env.js index 33e6f54..13d81b5 100644 --- a/test/spawn/node-env.js +++ b/test/spawn/node-env.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should *not* set NODE_ENV', t => { spawn('env.js', out => { diff --git a/test/spawn/relay-stdin.js b/test/spawn/relay-stdin.js index 4a6aba3..3ddaf80 100644 --- a/test/spawn/relay-stdin.js +++ b/test/spawn/relay-stdin.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should relay stdin', t => { spawn('echo.js', out => { diff --git a/test/spawn/require-extensions.js b/test/spawn/require-extensions.js index d344435..2294de3 100644 --- a/test/spawn/require-extensions.js +++ b/test/spawn/require-extensions.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should be resistant to breaking `require.extensions`', t => { spawn('modify-extensions.js', out => { diff --git a/test/spawn/restart-twice.js b/test/spawn/restart-twice.js index c87d67d..d1f1510 100644 --- a/test/spawn/restart-twice.js +++ b/test/spawn/restart-twice.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('should restart the server twice', t => { spawn('server.js', out => { diff --git a/test/spawn/sigterm.js b/test/spawn/sigterm.js index ed229d8..5025cd5 100644 --- a/test/spawn/sigterm.js +++ b/test/spawn/sigterm.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('should allow graceful shutdowns', t => { if (process.platform === 'win32') { diff --git a/test/spawn/timestamps.js b/test/spawn/timestamps.js index 8105693..f58beb4 100644 --- a/test/spawn/timestamps.js +++ b/test/spawn/timestamps.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('Logs timestamp by default', t => { spawn('server.js', out => { diff --git a/test/spawn/typescript.js b/test/spawn/typescript.js index 49238da..03e8e53 100644 --- a/test/spawn/typescript.js +++ b/test/spawn/typescript.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn, touchFile } = require('../utils'); +import { spawn, touchFile } from '../utils.js'; tap.test('Uses ts-node/register for .ts files through config file (also the default)', t => { spawn('typescript/index.ts', out => { diff --git a/test/spawn/uncaught.js b/test/spawn/uncaught.js index 0930a57..ad7fdc9 100644 --- a/test/spawn/uncaught.js +++ b/test/spawn/uncaught.js @@ -1,6 +1,6 @@ -const tap = require('tap'); +import tap from 'tap'; -const { spawn } = require('../utils'); +import { spawn } from '../utils.js'; tap.test('should run async code uncaughtException handlers', t => { spawn('uncaught-exception-handler.js', out => { diff --git a/test/utils.js b/test/utils.js index b16e843..ae7dc05 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,18 +1,21 @@ -const { spawn } = require('child_process'); -const { join } = require('path'); -const touch = require('touch'); +import { spawn as _spawn } from 'child_process'; +import { join } from 'path'; +import touch from 'touch'; +import { fileURLToPath } from 'url'; -const { control } = require('../lib/clear'); +import { control } from '../lib/clear.js'; -const bin = join(__dirname, '..', 'bin', 'node-dev'); -const dir = join(__dirname, 'fixture'); +const bin = fileURLToPath(new URL('../bin/node-dev.js', import.meta.url)); +const dir = fileURLToPath(new URL('fixture', import.meta.url)); const reClear = new RegExp(control); -const noop = () => {/**/}; +const noop = () => { + /**/ +}; -exports.spawn = (cmd, cb = noop) => { - const ps = spawn('node', [bin].concat(cmd.split(' ')), { cwd: dir }); +export function spawn(cmd, cb = noop) { + const ps = _spawn('node', [bin].concat(cmd.split(' ')), { cwd: dir }); let err = ''; function errorHandler(data) { @@ -50,12 +53,12 @@ exports.spawn = (cmd, cb = noop) => { ps.stdout.on('data', outHandler); return ps; -}; +} // filewatcher requires a new mtime to trigger a change event // but most file systems only have second precision, so wait // one full second before touching. -exports.touchFile = (...filepath) => { +export function touchFile(...filepath) { setTimeout(() => touch(join(dir, ...filepath)), 1000); -}; +}