@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
175 lines (158 loc) • 5.84 kB
JavaScript
const fs = require('node:fs')
const { resolve, sep } = require('node:path')
const { EventEmitter } = require('node:events')
module.exports = function watch(paths, { filter, ignore, hotReload } = {}) {
const ee = new EventEmitter()
const roots = [...new Set((Array.isArray(paths) ? paths : [paths]).filter(Boolean).map(p => resolve(p)))]
const ac = new AbortController() // eslint-disable-line no-undef
let closed = false
const _allow = p => !filter || (filter instanceof RegExp ? filter.test(p) : filter(p))
const dataFiles = hotReload && [
new RegExp(`test.*data.*\\.(csv|json)`),
new RegExp(`db.*data.*\\.(csv|json)`)
]
const _hotReload = !hotReload ? ()=>false : fullPath => {
if (!hotReload || !_allow(fullPath)) return false;
// Hot reload all CDS files, services are
if (fullPath.endsWith('.cds')) return true;
// .properties files are used for i18n -> hot reload i18n
if (fullPath.endsWith('.properties') && fullPath.includes('i18n')) return true;
// .json & .csv files used in data folders trigger a hot reload, e.g. delete and insert for the table
if (dataFiles.some(regex => regex.test(fullPath))) {
return true;
}
return false;
}
const reloadRelevantJSONfiles = hotReload ? [
new RegExp(`\\.cdsrc\\.json`),
new RegExp(`\\package\\.json`),
new RegExp(`\\.cdsrc-private\\.json`),
new RegExp(`default-env\\.json`),
] : []
/**
* Helper function to in case of hot reload restrict reloads based on .json files to just
* .cdsrc.json, cdsrc-private.json and root package.json
*/
const _narrowJSONreloads = !hotReload ? ()=> true : fullPath => {
if (!hotReload) return true;
if (reloadRelevantJSONfiles.some(regex => regex.test(fullPath))) {
return true;
}
return false;
}
const useNativeRecursive = process.platform === 'darwin' || process.platform === 'win32'
if (useNativeRecursive) {
const _watch = async root => {
try {
if (!fs.existsSync(root)) return
const watcher = fs.promises.watch(root, { recursive: true, signal: ac.signal })
for await (const event of watcher) {
if (closed) break
const name = event.filename?.toString?.() ?? event.filename ?? ''
const fullPath = resolve(root, name)
if (_hotReload(fullPath)) {
ee.emit('refreshModel', fs.existsSync(fullPath) ? 'update' : 'remove', fullPath)
} else if (_allow(fullPath) && _narrowJSONreloads(fullPath)) {
ee.emit('change', fs.existsSync(fullPath) ? 'update' : 'remove', fullPath)
}
}
} catch (e) {
if (e.name !== 'AbortError') ee.emit('error', e)
}
}
roots.forEach(r => _watch(r).catch(e => ee.emit('error', e)))
ee.close = () => {
if (closed) return
closed = true
ac.abort()
ee.emit('close')
}
return ee
} else {
return _linuxCompat()
}
// On Linux, recursive watchers allocates OS watchers for the whole tree,
// so we avoid it to skip ignored dirs before allocating watchers.
function _linuxCompat() {
const watching = new Set()
const queued = new Set()
const queue = []
const active = new Set()
const max = 32
const _ignored = p => {
if (!ignore) return false
const test = ignore instanceof RegExp ? s => ignore.test(s) : ignore
return test(p) || test(p + sep) // allow trailing sep
}
const _enqueue = d => {
if (closed) return
const dir = resolve(d)
if (_ignored(dir)) return
if (watching.has(dir) || queued.has(dir)) return
queued.add(dir)
queue.push(dir)
_pump()
}
const _startWatch = dir => {
if (watching.has(dir)) return
watching.add(dir)
;(async () => {
try {
if (!fs.existsSync(dir)) return
const watcher = fs.promises.watch(dir, { signal: ac.signal }) // non-recursive
for await (const event of watcher) {
if (closed) break
const name = event.filename?.toString?.() ?? event.filename ?? ''
const fullPath = name ? resolve(dir, name) : dir
// if a new dir appears, start watching it (unless ignored)
if (name && !_ignored(fullPath) && fs.existsSync(fullPath)) {
try {
if (fs.lstatSync(fullPath).isDirectory()) _enqueue(fullPath)
} catch { /* ignore */ }
}
if (_hotReload(fullPath)) {
ee.emit('refreshModel', fs.existsSync(fullPath) ? 'update' : 'remove', fullPath)
} else if (_allow(fullPath) && _narrowJSONreloads(fullPath)) {
ee.emit('change', fs.existsSync(fullPath) ? 'update' : 'remove', fullPath)
}
}
} catch (e) {
if (e.name !== 'AbortError' && !closed) ee.emit('error', e)
}
})().catch(e => ee.emit('error', e))
}
const _scan = async dir => {
_startWatch(dir)
let entries = []
try { entries = await fs.promises.readdir(dir, { withFileTypes: true }) }
catch { return }
for (const e of entries) {
if (e.isSymbolicLink?.()) continue
if (!e.isDirectory?.()) continue
const child = resolve(dir, e.name)
if (!_ignored(child)) _enqueue(child)
}
}
const _pump = () => {
while (!closed && queue.length && active.size < max) {
const dir = queue.shift()
queued.delete(dir)
const p = _scan(dir)
.catch(e => ee.emit('error', e))
.finally(() => {
active.delete(p)
_pump()
})
active.add(p)
}
}
roots.forEach(_enqueue)
ee.close = () => {
if (closed) return
closed = true
ac.abort()
ee.emit('close')
}
return ee
}
}