UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

468 lines (467 loc) 18.1 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any */ /* * Copyright 2017 Teppo Kurki <teppo.kurki@iki.fi> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.modulesWithKeyword = modulesWithKeyword; exports.restoreModules = restoreModules; exports.runNpm = runNpm; exports.checkForNewServerVersion = checkForNewServerVersion; exports.getAuthor = getAuthor; exports.getKeywords = getKeywords; exports.importOrRequire = importOrRequire; const child_process_1 = require("child_process"); const fs_1 = __importDefault(require("fs")); const lodash_1 = __importDefault(require("lodash")); const path_1 = __importDefault(require("path")); const semver_1 = __importStar(require("semver")); const atomicWrite_1 = require("./atomicWrite"); const debug_1 = require("./debug"); const plugin_paths_1 = require("./plugin-paths"); const debug = (0, debug_1.createDebug)('signalk:modules'); const npmDebug = (0, debug_1.createDebug)('signalk:modules:npm'); function findModulesInDir(dir, keyword) { // If no directory by name return empty array. if (!fs_1.default.existsSync(dir)) { return []; } debug('findModulesInDir: ' + dir); return fs_1.default .readdirSync(dir) .filter((name) => !name.startsWith('.')) .reduce((result, filename) => { if (filename.indexOf('@') === 0) { return result.concat(findModulesInDir(dir + filename + '/', keyword).map((entry) => { return { module: entry.module, metadata: entry.metadata, location: dir }; })); } else { let metadata; try { // eslint-disable-next-line @typescript-eslint/no-require-imports metadata = require(path_1.default.join(dir, filename, 'package.json')); } catch (err) { debug(err); } if (metadata && metadata.keywords && metadata.keywords.includes(keyword)) { result.push({ module: metadata.name, metadata, location: dir }); } } return result; }, []); } // Extract unique directory paths from app object. function getModulePaths(config) { // appPath is the app working directory. const { appPath, configPath } = config; return (appPath === configPath ? [appPath] : [configPath, appPath]).map((pathOption) => path_1.default.join(pathOption, 'node_modules/')); } const getModuleSortName = (x) => (x.module || '').replace('@signalk', ' '); // Sort handler that puts strings with '@signalk' first. const priorityPrefix = (a, b) => getModuleSortName(a).localeCompare(getModuleSortName(b)); // Searches for installed modules that contain `keyword`. function modulesWithKeyword(config, keyword) { return lodash_1.default.uniqBy( // _.flatten since values are inside an array. [[modules...], [modules...]] lodash_1.default.flatten(getModulePaths(config).map((pathOption) => findModulesInDir(pathOption, keyword))), (moduleData) => moduleData.module).sort(priorityPrefix); } function installModule(config, name, version, onData, onErr, onClose) { runNpm(config, name, version, 'install', onData, onErr, onClose); } function removeModule(config, name, version, onData, onErr, onClose, pluginId, deleteData = false) { runNpm(config, name, null, 'remove', onData, onErr, (code) => { cleanupAfterRemove(config.configPath, name, pluginId, deleteData); onClose(code); }); } function cleanupAfterRemove(configPath, packageName, pluginId, deleteData = false) { const moduleDir = path_1.default.join(configPath, 'node_modules', packageName); if (fs_1.default.existsSync(moduleDir)) { console.warn(`${packageName}: directory still exists after npm remove, cleaning up`); try { fs_1.default.rmSync(moduleDir, { recursive: true, force: true }); } catch (e) { console.error(`Failed to remove ${moduleDir}: ${e.message}`); } } const resolvedDir = path_1.default.resolve(moduleDir); for (const key of Object.keys(require.cache)) { if (key.startsWith(resolvedDir)) { delete require.cache[key]; } } const packageJsonPath = path_1.default.join(configPath, 'package.json'); if (fs_1.default.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8')); if (packageJson.dependencies && packageJson.dependencies[packageName]) { delete packageJson.dependencies[packageName]; (0, atomicWrite_1.atomicWriteFileSync)(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); console.warn(`${packageName}: removed from settings package.json`); } } catch (e) { console.error(`Failed to update settings package.json: ${e.message}`); } } if (pluginId && deleteData) { const configFile = (0, plugin_paths_1.pluginConfigPath)(configPath, pluginId); if (fs_1.default.existsSync(configFile)) { try { fs_1.default.unlinkSync(configFile); } catch (e) { console.error(`Failed to remove ${configFile}: ${e.message}`); } } const dataDir = (0, plugin_paths_1.pluginDataDir)(configPath, pluginId); if (fs_1.default.existsSync(dataDir)) { try { fs_1.default.rmSync(dataDir, { recursive: true, force: true }); } catch (e) { console.error(`Failed to remove ${dataDir}: ${e.message}`); } } } } async function getPluginDataSize(configPath, pluginId) { let totalBytes = 0; let fileCount = 0; const configFile = (0, plugin_paths_1.pluginConfigPath)(configPath, pluginId); try { const stats = await fs_1.default.promises.lstat(configFile); if (stats.isFile()) { totalBytes += stats.size; fileCount++; } } catch { // file does not exist or inaccessible } const dataDir = (0, plugin_paths_1.pluginDataDir)(configPath, pluginId); try { const dirStats = await fs_1.default.promises.lstat(dataDir); if (dirStats.isDirectory()) { const { default: getFolderSize } = await import('get-folder-size'); totalBytes += await getFolderSize.loose(dataDir); async function countFiles(d) { let entries; try { entries = await fs_1.default.promises.readdir(d); } catch { return 0; } let count = 0; for (const entry of entries) { try { const stats = await fs_1.default.promises.lstat(path_1.default.join(d, entry)); if (stats.isFile()) { count++; } else if (stats.isDirectory()) { count += await countFiles(path_1.default.join(d, entry)); } } catch { // entry removed or inaccessible between readdir and lstat } } return count; } fileCount += await countFiles(dataDir); } } catch { // directory does not exist or inaccessible } return { totalBytes, fileCount, hasData: totalBytes > 0 }; } function restoreModules(config, onData, onErr, onClose) { runNpm(config, null, null, 'remove', onData, onErr, onClose); } function runNpm(config, name, version, command, onData, onErr, onClose) { if (version && version !== '' && !semver_1.default.valid(version)) { onErr(new Error('Invalid version: ' + version)); onClose(-1); return; } let npm; const opts = {}; let packageString; if (process.platform === 'win32') { opts['shell'] = true; } if (name) { packageString = version ? `${name}@${version}` : name; } else { packageString = ''; } debug(`${command}: ${packageString}`); const npmArgs = isTheServerModule(name, config) ? [command, '-g'] : ['--save', '--ignore-scripts', command]; if (packageString) { npmArgs.push(packageString); } if (isTheServerModule(name, config)) { if (process.platform === 'win32') { npm = (0, child_process_1.spawn)('npm.cmd', npmArgs, opts); } else { npm = (0, child_process_1.spawn)('sudo', ['npm', ...npmArgs], opts); } } else { opts.cwd = config.configPath; if (process.platform === 'win32') { npm = (0, child_process_1.spawn)('npm.cmd', npmArgs, opts); } else { npm = (0, child_process_1.spawn)('npm', npmArgs, opts); } } npm.stdout.on('data', onData); npm.stderr.on('data', onErr); npm.on('close', onClose); npm.on('error', (err) => { onErr(err); onClose(-1); }); } function isTheServerModule(moduleName, config) { return moduleName === config.name; } const modulesByKeyword = {}; async function findModulesWithKeyword(keyword) { if (modulesByKeyword[keyword] && Date.now() - modulesByKeyword[keyword].time < 60 * 1000) { return modulesByKeyword[keyword].packages; } const moduleData = await searchByKeyword(keyword); npmDebug(`npm search returned ${moduleData.length} modules with keyword ${keyword}`); const result = moduleData.reduce((acc, module) => { const name = module.package.name; if (!acc[name] || semver_1.default.gt(module.package.version, acc[name].package.version)) { acc[name] = module; } return acc; }, {}); const packages = Object.values(result); modulesByKeyword[keyword] = { time: Date.now(), packages }; return packages; } const NPM_SEARCH_TIMEOUT_MS = 60_000; const NPM_DIST_TAGS_TIMEOUT_MS = 20_000; async function searchByKeyword(keyword) { let fetchedCount = 0; let toFetchCount = 1; let moduleData = []; while (fetchedCount < toFetchCount) { npmDebug(`searching ${keyword} from ${fetchedCount + 1} of ${toFetchCount}`); const res = await fetch(`https://registry.npmjs.org/-/v1/search?size=250&from=${fetchedCount > 0 ? fetchedCount : 0}&text=keywords:${keyword}`, { signal: AbortSignal.timeout(NPM_SEARCH_TIMEOUT_MS) }); if (!res.ok) { npmDebug(`npm search failed with status ${res.status}: ${res.statusText}`); break; } const parsed = (await res.json()); moduleData = moduleData.concat(parsed.objects); fetchedCount += parsed.objects.length; toFetchCount = parsed.total; } return moduleData; } let distTagsCache = { time: 0, data: {} }; async function fetchDistTagsForPackages(packageNames) { if (Date.now() - distTagsCache.time < 60 * 1000) { return distTagsCache.data; } const result = {}; const CONCURRENCY = 10; let i = 0; while (i < packageNames.length) { const batch = packageNames.slice(i, i + CONCURRENCY); const settled = await Promise.allSettled(batch.map(async (name) => { const res = await fetch(`https://registry.npmjs.org/-/package/${name}/dist-tags`, { signal: AbortSignal.timeout(NPM_DIST_TAGS_TIMEOUT_MS) }); if (!res.ok) return null; const tags = (await res.json()); return { name, tags }; })); for (const entry of settled) { if (entry.status === 'fulfilled' && entry.value) { result[entry.value.name] = entry.value.tags; } } i += CONCURRENCY; } distTagsCache = { time: Date.now(), data: result }; return result; } function doFetchDistTags() { return fetch('https://registry.npmjs.org/-/package/signalk-server/dist-tags', { signal: AbortSignal.timeout(NPM_DIST_TAGS_TIMEOUT_MS) }); } async function getLatestServerVersion(currentVersion, distTags = doFetchDistTags) { const res = await distTags(); if (!res.ok) { throw new Error(`Failed to fetch dist-tags: ${res.status} ${res.statusText}`); } const versions = (await res.json()); const prereleaseData = semver_1.default.prerelease(currentVersion); if (prereleaseData) { if (semver_1.default.satisfies(versions.latest, `>${currentVersion}`)) { return versions.latest; } else { return versions[prereleaseData[0]]; } } else { return versions.latest; } } function checkForNewServerVersion(currentVersion, serverUpgradeIsAvailable, getLatestServerVersionP = getLatestServerVersion) { getLatestServerVersionP(currentVersion) .then((version) => { if (semver_1.default.satisfies(new semver_1.SemVer(version), `>${currentVersion}`)) { serverUpgradeIsAvailable(undefined, version); } }) .catch((err) => { serverUpgradeIsAvailable(`unable to check for new server version: ${err}`); }); } function getAuthor(thePackage) { // Only use package.json's author field. npm maintainers and publisher // are publish-access identities (and OIDC sets publisher to "GitHub // Actions"), so falling back to them surfaces whoever happens to hold // publish rights as the "author" — not who wrote the package. const authorField = thePackage.author; if (typeof authorField === 'string') return authorField; return authorField?.name ?? ''; } function getKeywords(thePackage) { const keywords = thePackage.keywords; debug('%s keywords: %j', thePackage.name, keywords); return keywords; } async function importOrRequire(moduleDir) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const mod = require(moduleDir); // Starting with version 20.19.0 and 22 Node will load ESM modules with require // https://nodejs.org/en/blog/release/v20.19.0 return mod.default ?? mod; } catch (err) { debug(`Failed to require("${moduleDir}") module, trying import()`); // `import()` only works with file paths or npm module names. It can't // directly load a path to a directory. One solution would be to refactor // module loading to update `NODE_PATH` with plugin directories, and // then import/require them here using just their module name (e.g. // `import("@signalk/plugin-name")`), which would allow NodeJS to resolve // and load the module. This would be a little more extensive refactoring // that may be worth while once the whole project is entirely using ESM. // For now, this `esm-resolve` package work const { buildResolver } = await import('esm-resolve'); const resolver = buildResolver(moduleDir, { isDir: true, resolveToAbsolute: true }); const modulePath = resolver('.'); if (modulePath) { const module = await import(modulePath); return module.default; } else { // Could not resolve, throw the original error. throw err; } } } module.exports = { modulesWithKeyword, installModule, removeModule, isTheServerModule, findModulesWithKeyword, fetchDistTagsForPackages, getLatestServerVersion, checkForNewServerVersion, getAuthor, getKeywords, restoreModules, importOrRequire, runNpm, getPluginDataSize }; //# sourceMappingURL=modules.js.map