|
| 1 | +# Implementing native Node.js hot modules (technical write up) |
| 2 | + |
| 3 | +One of the key factors in rapid development is |
| 4 | +discarding as little state as possible. |
| 5 | +In Node.js, this means the new `--watch` flags |
| 6 | +are not that useful, since they throw everything away. |
| 7 | +The ideal is to simply invalidate module when it changes |
| 8 | +or when a module it depends on changes. |
| 9 | +This way, all imports and data are always fresh, |
| 10 | +but only partial module trees get re-evaluated. |
| 11 | + |
| 12 | +Previously, immaculata did the same thing Vite does now: |
| 13 | +use the built-in `node:vm` functionality to create |
| 14 | +an ad hoc module system that sits on top of Node's, |
| 15 | +and glues these systems together using custom logic. |
| 16 | +This effectively creates second class modules. |
| 17 | + |
| 18 | +The main drawbacks are that logic is duplicated and separated. |
| 19 | +Duplication happens in finding and loading files, parsing them, |
| 20 | +evaluating and storing their module objects, and gluing all these |
| 21 | +together with each other and with Node's own module system. |
| 22 | +And these systems are inherently separate, so that |
| 23 | +native Node module hooks will have no effect on the ad hoc system. |
| 24 | + |
| 25 | +By adding module hooks using Node's built-in `node:module` module, |
| 26 | +it's now possible to implement "hot module" functionality natively. |
| 27 | + |
| 28 | +First, we load source files from disk and keep them in memory. |
| 29 | +This won't hit memory limits for most projects and dev machines. |
| 30 | +To handle this need, we have a [FileTree] class, |
| 31 | +which does nothing other than load a file tree into memory, |
| 32 | +and optionally keep it up to date via [.watch()][watch], |
| 33 | +which returns an `EventEmitter` with a `filesUpdated` event. |
| 34 | +Node's native file watcher is now disk efficient, |
| 35 | +and returns all the information we need, |
| 36 | +so we don't need `chokidar` for this. |
| 37 | + |
| 38 | +Next, we have the [useTree] dual-hook which does two key things. |
| 39 | +First, it implements a loader hook that returns the source `string` |
| 40 | +using `tree.files.get` instead of `fs.readFileSync`. |
| 41 | +Second, it implements a resolver hook that appends `?ver=${file.version}` |
| 42 | +to the URL of any given module. |
| 43 | + |
| 44 | +What ties all of this together is the fact that |
| 45 | +the [FileTree] constructor and the [watch] method |
| 46 | +both set each file's `version` to `Date.now()`. |
| 47 | +This becomes an automatic query busting string, |
| 48 | +which works because Node internally uses URLs |
| 49 | +to represent all modules. |
| 50 | + |
| 51 | +In practice, this means that you can import a module file initially, |
| 52 | +and import the same file again after the `filesUpdated` event, |
| 53 | +and either the cached module object will be returned, |
| 54 | +or the file will be re-evaluated if it was updated. |
| 55 | + |
| 56 | +The one missing piece of this puzzle was dependency trees. |
| 57 | +Because module hooks are called during import, |
| 58 | +we can use this information to register dependencies, |
| 59 | +which is done internally within [FileTree]. |
| 60 | +Each time a dependency of a module changes, |
| 61 | +the parent module itself also has its version updated. |
| 62 | +This works recursively, so that modules are always fresh, |
| 63 | +and updated even if a single deep dependency changes. |
| 64 | + |
| 65 | +The code to use these hooks is relatively short and simple: |
| 66 | + |
| 67 | +```ts |
| 68 | +import { FileTree } from "immaculata" |
| 69 | +import { useTree } from "immaculata/hooks.js" |
| 70 | +import { registerHooks } from 'node:module' |
| 71 | + |
| 72 | +const tree = new FileTree('src', import.meta.dirname) |
| 73 | +registerHooks(useTree(tree)) |
| 74 | + |
| 75 | +const myModule = await import('src/myModule.js') |
| 76 | +// src/myModule.js is executed |
| 77 | + |
| 78 | +const myModule2 = await import('src/myModule.js') |
| 79 | +// src/myModule.js is NOT executed a second time |
| 80 | + |
| 81 | +tree.watch().on('filesUpdated', async () => { |
| 82 | + const myModule = await import('src/myModule.js') |
| 83 | + // src/myModule.js IS executed again iff invalidated |
| 84 | +}) |
| 85 | +``` |
| 86 | + |
| 87 | +[FileTree]: ../api/filetree.md#filetree |
| 88 | +[watch]: ../api/filetree.md#watch |
| 89 | +[useTree]: ../api/module-hooks.md#usetree |
0 commit comments