@nodecg/types
Version:
Dynamic broadcast graphics rendered in a browser
354 lines • 14.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BundleManager = void 0;
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
const internal_util_1 = require("@nodecg/internal-util");
const chokidar_1 = __importDefault(require("chokidar"));
const cosmiconfig_1 = require("cosmiconfig");
const lodash_1 = require("lodash");
const semver_1 = __importDefault(require("semver"));
const typed_emitter_1 = require("../shared/typed-emitter");
const bundle_parser_1 = require("./bundle-parser");
const git_1 = require("./bundle-parser/git");
const logger_1 = require("./logger");
const is_child_path_1 = require("./util/is-child-path");
/**
* Milliseconds
*/
const READY_WAIT_THRESHOLD = 1000;
// Start up the watcher, but don't watch any files yet.
// We'll add the files we want to watch later, in the init() method.
const watcher = chokidar_1.default.watch([], {
persistent: true,
ignoreInitial: true,
followSymlinks: true,
ignored: [
/\/.+___jb_.+___/, // Ignore temp files created by JetBrains IDEs
/\/node_modules\//, // Ignore node_modules folders
/\/bower_components\//, // Ignore bower_components folders
/\/.+\.lock/, // Ignore lockfiles
],
});
const blacklistedBundleDirectories = ["node_modules", "bower_components"];
const bundles = [];
const log = (0, logger_1.createLogger)("bundle-manager");
const hasChanged = new Set();
let backoffTimer;
class BundleManager extends typed_emitter_1.TypedEmitter {
bundles = [];
get ready() {
return this._ready;
}
_ready = false;
_cfgPath;
_debouncedGitChangeHandler = (0, lodash_1.debounce)((bundleName) => {
const bundle = this.find(bundleName);
if (!bundle) {
return;
}
bundle.git = (0, git_1.parseGit)(bundle.dir);
this.emit("gitChanged", bundle);
}, 250);
constructor(bundlesPaths, cfgPath, nodecgVersion, nodecgConfig) {
super();
this._cfgPath = cfgPath;
const readyTimeout = setTimeout(() => {
this._ready = true;
this.emit("ready");
}, READY_WAIT_THRESHOLD);
bundlesPaths.forEach((bundlesPath) => {
log.trace(`Loading bundles from ${bundlesPath}`);
/* istanbul ignore next */
watcher.on("add", (filePath) => {
const bundleName = getParentProjectName(filePath, bundlesPath);
if (!bundleName) {
return;
}
// In theory, the bundle parser would have thrown an error long before this block would execute,
// because in order for us to be adding a panel HTML file, that means that the file would have been missing,
// which the parser does not allow and would throw an error for.
// Just in case though, its here.
if (this.isPanelHTMLFile(bundleName, filePath)) {
this.handleChange(bundleName);
}
else if (isGitData(bundleName, filePath)) {
this._debouncedGitChangeHandler(bundleName);
}
if (!this.ready) {
readyTimeout.refresh();
}
});
watcher.on("change", (filePath) => {
const bundleName = getParentProjectName(filePath, bundlesPath);
if (!bundleName) {
return;
}
if (isManifest(bundleName, filePath) ||
this.isPanelHTMLFile(bundleName, filePath)) {
this.handleChange(bundleName);
}
else if (isGitData(bundleName, filePath)) {
this._debouncedGitChangeHandler(bundleName);
}
});
watcher.on("unlink", (filePath) => {
const bundleName = getParentProjectName(filePath, bundlesPath);
if (!bundleName) {
return;
}
if (this.isPanelHTMLFile(bundleName, filePath)) {
// This will cause NodeCG to crash, because the parser will throw an error due to
// a panel's HTML file no longer being present.
this.handleChange(bundleName);
}
else if (isGitData(bundleName, filePath)) {
this._debouncedGitChangeHandler(bundleName);
}
});
/* istanbul ignore next */
watcher.on("error", (error) => {
log.error(error.stack);
});
const handleBundle = (bundlePath) => {
if (!node_fs_1.default.statSync(bundlePath).isDirectory()) {
return;
}
// Prevent attempting to load unwanted directories. Those specified above and all dot-prefixed.
const bundleFolderName = node_path_1.default.basename(bundlePath);
if (blacklistedBundleDirectories.includes(bundleFolderName) ||
bundleFolderName.startsWith(".")) {
return;
}
const bundlePackageJson = node_fs_1.default.readFileSync(node_path_1.default.join(bundlePath, "package.json"), "utf-8");
const bundleName = JSON.parse(bundlePackageJson).name;
if (nodecgConfig?.["bundles"]?.disabled?.includes(bundleName)) {
log.debug(`Not loading bundle ${bundleName} as it is disabled in config`);
return;
}
if (nodecgConfig?.["bundles"]?.enabled &&
!nodecgConfig?.["bundles"].enabled.includes(bundleName)) {
log.debug(`Not loading bundle ${bundleName} as it is not enabled in config`);
return;
}
log.debug(`Loading bundle ${bundleName}`);
// Parse each bundle and push the result onto the bundles array
const bundle = (0, bundle_parser_1.parseBundle)(bundlePath, loadBundleCfg(cfgPath, bundleName));
if (internal_util_1.isLegacyProject) {
if (!bundle.compatibleRange) {
log.error(`${bundle.name}'s package.json does not have a "nodecg.compatibleRange" property.`);
return;
}
// Check if the bundle is compatible with this version of NodeCG
if (!semver_1.default.satisfies(nodecgVersion, bundle.compatibleRange)) {
log.error(`${bundle.name} requires NodeCG version ${bundle.compatibleRange}, current version is ${nodecgVersion}`);
return;
}
}
bundles.push(bundle);
// Use `chokidar` to watch for file changes within bundles.
// Workaround for https://github.com/paulmillr/chokidar/issues/419
// This workaround is necessary to fully support symlinks.
// This is applied after the bundle has been validated and loaded.
// Bundles that do not properly load upon startup are not recognized for updates.
watcher.add([
node_path_1.default.join(bundlePath, ".git"), // Watch `.git` directories.
node_path_1.default.join(bundlePath, "dashboard"), // Watch `dashboard` directories.
node_path_1.default.join(bundlePath, "package.json"), // Watch each bundle's `package.json`.
]);
};
if (!internal_util_1.isLegacyProject) {
handleBundle(internal_util_1.rootPath);
}
if (node_fs_1.default.existsSync(bundlesPath)) {
const bundleFolders = node_fs_1.default.readdirSync(bundlesPath);
bundleFolders.forEach((bundleFolderName) => {
const bundlePath = node_path_1.default.join(bundlesPath, bundleFolderName);
handleBundle(bundlePath);
});
}
});
}
/**
* Returns a shallow-cloned array of all currently active bundles.
* @returns {Array.<Object>}
*/
all() {
return bundles.slice(0);
}
/**
* Returns the bundle with the given name. undefined if not found.
* @param name {String} - The name of the bundle to find.
* @returns {Object|undefined}
*/
find(name) {
return bundles.find((b) => b.name === name);
}
/**
* Adds a bundle to the internal list, replacing any existing bundle with the same name.
* @param bundle {Object}
*/
add(bundle) {
/* istanbul ignore if: Again, it shouldn't be possible for "bundle" to be undefined, but just in case... */
if (!bundle) {
return;
}
// Remove any existing bundles with this name
if (this.find(bundle.name)) {
this.remove(bundle.name);
}
bundles.push(bundle);
}
/**
* Removes a bundle with the given name from the internal list. Does nothing if no match found.
* @param bundleName {String}
*/
remove(bundleName) {
const len = bundles.length;
for (let i = 0; i < len; i++) {
// TODO: this check shouldn't have to happen, idk why things in this array can sometimes be undefined
if (!bundles[i]) {
continue;
}
if (bundles[i].name === bundleName) {
bundles.splice(i, 1);
}
}
}
handleChange(bundleName) {
setTimeout(() => {
this._handleChange(bundleName);
}, 100);
}
/**
* Resets the backoff timer used to avoid event thrashing when many files change rapidly.
*/
resetBackoffTimer() {
clearTimeout(backoffTimer); // Typedefs for clearTimeout are always wonky
backoffTimer = setTimeout(() => {
backoffTimer = undefined;
for (const bundleName of hasChanged) {
log.debug("Backoff finished, emitting change event for", bundleName);
this.handleChange(bundleName);
}
hasChanged.clear();
}, 500);
}
/**
* Checks if a given path is a panel HTML file of a given bundle.
* @param bundleName {String}
* @param filePath {String}
* @returns {Boolean}
* @private
*/
isPanelHTMLFile(bundleName, filePath) {
const bundle = this.find(bundleName);
if (bundle) {
return bundle.dashboard.panels.some((panel) => panel.path.endsWith(filePath));
}
return false;
}
/**
* Only used by tests.
*/
_stopWatching() {
void watcher.close();
}
_handleChange(bundleName) {
const bundle = this.find(bundleName);
/* istanbul ignore if: It's rare for `bundle` to be undefined here, but it can happen when using black/whitelisting. */
if (!bundle) {
return;
}
if (backoffTimer) {
log.debug("Backoff active, delaying processing of change detected in", bundleName);
hasChanged.add(bundleName);
this.resetBackoffTimer();
}
else {
log.debug("Processing change event for", bundleName);
this.resetBackoffTimer();
try {
const reparsedBundle = (0, bundle_parser_1.parseBundle)(bundle.dir, loadBundleCfg(this._cfgPath, bundle.name));
this.add(reparsedBundle);
this.emit("bundleChanged", reparsedBundle);
}
catch (error) {
log.warn('Unable to handle the bundle "%s" change:\n%s', bundleName, error.stack);
this.emit("invalidBundle", bundle, error);
}
}
}
}
exports.BundleManager = BundleManager;
/**
* Checks if a given path is the manifest file for a given bundle.
* @param bundleName {String}
* @param filePath {String}
* @returns {Boolean}
* @private
*/
function isManifest(bundleName, filePath) {
return (node_path_1.default.dirname(filePath).endsWith(bundleName) &&
node_path_1.default.basename(filePath) === "package.json");
}
/**
* Checks if a given path is in the .git dir of a bundle.
* @param bundleName {String}
* @param filePath {String}
* @returns {Boolean}
* @private
*/
function isGitData(bundleName, filePath) {
const regex = new RegExp(`${bundleName}\\${node_path_1.default.sep}\\.git`);
return regex.test(filePath);
}
/**
* Determines which config file to use for a bundle.
*/
function loadBundleCfg(cfgDir, bundleName) {
try {
const cc = (0, cosmiconfig_1.cosmiconfigSync)("nodecg", {
searchPlaces: [
`${bundleName}.json`,
`${bundleName}.yaml`,
`${bundleName}.yml`,
`${bundleName}.js`,
`${bundleName}.config.js`,
],
stopDir: cfgDir,
});
const result = cc.search(cfgDir);
return result?.config;
}
catch (_) {
throw new Error(`Config for bundle "${bundleName}" could not be read. Ensure that it is valid JSON, YAML, or CommonJS.`);
}
}
function getParentProjectName(changePath, rootPath) {
if (!(0, is_child_path_1.isChildPath)(rootPath, changePath)) {
return false;
}
const filePath = node_path_1.default.join(changePath, "package.json");
try {
const fileContent = node_fs_1.default.readFileSync(filePath, "utf-8");
try {
const parsed = JSON.parse(fileContent);
return parsed.name;
}
catch (error) {
return false;
}
}
catch (error) {
const parentDir = node_path_1.default.join(changePath, "..");
if (parentDir === changePath) {
return false;
}
return getParentProjectName(parentDir, rootPath);
}
}
//# sourceMappingURL=bundle-manager.js.map