├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE.md ├── README.md ├── examples ├── js │ ├── dep1.js │ ├── dep2.js │ └── main.js └── ts │ ├── package.json │ └── tsconfig.json ├── package.json ├── src ├── FileWatcher.ts ├── HotReloadService.ts ├── disposable.ts ├── hotReloadExportedItem.ts ├── index.ts ├── initializeHotReloadExport.ts ├── logging.ts ├── node.ts ├── nodeApi.ts ├── types.d.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.test 60 | 61 | # parcel-bundler cache (https://parceljs.org/) 62 | .cache 63 | 64 | # next.js build output 65 | .next 66 | 67 | # nuxt.js build output 68 | .nuxt 69 | 70 | # vuepress build output 71 | .vuepress/dist 72 | 73 | # Serverless directories 74 | .serverless/ 75 | 76 | # FuseBox cache 77 | .fusebox/ 78 | 79 | # DynamoDB Local files 80 | .dynamodb/ 81 | 82 | dist/ 83 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Verwendet IntelliSense zum Ermitteln möglicher Attribute. 3 | // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. 4 | // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Run Simple Steps", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/examples/dist/moodle.entry" 12 | }, 13 | { 14 | "name": "Run Demo Extension", 15 | "type": "extensionHost", 16 | "request": "launch", 17 | "runtimeExecutable": "${execPath}", 18 | "args": ["--extensionDevelopmentPath=${workspaceFolder}/examples"], 19 | "outFiles": ["${workspaceFolder}/examples/dist/**/*.js"], 20 | "preLaunchTask": "npm: dev" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rpcServer.nodeDebugger.autoAttachLabels": ["hot-reload"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // Unter https://go.microsoft.com/fwlink/?LinkId=733558 3 | // finden Sie Informationen zum Format von "tasks.json" 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "dev", 9 | "problemMatcher": [] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Henning Dieterichs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hot Reloading for NodeJS 2 | 3 | [![](https://img.shields.io/twitter/follow/hediet_dev.svg?style=social)](https://twitter.com/intent/follow?screen_name=hediet_dev) 4 | 5 | A thoughtfully designed library that brings advanced hot reloading to NodeJS. 6 | 7 | ## Features 8 | 9 | - Tracks a dependency graph (files in `node_modules` and there like can be ignored). 10 | - Tracked files are watched for changes. 11 | - If a file has changed, reconcilers try to apply the change to the running app on a module basis. 12 | - If a reconciler is not successful, the reconcilers of the dependees are asked to apply the change. 13 | 14 | ## Usage 15 | 16 | ### Installation 17 | 18 | ``` 19 | yarn add @hediet/node-reload 20 | ``` 21 | 22 | or 23 | 24 | ``` 25 | npm install @hediet/node-reload --save 26 | ``` 27 | 28 | See the `./examples` folder for detailed examples. 29 | Works best with TypeScript. 30 | 31 | ### Hot Reload Exported Items 32 | 33 | `hotReloadExportedItem` makes it very easy to track changes of exported items. 34 | 35 | ```ts 36 | import { enableHotReload } from "@hediet/node-reload/node"; // This import needs nodejs. 37 | 38 | // Call this before importing modules that should be hot-reloaded! 39 | enableHotReload({ 40 | entryModule: module, // only this module and its transitive dependencies are tracked 41 | logging: 'debug', // useful for debugging if hot-reload does not work 42 | }); 43 | 44 | import { hotReloadExportedItem } from "@hediet/node-reload"; // This import is bundler-friendly and works in any environment! 45 | import { myFunction } from './dep1'; 46 | 47 | const d = hotReloadExportedItem(myFunction, myFunction => { 48 | // Runs initially and on every change of `myFunction` 49 | console.log('myFunction: ' + myFunction()); 50 | return { 51 | dispose: () => { 52 | console.log('cleanup'); 53 | } 54 | } 55 | }); 56 | // ... 57 | d.dispose(); 58 | 59 | ``` 60 | 61 | ## Similar libs 62 | 63 | - [node-hot](https://github.com/mihe/node-hot): Inspired this library. 64 | 65 | ## Changelog 66 | 67 | - 0.0.2 - Initial release. 68 | - 0.4.2 - Implements Live Debug 69 | - 0.10.0 - Rewrite. Focus on `hotReloadExportedItem` and more portable code. 70 | -------------------------------------------------------------------------------- /examples/js/dep1.js: -------------------------------------------------------------------------------- 1 | require('./dep2'); 2 | require('typescript'); 3 | 4 | module.exports.myFunction = function() { 5 | return 42; 6 | }; 7 | -------------------------------------------------------------------------------- /examples/js/dep2.js: -------------------------------------------------------------------------------- 1 | const { handleChange } = require("../../dist/node") 2 | 3 | setTimeout(() => { 4 | handleChange(module); 5 | }, 1000); 6 | -------------------------------------------------------------------------------- /examples/js/main.js: -------------------------------------------------------------------------------- 1 | const { hotReloadExportedItem } = require('../../'); 2 | const { enableHotReload } = require('../../dist/node'); 3 | 4 | enableHotReload({ entryModule: module, logging: 'debug' }); 5 | 6 | const dep1 = require('./dep1'); 7 | 8 | hotReloadExportedItem(dep1.myFunction, f => { 9 | console.log('myFunction: ' + f()); 10 | }); 11 | 12 | setInterval(() => {}, 10000); 13 | -------------------------------------------------------------------------------- /examples/ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hediet/node-reload-example", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "dependencies": { 6 | "@hediet/live-debug": "^0.4.1", 7 | "@hediet/node-reload": "^0.4.0", 8 | "@hediet/std": "^0.4.0", 9 | "@types/node": "^11.13.7", 10 | "@types/puppeteer-core": "^1.9.0", 11 | "@types/rimraf": "^2.0.2", 12 | "puppeteer": "^1.15.0", 13 | "puppeteer-core": "^1.15.0", 14 | "rimraf": "^2.6.3", 15 | "vscode": "^1.1.33", 16 | "ws": "^6.2.1" 17 | }, 18 | "devDependencies": { 19 | "typescript": "^3.4.3" 20 | }, 21 | "activationEvents": [ 22 | "*" 23 | ], 24 | "scripts": { 25 | "dev": "tsc --watch" 26 | }, 27 | "main": "./dist/hot-vscode-extension.js", 28 | "engines": { 29 | "vscode": "^1.30.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "strict": true, 6 | "outDir": "dist", 7 | "skipLibCheck": true, 8 | "rootDir": "./src", 9 | "resolveJsonModule": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "newLine": "LF", 13 | "sourceMap": true, 14 | "experimentalDecorators": true 15 | }, 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hediet/node-reload", 3 | "version": "0.10.0", 4 | "main": "dist/index.js", 5 | "exports": { 6 | ".": "./dist/index.js", 7 | "./node": "./dist/node.js" 8 | }, 9 | "types": "dist/index.d.ts", 10 | "author": { 11 | "name": "Henning Dieterichs", 12 | "email": "henning.dieterichs@live.de" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/hediet/node-reload.git" 17 | }, 18 | "homepage": "https://github.com/hediet/node-reload", 19 | "scripts": { 20 | "dev": "tsc --watch", 21 | "build": "tsc" 22 | }, 23 | "license": "MIT", 24 | "dependencies": { 25 | "@types/node": "^22.12.0" 26 | }, 27 | "devDependencies": { 28 | "typescript": "^5.7.2" 29 | }, 30 | "publishConfig": { 31 | "access": "public", 32 | "registry": "https://registry.npmjs.org/" 33 | }, 34 | "files": [ 35 | "dist", 36 | "src" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/FileWatcher.ts: -------------------------------------------------------------------------------- 1 | import { FSWatcher, watch } from "fs"; 2 | import { IDisposable } from "./disposable"; 3 | import { readFile } from "fs/promises"; 4 | import { AsyncQueue } from "./utils"; 5 | 6 | export class FileWatcher { 7 | constructor( 8 | private readonly _handleChanges: (filenames: string[]) => void, 9 | ) { } 10 | 11 | private readonly _watchers = new Map(); 12 | 13 | private readonly _pendingFileWatchers = new Set(); 14 | private readonly _changedWatchers = new Set(); 15 | 16 | addFile(filename: string): IDisposable { 17 | if (this._watchers.get(filename)) { 18 | throw new Error(`Tracker for ${filename} already set!`); 19 | } 20 | 21 | const q = new AsyncQueue(); 22 | const w = new SingleFileWatcher(filename, async () => { 23 | this._pendingFileWatchers.add(w); 24 | q.clear(); 25 | q.schedule(() => wait(100)); 26 | q.schedule(async () => { 27 | try { 28 | const { didChange } = await w.update(); 29 | if (didChange) { 30 | this._changedWatchers.add(w); 31 | } 32 | } catch (e) { 33 | console.error('unhandled error during update check', e); 34 | } 35 | }); 36 | q.schedule(async () => { 37 | this._pendingFileWatchers.delete(w); 38 | if (this._pendingFileWatchers.size === 0) { 39 | const filenames = Array.from(this._changedWatchers).map(w => w.filename); 40 | this._changedWatchers.clear(); 41 | if (filenames.length > 0) { 42 | this._handleChanges(filenames); 43 | } 44 | } 45 | }); 46 | }); 47 | this._watchers.set(filename, w); 48 | return { 49 | dispose: () => { 50 | this._watchers.delete(filename); 51 | w.dispose(); 52 | } 53 | }; 54 | } 55 | } 56 | 57 | function wait(ms: number): Promise { 58 | return new Promise(resolve => setTimeout(resolve, ms)); 59 | } 60 | 61 | class SingleFileWatcher implements IDisposable { 62 | private _fileContent: string | undefined = undefined; 63 | private _init: Promise; 64 | private _watcher: FSWatcher; 65 | 66 | constructor( 67 | public readonly filename: string, 68 | private readonly _handleChange: () => void, 69 | ) { 70 | this._init = (async () => { 71 | const content = await readFile(filename, 'utf-8'); 72 | this._fileContent = content; 73 | })(); 74 | this._watcher = watch(filename, { persistent: false }); 75 | this._watcher.on('change', this._handler); 76 | } 77 | 78 | dispose(): void { 79 | this._watcher.close(); 80 | } 81 | 82 | private readonly _handler = () => { 83 | this._handleChange(); 84 | }; 85 | 86 | async update(): Promise<{ didChange: boolean }> { 87 | await this._init; 88 | const content = await readFile(this.filename, 'utf-8'); 89 | const didChange = content !== this._fileContent; 90 | this._fileContent = content; 91 | return { didChange }; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/HotReloadService.ts: -------------------------------------------------------------------------------- 1 | import { HotReloadOptions } from "./node"; 2 | import { IDisposable } from "./disposable"; 3 | import { FileWatcher } from "./FileWatcher"; 4 | import { getLogLevel, Logger } from "./logging"; 5 | import { registerModuleInterceptors, resolveFileName, getLoadedModule, deleteModule, NodeJsModule, moduleFromNodeModule } from "./nodeApi"; 6 | import { EventEmitter } from "./utils"; 7 | 8 | export class HotReloadService { 9 | private static _instance: HotReloadService | undefined = undefined; 10 | public static get instance(): HotReloadService | undefined { return this._instance; } 11 | 12 | public static initialize(options: HotReloadOptions): void { 13 | if (this._instance) { 14 | if (!options.skipInitializationIfEnabled) { 15 | console.error('HotReloadService already initialized, ignoring subsequent initialization call. (Set skipInitializationIfEnabled option to true to suppress this warning.)'); 16 | } 17 | } else { 18 | this._instance = new HotReloadService( 19 | new Logger(getLogLevel(options.logging), options.loggingFileRoot), 20 | predicateFromStringArray(options.ignoredModules ?? ['.*[/\\\\]node_modules[/\\\\].*']), 21 | predicateFromStringArray(options.ignoredModules ?? ['vscode']), 22 | ); 23 | this._instance.trackModule(options.entryModule); 24 | } 25 | } 26 | 27 | public readonly interceptor = registerModuleInterceptors({ 28 | interceptLoad: (module, filename) => { 29 | const loadResult = this.interceptor.originalLoad(module, filename); 30 | this._onAfterLoad(filename); 31 | return loadResult; 32 | }, 33 | interceptRequire: (module, filename) => { 34 | const { didLog } = this._onBeforeRequire(module, filename); 35 | if (didLog) { 36 | this._logger.indent(); 37 | } 38 | try { 39 | const result = this.interceptor.originalRequire(module, filename); 40 | return result; 41 | } finally { 42 | if (didLog) { 43 | this._logger.unindent(); 44 | } 45 | } 46 | }, 47 | }); 48 | 49 | private readonly _trackedModules = new Map(); 50 | private readonly _watcher = new FileWatcher(filenames => this._handleFileChanges(filenames)); 51 | private readonly _onTrackedModuleExportsLoaded = new EventEmitter<{ module: TrackedModule }>(); 52 | public readonly onTrackedModuleExportsLoaded = this._onTrackedModuleExportsLoaded.event; 53 | 54 | constructor( 55 | private readonly _logger: Logger, 56 | private readonly _shouldIgnoreModule: (moduleFilename: string) => boolean, 57 | private readonly _shouldIgnoreRequireRequest: (request: string) => boolean, 58 | ) { 59 | this._logger.logHotReloadActive(); 60 | } 61 | 62 | public trackModule(module: NodeModule): void { 63 | this._getOrCreateTrackedModule(module.filename); 64 | setTimeout(() => { 65 | this._onAfterLoad(module.filename); 66 | }, 0); 67 | } 68 | 69 | private _getOrCreateTrackedModule(filename: string): TrackedModule { 70 | const existing = this._trackedModules.get(filename); 71 | if (existing) { 72 | return existing; 73 | } 74 | const trackedModule = new TrackedModule(filename, this, this._logger, () => this._watcher.addFile(filename)); 75 | this._trackedModules.set(filename, trackedModule); 76 | return trackedModule; 77 | } 78 | 79 | private _onBeforeRequire(module: NodeJsModule, request: string): { didLog: boolean } { 80 | if (this._shouldIgnoreRequireRequest(request)) { 81 | const didLog = this._logger.logSkippingRequire(request, module.filename, 'ignored require request'); 82 | return { didLog }; 83 | } 84 | 85 | const requiredByModule = this._trackedModules.get(module.filename); 86 | if (!requiredByModule) { 87 | const didLog = this._logger.logSkippingRequire(request, module.filename, 'caller not tracked'); 88 | return { didLog }; 89 | } 90 | 91 | let requiredModuleFilename: string; 92 | try { 93 | requiredModuleFilename = resolveFileName(request, module); 94 | } catch (e) { 95 | const didLog = this._logger.logResolvingError(request, module.filename, e); 96 | return { didLog }; 97 | } 98 | 99 | if (this._shouldIgnoreModule(requiredModuleFilename)) { 100 | const didLog = this._logger.logSkippingRequire(request, module.filename, 'required module ignored'); 101 | return { didLog }; 102 | } 103 | 104 | const didLog = this._logger.logTrackingRequire(request, module.filename, requiredModuleFilename); 105 | const requiredModule = this._getOrCreateTrackedModule(requiredModuleFilename); 106 | requiredModule.consumers.add(requiredByModule); 107 | return { didLog }; 108 | } 109 | 110 | private _onAfterLoad(filename: string): void { 111 | const loadedModule = this._trackedModules.get(filename); 112 | if (loadedModule) { 113 | loadedModule.watch(); 114 | loadedModule.exports = getLoadedModule(filename)?.exports; 115 | this._onTrackedModuleExportsLoaded.emit({ module: loadedModule }); 116 | } 117 | } 118 | 119 | private _handleFileChanges(filenames: string[]): void { 120 | const didLog = this._logger.logFilesChanged(filenames); 121 | if (didLog) { 122 | this._logger.indent(); 123 | } 124 | try { 125 | const modules: TrackedModule[] = []; 126 | for (const filename of filenames) { 127 | const module = this._trackedModules.get(filename); 128 | if (module) { 129 | modules.push(module); 130 | } 131 | } 132 | 133 | for (const module of modules) { 134 | module.beginUpdate([]); 135 | module.markChanged(); 136 | } 137 | for (const module of modules) { 138 | module.endUpdate(); 139 | } 140 | } finally { 141 | if (didLog) { 142 | this._logger.unindent(); 143 | } 144 | } 145 | } 146 | 147 | public handleChange(moduleOrFilename: NodeModule | string): void { 148 | const moduleFilename = typeof moduleOrFilename === 'string' 149 | ? moduleOrFilename 150 | : moduleOrFilename.filename; 151 | this._handleFileChanges([moduleFilename]); 152 | } 153 | } 154 | 155 | function predicateFromStringArray(arr: string[]): (str: string) => boolean { 156 | const regexes = arr.map(s => new RegExp(s)); 157 | return str => { 158 | return regexes.some(r => r.test(str)); 159 | }; 160 | } 161 | 162 | export class TrackedModule { 163 | public readonly consumers = new Set(); 164 | 165 | public exports: Record = {}; 166 | 167 | private readonly _updateStrategies = new Set(); 168 | public readonly updateStrategies: ReadonlySet = this._updateStrategies; 169 | 170 | public registerUpdateStrategy(strategy: IUpdateStrategy): IDisposable { 171 | this._updateStrategies.add(strategy); 172 | return { 173 | dispose: () => this._updateStrategies.delete(strategy), 174 | }; 175 | } 176 | 177 | private _updateCounter = 0; 178 | private _updatedCosumers: TrackedModule[] = []; 179 | private _active = false; 180 | public get active(): boolean { return this._active; } 181 | private _moduleChanged = false; 182 | private readonly _changedDependencies: Set = new Set(); 183 | private _watcher: IDisposable | undefined = undefined; 184 | 185 | constructor( 186 | public readonly filename: string, 187 | private readonly _hotReloadService: HotReloadService, 188 | private readonly _logger: Logger, 189 | private readonly _watch: () => IDisposable, 190 | ) { } 191 | 192 | public watch(): void { 193 | if (this._watcher) { 194 | return; 195 | } 196 | this._watcher = this._watch(); 197 | } 198 | 199 | public beginUpdate(stack: TrackedModule[]): void { 200 | if (this._active) { 201 | throw new Error('Cannot begin update while update is in progress'); 202 | } 203 | stack.push(this); 204 | this._active = true; 205 | this._updateCounter++; 206 | 207 | if (this._updateCounter === 1) { 208 | this._updatedCosumers = []; 209 | for (const c of this.consumers) { 210 | if (c.active) { 211 | // recursion, ignore 212 | stack.push(c); 213 | this._logger.logRecursiveUpdate(stack.map(s => s.filename)); 214 | stack.pop(); 215 | } else { 216 | this._updatedCosumers.push(c); 217 | c.beginUpdate(stack); 218 | } 219 | } 220 | } 221 | 222 | stack.pop(); 223 | this._active = false; 224 | } 225 | 226 | public endUpdate(): void { 227 | if (this._active) { 228 | throw new Error('Cannot begin update while update is in progress'); 229 | } 230 | try { 231 | let didLog = false; 232 | let didLogUpdatingModule = false; 233 | this._active = true; 234 | this._updateCounter--; 235 | if (this._updateCounter === 0) { 236 | if (this._moduleChanged || this._changedDependencies.size > 0) { 237 | const changeInfo = new ModuleChangeInfo(this, this._moduleChanged, new Set(this._changedDependencies)); 238 | this._moduleChanged = false; 239 | this._changedDependencies.clear(); 240 | 241 | didLogUpdatingModule = this._logger.logUpdatingModule(this.filename); 242 | if (didLogUpdatingModule) { 243 | this._logger.indent(); 244 | } 245 | 246 | let couldApplyUpdate = false; 247 | for (const u of this.updateStrategies) { 248 | const r = u.applyUpdate(changeInfo); 249 | couldApplyUpdate = couldApplyUpdate || r; 250 | } 251 | if (couldApplyUpdate) { 252 | didLog = this._logger.logModuleUpdated(this.filename); 253 | } else { 254 | this.clearCache(); 255 | if (this._updatedCosumers.length === 0) { 256 | didLog = this._logger.logEntryModuleUpdateFailed(this.filename); 257 | } else { 258 | didLog = this._logger.logUpdateFailed(this.filename, this._updatedCosumers.length); 259 | for (const consumer of this._updatedCosumers) { 260 | consumer.markDependencyChanged(changeInfo); 261 | } 262 | } 263 | } 264 | } 265 | 266 | if (didLog) { 267 | this._logger.indent(); 268 | } 269 | for (const consumer of this._updatedCosumers) { 270 | consumer.endUpdate(); 271 | } 272 | if (didLog) { 273 | this._logger.unindent(); 274 | } 275 | if (didLogUpdatingModule) { 276 | this._logger.unindent(); 277 | } 278 | this._updatedCosumers = []; 279 | } else { 280 | this._logger.logPostponeEndUpdate(this.filename, this._updateCounter); 281 | } 282 | } finally { 283 | this._active = false; 284 | } 285 | } 286 | 287 | public markChanged(): void { 288 | this._checkUpdateInProgress(); 289 | this._moduleChanged = true; 290 | } 291 | 292 | public markDependencyChanged(changeInfo: ModuleChangeInfo): void { 293 | this._checkUpdateInProgress(); 294 | this._changedDependencies.add(changeInfo); 295 | } 296 | 297 | private _checkUpdateInProgress(): void { 298 | if (this._updateCounter === 0) { 299 | debugger; 300 | throw new Error('Cannot mark module as changed outside of an update'); 301 | } 302 | } 303 | 304 | public clearCache(): void { 305 | this._logger.logClearingModule(this.filename); 306 | deleteModule(this.filename); 307 | } 308 | 309 | public reload(): void { 310 | this.clearCache(); 311 | this._hotReloadService.interceptor.originalRequire(moduleFromNodeModule(module), this.filename); 312 | } 313 | 314 | public toString() { 315 | return `TrackedModule(${this.filename})`; 316 | } 317 | } 318 | 319 | export interface IUpdateStrategy { 320 | applyUpdate(changeInfo: ModuleChangeInfo): boolean; 321 | } 322 | 323 | export class ModuleChangeInfo { 324 | constructor( 325 | public readonly module: TrackedModule, 326 | /** 327 | * Is set, if the current module changed. 328 | */ 329 | public readonly moduleChanged: boolean, 330 | /** 331 | * Describes the changes of the dependent modules. 332 | */ 333 | public readonly dependencyChangeInfos: ReadonlySet, 334 | ) { } 335 | 336 | toString(): string { 337 | return JSON.stringify(this._toJson(), null, '\t'); 338 | } 339 | 340 | private _toJson(): any { 341 | return { 342 | moduleChanged: this.moduleChanged, 343 | dependencyChangeInfos: Object.fromEntries(this.dependencyChangeInfos.entries()), 344 | }; 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/disposable.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IDisposable { 3 | dispose(): void; 4 | } 5 | 6 | export class DisposableStore implements IDisposable { 7 | private readonly _toDispose = new Set(); 8 | 9 | add(disposable: T): T { 10 | this._toDispose.add(disposable); 11 | return disposable; 12 | } 13 | 14 | dispose(): void { 15 | for (const disposable of this._toDispose) { 16 | disposable.dispose(); 17 | } 18 | this._toDispose.clear(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/hotReloadExportedItem.ts: -------------------------------------------------------------------------------- 1 | import type { IDisposable } from "./disposable"; 2 | import type { TrackedModule } from "./HotReloadService"; 3 | 4 | export const moduleSource = new WeakMap(); 5 | 6 | export function hotReloadExportedItem(exportedItem: T, handleExportedItem: (exportedItem: T) => IDisposable | undefined): IDisposable { 7 | const source = moduleSource.get(exportedItem as object | Function); 8 | if (!source) { 9 | const v = handleExportedItem(exportedItem); 10 | return { dispose: () => { v?.dispose(); } }; 11 | } 12 | 13 | let curDisposable = handleExportedItem(exportedItem); 14 | 15 | const updateStrategy = source.module.registerUpdateStrategy({ 16 | applyUpdate: _changeInfo => { 17 | source.module.reload(); 18 | const newValue = source.module.exports[source.exportName] as any; 19 | curDisposable?.dispose(); 20 | curDisposable = handleExportedItem(newValue); 21 | return true; 22 | } 23 | }); 24 | 25 | return { 26 | dispose: () => { 27 | curDisposable?.dispose(); 28 | updateStrategy.dispose(); 29 | } 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { hotReloadExportedItem } from "./hotReloadExportedItem"; 2 | 3 | export { hotReloadExportedItem }; 4 | -------------------------------------------------------------------------------- /src/initializeHotReloadExport.ts: -------------------------------------------------------------------------------- 1 | import { moduleSource } from "./hotReloadExportedItem"; 2 | import { HotReloadService } from "./HotReloadService"; 3 | 4 | export function initializeHotReloadExport(service: HotReloadService): void { 5 | service.onTrackedModuleExportsLoaded(data => { 6 | if (typeof data.module.exports !== 'object') { 7 | return; 8 | } 9 | for (const [key, val] of Object.entries(data.module.exports)) { 10 | if ((typeof val === 'function' || typeof val === 'object') && val) { 11 | moduleSource.set(val, { module: data.module, exportName: key }); 12 | } 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import { HotReloadOptions } from "./node"; 2 | import * as path from "path"; 3 | 4 | export enum LogLevel { 5 | Off = 0, 6 | Warn = 1, 7 | Info = 2, 8 | Trace = 3, 9 | Debug = 4, 10 | } 11 | 12 | export function getLogLevel(options: HotReloadOptions['logging']): LogLevel { 13 | if (options === false) { return LogLevel.Off; } 14 | if (options === 'info' || options === true) { return LogLevel.Info; } 15 | if (options === 'trace') { return LogLevel.Trace; } 16 | if (options === 'debug') { return LogLevel.Debug; } 17 | return LogLevel.Info; 18 | } 19 | 20 | export class Logger { 21 | private _indentation: number = 0; 22 | 23 | private static readonly TREE_BRANCH = '├── '; 24 | private static readonly TREE_VERTICAL = '│ '; 25 | 26 | constructor( 27 | private readonly _logLevel: LogLevel, 28 | private readonly _root: string = process.cwd(), 29 | ) { } 30 | 31 | private _formatPath(filepath: string): string { 32 | const result = path.relative(this._root, filepath); 33 | if (!path.isAbsolute(result)) { 34 | // so that ctrl+click works in vscode 35 | return `.${path.sep}${result}`; 36 | } 37 | return result; 38 | } 39 | 40 | public logHotReloadActive(): boolean { 41 | return this._log(LogLevel.Info, `Hot reload active`); 42 | } 43 | 44 | public logTrackingRequire(request: string, moduleFilename: string, resolvedFilename: string): boolean { 45 | return this._log(LogLevel.Trace, `Tracking require from "${this._formatPath(moduleFilename)}" of "${request}" (resolved to "${this._formatPath(resolvedFilename)}")`); 46 | } 47 | 48 | public logSkippingRequire(request: string, filename: string, reason: string): boolean { 49 | return this._log(LogLevel.Debug, `Skipping require of "${request}" from "${this._formatPath(filename)}" (${reason})`); 50 | } 51 | 52 | public logResolvingError(request: string, filename: string, error: unknown): boolean { 53 | const detail = this._logLevel >= LogLevel.Debug ? ` (error: ${error})` : ''; 54 | return this._log(LogLevel.Warn, `Error while resolving module "${request}" from "${this._formatPath(filename)}"${detail}`); 55 | } 56 | 57 | public logFilesChanged(filenames: string[]): boolean { 58 | return this._log(LogLevel.Info, `File(s) changed: ${filenames.map(f => this._formatPath(f)).join(', ')}`); 59 | } 60 | 61 | public logUpdatingModule(filename: string): boolean { 62 | return this._log(LogLevel.Trace, `Updating "${this._formatPath(filename)}"...`); 63 | } 64 | 65 | public logClearingModule(filename: string): boolean { 66 | return this._log(LogLevel.Trace, `Clearing "${this._formatPath(filename)}"`); 67 | } 68 | 69 | public logModuleUpdated(filename: string): boolean { 70 | return this._log(LogLevel.Info, `Successfully updated "${this._formatPath(filename)}"`); 71 | } 72 | 73 | public logUpdateFailed(filename: string, consumerCount: number): boolean { 74 | return this._log(LogLevel.Trace, `Could not update "${this._formatPath(filename)}", updating ${consumerCount} consumer(s)...`); 75 | } 76 | 77 | public logEntryModuleUpdateFailed(filename: string): boolean { 78 | return this._log(LogLevel.Warn, `Could not update entry module "${this._formatPath(filename)}"!`); 79 | } 80 | 81 | public logRecursiveUpdate(stackFilenames: string[]): boolean { 82 | const level = LogLevel.Info; 83 | if (!this._isLoggingEnabled(level)) { 84 | return false; 85 | } 86 | this._log(level, `Skipping recursive dependency update:`); 87 | this.indent(); 88 | let first = true; 89 | for (const stackFilename of stackFilenames.reverse()) { 90 | this._log(level, ` ${first ? '' : `...requires `}"${this._formatPath(stackFilename)}"`); 91 | first = false; 92 | } 93 | this.unindent(); 94 | 95 | return true; 96 | } 97 | 98 | public logPostponeEndUpdate(filename: string, updateCounter: number): boolean { 99 | return this._log(LogLevel.Trace, `Postponing endUpdate of "${this._formatPath(filename)}" (${updateCounter})`); 100 | } 101 | 102 | private _isLoggingEnabled(level: LogLevel): boolean { 103 | return this._logLevel >= level; 104 | } 105 | 106 | private _getIndentation(): string { 107 | let result = ''; 108 | for (let i = 0; i < this._indentation; i++) { 109 | result += i === this._indentation - 1 ? Logger.TREE_BRANCH : Logger.TREE_VERTICAL; 110 | } 111 | return result; 112 | } 113 | 114 | private _log(level: LogLevel, message: string): boolean { 115 | if (!this._isLoggingEnabled(level)) { 116 | return false; 117 | } 118 | const indentation = this._getIndentation(); 119 | console.log(`[node-reload] ${indentation}${message}`); 120 | return true; 121 | } 122 | 123 | public indent(): void { 124 | this._indentation++; 125 | } 126 | 127 | public unindent(): void { 128 | this._indentation--; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import Module = require("module"); 2 | import { HotReloadService } from "./HotReloadService"; 3 | import { initializeHotReloadExport } from "./initializeHotReloadExport"; 4 | 5 | export interface HotReloadOptions { 6 | /** 7 | * Use "module" 8 | */ 9 | entryModule: Module, 10 | logging?: boolean | 'info' | 'trace' | 'debug', 11 | loggingFileRoot?: string; 12 | /** A list of regular expressions. */ 13 | ignoredModules?: string[]; 14 | skipInitializationIfEnabled?: boolean; 15 | } 16 | 17 | export function hotReloadEnabled(): boolean { 18 | return HotReloadService.instance !== undefined; 19 | } 20 | 21 | export function enableHotReload(options: HotReloadOptions): void { 22 | HotReloadService.initialize(options); 23 | initializeHotReloadExport(HotReloadService.instance!); 24 | } 25 | 26 | /** 27 | * Mark a module as changed, so that it will be reloaded (even if its source did not actually change). 28 | */ 29 | export function handleChange(moduleOrFilename: Module | string): void { 30 | HotReloadService.instance?.handleChange(moduleOrFilename); 31 | } 32 | -------------------------------------------------------------------------------- /src/nodeApi.ts: -------------------------------------------------------------------------------- 1 | import Module = require("module"); 2 | import { IDisposable } from "./disposable"; 3 | 4 | const nodeJsModuleBrand = Symbol("NodeJsModule"); 5 | 6 | export interface NodeJsModule { 7 | [nodeJsModuleBrand]: true; 8 | filename: string; 9 | exports: any; 10 | } 11 | 12 | export function getLoadedModule(filename: string): NodeJsModule | undefined { 13 | return require.cache[filename] as any as NodeJsModule | undefined; 14 | } 15 | 16 | export function deleteModule(filename: string): boolean { 17 | if (!require.cache[filename]) { 18 | return false; 19 | } 20 | delete require.cache[filename]; 21 | return true; 22 | } 23 | 24 | export function resolveFileName(request: string, caller: NodeJsModule) { 25 | return (Module as any)._resolveFilename(request, caller); 26 | } 27 | 28 | export function moduleFromNodeModule(nodeModule: NodeModule): NodeJsModule { 29 | return nodeModule as any as NodeJsModule; 30 | } 31 | 32 | export function registerModuleInterceptors( 33 | options: { 34 | interceptRequire: (module: NodeJsModule, request: string) => unknown; 35 | interceptLoad: (module: NodeJsModule, filename: string) => unknown; 36 | } 37 | ): { 38 | originalRequire: (module: NodeJsModule, request: string) => unknown; 39 | originalLoad: (module: NodeJsModule, filename: string) => unknown; 40 | } & IDisposable { 41 | const originalModule = { 42 | load: Module.prototype.load, 43 | require: Module.prototype.require, 44 | }; 45 | 46 | let disposed = false; 47 | 48 | const newRequire = Module.prototype.require = function (this: NodeModule, request: string): unknown { 49 | if (disposed) { 50 | return originalModule.require.call(this, request); 51 | } 52 | return options.interceptRequire(this as any as NodeJsModule, request); 53 | } as any; 54 | 55 | const newLoad = Module.prototype.load = function (this: NodeModule, filename: string): unknown { 56 | if (disposed) { 57 | return originalModule.load.call(this, filename); 58 | } 59 | return options.interceptLoad(this as any as NodeJsModule, filename); 60 | }; 61 | 62 | return { 63 | originalRequire: (module, request) => originalModule.require.call(module, request), 64 | originalLoad: (module, filename) => originalModule.load.call(module as any as NodeModule, filename), 65 | dispose: () => { 66 | disposed = true; 67 | if (Module.prototype.require === newRequire) { 68 | Module.prototype.require = originalModule.require; 69 | } 70 | if (Module.prototype.load === newLoad) { 71 | Module.prototype.load = originalModule.load; 72 | } 73 | } 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Module { 3 | load(this: NodeModule, filename: string): unknown; 4 | } 5 | 6 | namespace Module { 7 | function _resolveFilename(request: string, caller: NodeModule): string; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { IDisposable } from "./disposable"; 2 | 3 | export class Debouncer implements IDisposable { 4 | private _timeout: NodeJS.Timeout | undefined; 5 | 6 | run(delayMs: number, cb: () => void): void { 7 | if (this._timeout) { 8 | clearTimeout(this._timeout); 9 | } 10 | this._timeout = setTimeout(cb, delayMs); 11 | } 12 | 13 | dispose(): void { 14 | if (this._timeout) { 15 | clearTimeout(this._timeout); 16 | } 17 | } 18 | } 19 | 20 | type Task = () => Promise; 21 | 22 | export class AsyncQueue { 23 | private readonly _queue: Task[] = []; 24 | private _running: boolean = false; 25 | 26 | private async _runNext(): Promise { 27 | if (this._running || this._queue.length === 0) { 28 | return; 29 | } 30 | this._running = true; 31 | const task = this._queue.shift()!; 32 | try { 33 | await task(); 34 | } finally { 35 | this._running = false; 36 | this._runNext(); 37 | } 38 | } 39 | 40 | schedule(task: Task): Promise { 41 | return new Promise((resolve, reject) => { 42 | this._queue.push(async () => { 43 | try { 44 | resolve(await task()); 45 | } catch (error) { 46 | reject(error); 47 | } 48 | }); 49 | this._runNext(); 50 | }); 51 | } 52 | 53 | clear(): void { 54 | this._queue.length = 0; 55 | } 56 | } 57 | 58 | export type Event = (listener: (args: T) => void) => IDisposable; 59 | 60 | export class EventEmitter { 61 | private readonly _listeners = new Set<(args: T) => void>(); 62 | 63 | emit(args: T): void { 64 | for (const listener of this._listeners) { 65 | listener(args); 66 | } 67 | } 68 | 69 | event: Event = (listener) => { 70 | this._listeners.add(listener); 71 | return { 72 | dispose: () => { 73 | this._listeners.delete(listener); 74 | } 75 | }; 76 | }; 77 | } 78 | 79 | export class Node { 80 | constructor( 81 | public readonly value: T, 82 | public readonly outNodes: Node[] = [], 83 | public readonly inNodes: Node[] = [], 84 | ) { } 85 | } 86 | 87 | export class Graph { 88 | public static build(roots: T[], getOut: (value: T) => T[]): Graph { 89 | const nodes = new Map>(); 90 | const getNode = (value: T): Node => { 91 | let node = nodes.get(value); 92 | if (!node) { 93 | node = new Node(value); 94 | nodes.set(value, node); 95 | } 96 | return node; 97 | }; 98 | const build = (value: T): Node => { 99 | const node = getNode(value); 100 | for (const out of getOut(value)) { 101 | const outNode = build(out); 102 | node.outNodes.push(outNode); 103 | outNode.inNodes.push(node); 104 | } 105 | return node; 106 | }; 107 | const rootNodes = roots.map(build); 108 | return new Graph(rootNodes); 109 | } 110 | 111 | constructor( 112 | public readonly roots: Node[], 113 | ) { } 114 | } 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "strict": true, 6 | "outDir": "dist", 7 | "skipLibCheck": true, 8 | "rootDir": "./src", 9 | "resolveJsonModule": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "newLine": "LF", 13 | "sourceMap": true, 14 | "useDefineForClassFields": false, 15 | }, 16 | "include": ["./src/**/*", "./src/types.d.ts", "./node_modules/@types/node/fs.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/node@^22.12.0": 6 | version "22.12.0" 7 | resolved "https://registry.yarnpkg.com/@types/node/-/node-22.12.0.tgz#bf8af3b2af0837b5a62a368756ff2b705ae0048c" 8 | integrity sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA== 9 | dependencies: 10 | undici-types "~6.20.0" 11 | 12 | typescript@^5.7.2: 13 | version "5.7.2" 14 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" 15 | integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== 16 | 17 | undici-types@~6.20.0: 18 | version "6.20.0" 19 | resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" 20 | integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== 21 | --------------------------------------------------------------------------------