Skip to content

Instantly share code, notes, and snippets.

@marxangels
Last active September 10, 2023 03:59
Show Gist options
  • Save marxangels/68c1e50fe144c4ec4c911a6534aa7e32 to your computer and use it in GitHub Desktop.
Save marxangels/68c1e50fe144c4ec4c911a6534aa7e32 to your computer and use it in GitHub Desktop.
A simple module-level hot-reload for my express web application with less than 200 lines of code.
import fs from 'fs'
import vm from 'vm'
import path from 'path'
/*
* cli command
* node --experimental-vm-modules express-hot-reload.js
*/
const ModuleCache = new Map()
const context = vm.createContext(global)
const projRoot = path.join(import.meta.url.substr(7), '../..')
globalThis.useHotReload = registerWebHook
await SrcModule(projRoot + '/api/index.js')
function registerWebHook(app) {
app.use('/dev-restart-process', (req, res) => {
process.kill(1, 'SIGPIPE')
res.send('restarting')
})
app.use('/dev-reload-module', async(req, res) => {
let { moduleFullPath } = req.query
if (undefined === ModuleCache.get(moduleFullPath)) {
return res.send({ moduleFullPath, message: 'nothing matched' })
}
if (ModuleCache.get(moduleFullPath).namespace.default instanceof Function) {
await SrcModule(moduleFullPath, true)
res.send({ moduleFullPath, message: 'reloaded' })
} else {
process.kill(1, 'SIGPIPE')
res.send({ moduleFullPath, message: 'RESTART SIGNAL sent to process 1' })
}
})
}
async function ProxyModule(filepath) {
let proxy = ModuleCache.get('proxy:' + filepath)
if (proxy) return proxy
let module = await SrcModule(filepath)
let names = Object.keys(module.namespace)
proxy = new vm.SyntheticModule(names, function() {
for (let name of names) {
if (module.namespace[name] instanceof Function) {
this.setExport(name, new Proxy(
function() {
return ModuleCache.get(filepath).namespace[name]
},
{
has(target, prop) { return prop in target() },
get(target, prop) { return target()[prop] },
set(target, prop, value) { target()[prop] = value; return true },
construct(target, args) { return new (target())(...args) },
apply(target, the, args) { return target().apply(the, args) }
}))
} else {
this.setExport(name, module.namespace[name])
}
}
}, { context, identifier: 'proxy:' + filepath })
await proxy.link(linker)
await proxy.evaluate()
ModuleCache.set(proxy.identifier, proxy)
return proxy
}
async function SrcModule(filepath, reload) {
let module = ModuleCache.get(filepath)
if (module && !reload) return module
let code = (await fs.promises.readFile(filepath)).toString('utf8')
module = new vm.SourceTextModule(code, {
context,
identifier: filepath,
initializeImportMeta(meta, module) {
meta.url = 'file://' + module.identifier
},
importModuleDynamically(spec, ref) {
return linker(spec, ref)
}
}
)
await module.link(linker)
await module.evaluate()
ModuleCache.set(filepath, module)
return module
}
async function LibModule(spec) {
let lib = ModuleCache.get(spec)
if (lib) return lib
let module = await import(spec)
let names = Object.keys(module)
lib = new vm.SyntheticModule(names, function() {
for (let name of names) this.setExport(name, module[name])
}, { context, identifier: spec })
await lib.link(linker)
await lib.evaluate()
ModuleCache.set(spec, lib)
return lib
}
function fileStat(file, suf = '') {
return fs.promises.lstat(file + suf).catch(() => null)
}
async function linker(spec, ref) {
// handle your path alias
if (spec.startsWith('@/')) {
spec = spec.replace(/^@\//, projRoot + '/')
} else if (spec.startsWith('.')) {
spec = path.join(ref.identifier, '..', spec)
} else if (spec.startsWith('/')) {
// !spec.includes('/node_modules/')
} else {
return LibModule(spec)
}
let filepath = path.resolve(spec)
if (/\/[-\w]+$/.test(filepath)) {
let stat = await fileStat(filepath)
if (stat && stat.isDirectory()) {
filepath += '/index'
}
if (await fileStat(filepath, '.js')) {
filepath += '.js'
} else if (await fileStat(filepath, '.mjs')) {
filepath += '.mjs'
}
}
if (/.\.m?js$/.test(filepath)) {
let module = await SrcModule(filepath)
if (module.namespace.default instanceof Function) {
return ProxyModule(filepath)
} else {
return module
}
} else {
// extend loader
return LibModule(spec)
}
}
@NyanHelsing
Copy link

NyanHelsing commented Sep 10, 2023

I think this has an issue with the rebuilding of modules,

ie if you have modules A and B; and A imports B,

lets import A, then import B, (a is already build so it uses that cached build)

Now rebuild A, but note B is not gonna get rebuilt, so then B is using a stale version of A.

I'm wondering if I've solved that with something like:

(resolutions is ModuleCache in the above code, and references is a Map that relates a specifier to a Set of specifiers whose modules import that specifier)

const rebuild = ({ context, linker, resolutions, references }) => (specifier, mod) => {
    // Remove all references to this module since the new module may have different dependencies
    mod.dependencySpecifiers.forEach((dependencySpecifier) => {
        references.get(dependencySpecifier).delete(specifier);
    });
    // Rebuild this module
    buildModule({
        specifier,
        context,
        linker,
        resolutions,
        references
    });
    // Any modules that depend on this module need to be rebuilt
    [...references.get(specifier)].forEach((reference) => {
        rebuild({
            specifier: reference, 
            context,
            linker,
            resolutions,
            references
        })(reference, resolutions.get(reference));
    });
    return resolutions.get(reference);
};

@NyanHelsing
Copy link

Also... based profile pic 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment