watch-dependency-graph
Version:
A Node file watcher, but instead of scanning the filesystem for files to be watched, it monitors only specified entry files and their dependency trees.
395 lines (327 loc) • 10.6 kB
JavaScript
const fs = require('fs')
const path = require('path')
const { createRequire } = require('module')
const debug = require('debug')('wdg')
const filewatcher = require('filewatcher')
const { parse } = require('es-module-lexer')
const stripComments = require('strip-comments')
const ESM_IMPORT_REGEX = /(?<![^;\n])[ ]*import(?:["'\s]*([\w*${}\n\r\t, ]+)\s*from\s*)?\s*["'](.*?)["']/gm
const ESM_DYNAMIC_IMPORT_REGEX = /(?<!\.)\bimport\((?:['"].+['"]|`[^$]+`)\)/gm
function emitter () {
let events = {}
return {
emit (ev, ...args) {
return events[ev] ? events[ev].map(fn => fn(...args)) : []
},
on (ev, fn) {
events[ev] = events[ev] ? events[ev].concat(fn) : [fn]
return () => events[ev].slice(events[ev].indexOf(fn), 1)
},
clear () {
events = {}
},
listeners (ev) {
return events[ev] || []
}
}
}
/*
* Simple alias resolver i.e.
*
* {
* '@': process.cwd()
* }
*/
function resolveAliases (id, alias) {
for (const a of Object.keys(alias)) {
if (id.indexOf(a) === 0) {
return path.join(alias[a], id.replace(a, ''))
}
}
return id
}
/*
* Walks up the tree, clearing require cache as it goes
*/
function clearUp (ids, tree, parentPointers) {
for (const p of parentPointers) {
const id = ids[p]
delete require.cache[id]
clearUp(ids, tree, tree[id].parentPointers)
}
}
/**
* Lifted from snowpack, props to their team
*
* @see https://github.com/snowpackjs/snowpack/blob/f75de1375fe14155674112d88bf211ca3721ac7c/snowpack/src/scan-imports.ts#L119
*/
function cleanCodeForParsing (code) {
code = stripComments(code)
const allMatches = []
let match
const importRegex = new RegExp(ESM_IMPORT_REGEX)
while ((match = importRegex.exec(code))) {
allMatches.push(match)
}
const dynamicImportRegex = new RegExp(ESM_DYNAMIC_IMPORT_REGEX)
while ((match = dynamicImportRegex.exec(code))) {
allMatches.push(match)
}
return allMatches.map(([full]) => full).join('\n')
}
/*
* Read file, parse, traverse, resolve children modules IDs
*/
async function getChildrenModuleIds ({ id, alias }) {
const raw = fs.readFileSync(id, 'utf-8')
let children = []
try {
children = (await parse(raw))[0].map(i => i.n)
} catch (e) {
children = (await parse(cleanCodeForParsing(raw)))[0].map(i => i.n)
}
return children
.map(moduleId => {
const req = createRequire(id)
let resolved
try {
resolved = req.resolve(moduleId)
} catch (e1) {
try {
resolved = req.resolve(resolveAliases(moduleId, alias))
} catch (e2) {
resolved = require.resolve(moduleId)
}
}
// same same, must be built-in module
return resolved === moduleId ? undefined : resolved
})
.filter(Boolean)
}
module.exports = function graph ({ alias = {} } = {}) {
debug('initialized with', { alias })
// once instance
const events = emitter()
let ids = [] // list of all file IDs
let tree = {} // graph of modules
const watcher = filewatcher() // filewatcher instance
let entryIds = [] // top level entry files
/**
* Clean tree by module ID, removing leafs and unwatching if de-referenced
*/
function cleanById (id) {
// target leaf
const { pointer, parentPointers, childrenPointers } = tree[id]
// de-reference this module from its parents
for (const p of parentPointers) {
const children = tree[ids[p]].childrenPointers
children.splice(children.indexOf(pointer), 1)
}
for (const p of childrenPointers) {
// de-reference this module from its children
const parents = tree[ids[p]].parentPointers
parents.splice(parents.indexOf(pointer), 1)
// de-reference this module from its entries
const entries = tree[ids[p]].entryPointers
if (entries.includes(pointer)) {
entries.splice(entries.indexOf(pointer), 1)
}
// if no longer referenced, clean again
if (entries.length === 0 || parents.length === 0) {
cleanById(ids[p])
}
}
// delete target leaf and unwatch, DO THIS LAST
delete tree[id]
watcher.remove(id)
}
/*
* Walk a file, creating trees and leafs as needed. If the file has
* deps, find those and walk them too.
*
* Also used to walk a leaf directly, in which case bootstrapping will be false
*/
async function walk (id, context) {
let { entryPointer, parentPointer, visitedIds, bootstrapping } = context
// on first call of walk with a fresh entry
const isEntry = entryPointer === undefined
// non-js files can be indexed by not walked
const isTraversable = /^\.(j|t)sx?$/.test(path.extname(id))
// ignore any IDs that we've already walked from a given entrypoint
if (!visitedIds.includes(id)) {
// note that we've visited this ID while walking the current entry
visitedIds.push(id)
// watch any file we pass to watch, even if we can't traverse it
watcher.add(id)
// IDs should be unique
if (!ids.includes(id)) ids.push(id)
// current file
const pointer = ids.indexOf(id)
// if this is an entry, it should be self referential
entryPointer = isEntry ? pointer : entryPointer
// if this is an entry, set up the parentPointer for the next walk
parentPointer = isEntry ? pointer : parentPointer
// create tree leaf if not exists
if (!tree[id]) {
tree[id] = {
pointer,
entryPointers: [entryPointer],
// entry has no parent
parentPointers: isEntry ? [] : [parentPointer],
childrenPointers: []
}
}
// reference our new or old leaf, and its parent
const leaf = tree[id]
const parentLeaf = tree[ids[parentPointer]]
// every leaf should point back to the entry that kicked it off
if (!leaf.entryPointers.includes(entryPointer))
leaf.entryPointers.push(entryPointer)
if (!isEntry) {
/*
* Push parent pointer, if not an entry (no parent)
*/
if (!leaf.parentPointers.includes(parentPointer))
leaf.parentPointers.push(parentPointer)
/*
* Push current child to its parent, if not an entry (no parent)
*/
if (!parentLeaf.childrenPointers.includes(pointer))
parentLeaf.childrenPointers.push(pointer)
}
// if not walkable, we've done as much as we can
if (!isTraversable) return
try {
const childModuleIds = isTraversable
? await getChildrenModuleIds({ id, alias })
: []
// if this isn't the first time we've traversed this leaf, check for any removed modules
if (!bootstrapping) {
for (const _pointer of leaf.childrenPointers) {
if (!childModuleIds.includes(ids[_pointer])) {
cleanById(ids[_pointer])
}
}
}
// walk each child module if it hasn't already been done
for (const _id of childModuleIds) {
if (!parentLeaf.childrenPointers.includes(ids.indexOf(_id))) {
await walk(_id, {
entryPointer,
parentPointer: pointer,
visitedIds,
bootstrapping
})
}
}
} catch (e) {
debug('walk error', e)
// overwrite to localize error
if (e instanceof SyntaxError) {
e = new SyntaxError(e.message, id, e.lineNumber)
}
// if no error handler is configured, just stderr it
if (!events.listeners('error').length) console.error(e)
events.emit('error', e)
}
}
}
function handleChange (file) {
const { entryPointers } = tree[file]
// bust cache for all involved files up the tree
clearUp(ids, tree, [ids.indexOf(file)])
// only emit which entryIds changed
events.emit(
'change',
entryPointers.map(p => ids[p]) // TODO pass source file
)
}
/**
* Validates that entry filepaths are absolute and notifies the user if they are not
*/
function isAbsoluteFilepath (id) {
const isAbs = path.isAbsolute(id)
if (!isAbs) {
const e = new Error(
`Paths added or removed must be absolute. You passed ${id}.`
)
events.emit('error', e)
// if no error handler is configured, just stderr it
if (!events.listeners('error').length) console.error(e)
}
return isAbs
}
/**
* Main handler
*/
watcher.on('change', async (file, stat) => {
const { pointer, entryPointers } = tree[file]
const isEntry = entryPointers.includes(pointer)
if (stat.deleted) {
debug('remove', file)
// is an entry itself (self-referential)
if (isEntry) {
// only emit if an entry is removed
events.emit('remove', [ids[pointer]])
entryIds.splice(entryIds.indexOf(ids[pointer]), 1)
ids.splice(pointer, 1)
cleanById(file) // remove any references to removed file
} else {
handleChange(file)
cleanById(file) // remove any references to removed file
}
} else {
debug('change', file)
handleChange(file)
// fabricate entry/parent pointers if we're jumping into a leaf and not an entry
await walk(file, {
visitedIds: [],
entryPointer: isEntry ? undefined : tree[file].entryPointers[0],
parentPointer: isEntry ? undefined : tree[file].parentPointers[0]
})
}
})
return {
get ids () {
return ids
},
get tree () {
return tree
},
on (ev, fn) {
return events.on(ev, fn)
},
close () {
events.clear()
watcher.removeAll()
watcher.removeAllListeners()
},
async add (files) {
debug('add', files)
files = [].concat(files).filter(entry => {
// filter out any already watched files
if (entryIds.includes(entry)) return false
return isAbsoluteFilepath(entry)
})
events.emit('add', files)
entryIds.push(...files)
// walk each entry
for (const id of entryIds) {
await walk(id, {
visitedIds: [],
bootstrapping: true
})
}
},
remove (files) {
files = [].concat(files).filter(isAbsoluteFilepath)
events.emit('remove', files)
for (const file of files) {
if (entryIds.includes(file)) {
entryIds.splice(entryIds.indexOf(file), 1)
cleanById(file)
}
}
}
}
}