UNPKG

signalk-server

Version:

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

730 lines 32.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const logging_1 = require("@signalk/streams/logging"); const express_1 = __importDefault(require("express")); const fs_1 = __importDefault(require("fs")); const util_1 = require("util"); const lodash_1 = __importDefault(require("lodash")); const path_1 = __importDefault(require("path")); const constants_1 = require("../constants"); const debug_1 = require("../debug"); const plugin_paths_1 = require("../plugin-paths"); const serialports_1 = require("../serialports"); const debug = (0, debug_1.createDebug)('signalk-server:interfaces:plugins'); const deltastats_1 = require("../deltastats"); const modules_1 = require("../modules"); const put = require('../put'); const _putPath = put.putPath; const getModulePublic = require('../config/get').getModulePublic; const requestResponse_1 = require("../requestResponse"); const path_metadata_1 = require("@signalk/path-metadata"); const pluginid_1 = require("../pluginid"); const atomicWrite_1 = require("../atomicWrite"); const config_1 = require("../config/config"); const deltaeditor_1 = __importDefault(require("../deltaeditor")); const unitpreferences_1 = require("../unitpreferences"); // #521 Returns path to load plugin-config assets. const getPluginConfigPublic = getModulePublic('@signalk/plugin-config'); const DEFAULT_ENABLED_PLUGINS = process.env.DEFAULTENABLEDPLUGINS ? process.env.DEFAULTENABLEDPLUGINS.split(',') : []; function backwardsCompat(url) { return [`${constants_1.SERVERROUTESPREFIX}${url}`, url]; } // Resolve excludeSelf:true to the plugin's id and union it with any // explicit excludeSources. Returns undefined when neither field is // set so the existing "no excludes" fast path in subscriptionmanager // is preserved. function mergeExcludeSelf(excludeSources, excludeSelf, pluginId) { const hasList = Array.isArray(excludeSources) && excludeSources.length > 0; if (!hasList && !excludeSelf) return undefined; const merged = new Set(); if (hasList) { for (const ref of excludeSources) { if (typeof ref === 'string' && ref.length > 0) merged.add(ref); } } if (excludeSelf) merged.add(pluginId); return merged.size > 0 ? Array.from(merged) : undefined; } module.exports = (theApp) => { const onStopHandlers = {}; const appNodeModules = path_1.default.join(theApp.config.appPath, 'node_modules/'); return { async start() { ensureExists(path_1.default.join(theApp.config.configPath, plugin_paths_1.PLUGIN_CONFIG_DATA_DIR)); theApp.getPluginsList = async (enabled) => { return await getPluginsList(enabled); }; theApp.use(backwardsCompat('/plugins/configure'), express_1.default.static(getPluginConfigPublic(theApp))); theApp.get(backwardsCompat('/plugins'), (req, res) => { getPluginResponseInfos() .then((json) => res.json(json)) .catch((err) => { console.error(err); res.status(500); res.json(err); }); }); await startPlugins(theApp); } }; function getPluginResponseInfos() { const providerStatus = theApp.getProviderStatus(); return Promise.all(lodash_1.default.sortBy(theApp.plugins, [ (plugin) => { return plugin.name; } ]).map((plugin) => getPluginResponseInfo(plugin, providerStatus))); } function emitPluginsChanged() { getPluginResponseInfos() .then((plugins) => { theApp.emit('serverevent', { type: 'PLUGINS_CHANGED', from: 'signalk-server', data: plugins }); }) .catch((err) => { console.error('Failed to emit PLUGINS_CHANGED:', err); }); } function getPluginsList(enabled) { return getPluginResponseInfos().then((pa) => { const res = pa.map((p) => { return { id: p.id, name: p.name, version: p.version, enabled: p.data.enabled ?? false }; }); if (typeof enabled === 'undefined') { return res; } else { return res.filter((p) => { return p.enabled === enabled; }); } }); } function isBundledPlugin(plugin) { return plugin.packageLocation === appNodeModules; } function getPluginResponseInfo(plugin, providerStatus) { return new Promise((resolve, reject) => { let data = null; try { data = getPluginOptions(plugin.id); } catch (e) { console.error(e.code + ' ' + e.path); } if (data && lodash_1.default.isUndefined(data.enabled) && plugin.enabledByDefault) { data.enabled = true; } Promise.all([ Promise.resolve(typeof plugin.schema === 'function' ? (() => { try { return plugin.schema(); } catch (e) { console.error(e); // return a fake schema to inform the user // downside is that saving this may overwrite an existing configuration return { type: 'object', required: ['error'], properties: { error: { title: 'Error loading plugin configuration schema, check server log', type: 'string' } } }; } })() : plugin.schema), Promise.resolve(typeof plugin.uiSchema === 'function' ? plugin.uiSchema() : plugin.uiSchema) ]) .then(([schema, uiSchema]) => { const status = providerStatus.find((p) => p.id === plugin.name); const statusMessage = status ? status.message : ''; if (schema === undefined) { console.error(`Error: plugin ${plugin.id} is missing configuration schema`); } resolve({ id: plugin.id, name: plugin.name, packageName: plugin.packageName, keywords: plugin.keywords, version: plugin.version, description: plugin.description, schema: schema || {}, statusMessage, uiSchema, state: plugin.state, data, type: plugin.type, // Include type to identify WASM plugins in Admin UI bundled: isBundledPlugin(plugin) }); }) .catch((err) => { reject(err); }); }); } function ensureExists(dir) { if (!fs_1.default.existsSync(dir)) { fs_1.default.mkdirSync(dir); } } function pathForPluginId(id) { return (0, plugin_paths_1.pluginConfigPath)(theApp.config.configPath, id); } function dirForPluginId(id) { const dirName = (0, plugin_paths_1.pluginDataDir)(theApp.config.configPath, id); ensureExists(dirName); return dirName; } function savePluginOptions(pluginId, data, callback) { try { (0, atomicWrite_1.atomicWriteFileSync)(pathForPluginId(pluginId), JSON.stringify(data, null, 2)); callback(null); } catch (err) { callback(err); } } function getPluginOptions(id) { let optionsAsString = '{}'; try { optionsAsString = fs_1.default.readFileSync(pathForPluginId(id), 'utf8'); } catch (_e) { debug('Could not find options for plugin ' + id + ', returning empty options: '); } try { const options = JSON.parse(optionsAsString); if (optionsAsString === '{}' && DEFAULT_ENABLED_PLUGINS.includes(id)) { debug('Override enable for plugin ' + id); options.enabled = true; } if (process.env.DISABLEPLUGINS) { debug('Plugins disabled by configuration'); options.enabled = false; } debug(optionsAsString); return options; } catch (e) { console.error('Could not parse JSON options:' + e.message + ' ' + optionsAsString); return {}; } } async function startPlugins(app) { app.plugins = []; app.pluginsMap = {}; // Expose getPluginOptions for use by other modules (e.g., webapps.js) app.getPluginOptions = getPluginOptions; // Discover both Node.js and WASM plugins const jsModules = (0, modules_1.modulesWithKeyword)(app.config, 'signalk-node-server-plugin'); const wasmModules = (0, modules_1.modulesWithKeyword)(app.config, 'signalk-wasm-plugin'); // Combine and deduplicate by module name (a plugin might have both keywords) const seenModules = new Set(); const modules = [...jsModules, ...wasmModules].filter((moduleData) => { if (seenModules.has(moduleData.module)) { return false; } seenModules.add(moduleData.module); return true; }); await Promise.all(modules.map((moduleData) => { return registerPlugin(app, moduleData.module, moduleData.metadata, moduleData.location); })); } function handleMessageWrapper(app, id) { const pluginsLoggingEnabled = lodash_1.default.isUndefined(app.config.settings.enablePluginLogging) || app.config.settings.enablePluginLogging; return (providerId, data) => { const plugin = app.pluginsMap[id]; if (!lodash_1.default.isUndefined(plugin) && pluginsLoggingEnabled && plugin.enableLogging) { if (!plugin.logger) { plugin.logger = (0, logging_1.getLogger)(app, providerId); } plugin.logger(data); } app.handleMessage(id, data); }; } function getSelfPath(aPath) { return lodash_1.default.get(theApp.signalk.self, aPath); } function getPath(aPath) { if (aPath === '/sources') { return { ...theApp.signalk.retrieve().sources, ...theApp.deltaCache.getSources() }; } else { return lodash_1.default.get(theApp.signalk.retrieve(), aPath); } } function putSelfPath(aPath, value, updateCb, source) { return _putPath(theApp, 'vessels.self', aPath, { value, source }, null, null, updateCb); } function putPath(aPath, value, updateCb, source) { const parts = aPath.length > 0 ? aPath.split('.') : []; if (parts.length <= 2) { updateCb(new Error(`Put path begin with a two part context:${aPath}`)); return; } const context = `${parts[0]}.${parts[1]}`; const skpath = parts.slice(2).join('.'); return _putPath(theApp, context, skpath, { value, source }, null, null, updateCb); } function getSerialPorts() { return (0, serialports_1.listAllSerialPorts)(); } async function registerPlugin(app, pluginName, metadata, location) { debug('Registering plugin ' + pluginName); try { // Check if this is a WASM plugin (wasmManifest is now part of NpmPackageData) if (metadata.wasmManifest) { // This is a WASM plugin - check if WASM interface is enabled const wasmEnabled = app.config.settings.interfaces?.wasm !== false; if (!wasmEnabled) { debug(`WASM plugin ${pluginName} discovered but WASM interface disabled - registering minimal entry`); // Create minimal plugin entry so it appears in Plugin Config with "No WASM" badge // Derive plugin ID from npm package name (@ → _, / → _) const pluginId = (0, pluginid_1.derivePluginId)(pluginName); // Use signalk.displayName (standard SignalK convention) or fall back to package name const pluginDisplayName = metadata.signalk?.displayName || pluginName; const minimalPlugin = { id: pluginId, name: pluginDisplayName, type: 'wasm', packageName: pluginName, version: metadata.version, description: metadata.description || '', keywords: metadata.keywords || [], packageLocation: location, enabled: false, state: 'disabled', statusMessage: () => 'WASM interface disabled', schema: () => ({}), uiSchema: () => ({}), start: () => { }, stop: () => Promise.resolve(), enableLogging: false, enableDebug: false }; app.plugins.push(minimalPlugin); app.pluginsMap[pluginId] = minimalPlugin; debug(`Registered minimal WASM plugin entry: ${pluginId} (WASM disabled)`); return; } // Route to WASM loader debug(`Detected WASM plugin: ${pluginName}`); const { registerWasmPlugin } = require('../wasm'); await registerWasmPlugin(app, pluginName, metadata, location, theApp.config.configPath); return; } // Standard Node.js plugin await doRegisterPlugin(app, pluginName, metadata, location); } catch (e) { console.error(e); } } function stopPlugin(plugin) { debug('Stopping plugin ' + plugin.name); onStopHandlers[plugin.id].forEach((f) => { try { f(); } catch (err) { console.error(err); } }); onStopHandlers[plugin.id] = []; const result = Promise.resolve(plugin.stop()); result.then(() => { theApp.setPluginStatus(plugin.id, 'Stopped'); debug('Stopped plugin ' + plugin.name); if (theApp.deltaCache) { theApp.deltaCache.removeSource(plugin.id); } }); return result; } function setPluginStartedMessage(plugin) { const statusMessage = typeof plugin.statusMessage === 'function' ? plugin.statusMessage() : undefined; if (lodash_1.default.isUndefined(statusMessage) && lodash_1.default.isUndefined(theApp.providerStatus[plugin.id]) && lodash_1.default.isUndefined(plugin.statusMessage)) { theApp.setPluginStatus(plugin.id, 'Started'); } } function doPluginStart(app, plugin, location, configuration, restart) { debug('Starting plugin %s from %s', plugin.name, location); try { app.setPluginStatus(plugin.id, null); if (plugin.enableDebug) { app.logging.addDebug(plugin.packageName); } else { app.logging.removeDebug(plugin.packageName); } let safeConfiguration = configuration; if (!safeConfiguration) { console.error(`${plugin.id}:no configuration data`); safeConfiguration = {}; } onStopHandlers[plugin.id].push(() => { app.resourcesApi.unRegister(plugin.id); app.autopilotApi.unRegister(plugin.id); app.weatherApi.unRegister(plugin.id); }); plugin.start(safeConfiguration, restart); debug('Started plugin ' + plugin.name); setPluginStartedMessage(plugin); } catch (e) { console.error('error starting plugin: ' + e); console.error(e.stack); app.setProviderError(plugin.id, `Failed to start: ${e.message}`); } } async function doRegisterPlugin(app, packageName, metadata, location) { let plugin; const appCopy = lodash_1.default.assign({}, app, { getSelfPath, getPath, putSelfPath, queryRequest: requestResponse_1.queryRequest, error: (msg) => { console.error(`${packageName}:${msg}`); if (msg instanceof Error) { console.error(msg.stack); } }, debug: (0, debug_1.createDebug)(packageName), registerDeltaInputHandler: (handler) => { onStopHandlers[plugin.id].push(app.registerDeltaInputHandler(handler)); }, setProviderStatus: (0, util_1.deprecate)((msg) => { app.setPluginStatus(plugin.id, msg); }, `[${packageName}] setProviderStatus() is deprecated, use setPluginStatus() instead`), setProviderError: (0, util_1.deprecate)((msg) => { app.setPluginError(plugin.id, msg); }, `[${packageName}] setProviderError() is deprecated, use setPluginError() instead`), setPluginStatus: (msg) => { app.setPluginStatus(plugin.id, msg); }, setPluginError: (msg) => { app.setPluginError(plugin.id, msg); }, emitPropertyValue(name, value) { const propValues = app.propertyValues; // just for typechecking propValues.emitPropertyValue({ timestamp: Date.now(), setter: plugin.id, name, value }); }, onPropertyValues(name, cb) { return app.propertyValues.onPropertyValues(name, cb); }, getSerialPorts, supportsMetaDeltas: true, getMetadata: path_metadata_1.getMetadata, setDefaultMetadata: async (skPath, value) => { const context = 'vessels.self'; const existingMeta = app.config.baseDeltaEditor.getMeta(context, skPath); const { hasNewFields, fieldsToSet, merged } = deltaeditor_1.default.computeDefaultFields(existingMeta, value); if (!hasNewFields) { return false; } const displayUnits = fieldsToSet.displayUnits; if (displayUnits?.category) { const schemaMeta = (0, path_metadata_1.getMetadata)('vessels.self.' + skPath); const pathSiUnit = fieldsToSet.units ?? existingMeta?.units ?? schemaMeta?.units; const validationError = (0, unitpreferences_1.validateCategoryAssignment)(pathSiUnit, displayUnits.category); if (validationError) { debug(`setDefaultMetadata: invalid category for ${skPath}: ${validationError}`); return false; } } app.config.baseDeltaEditor.setMeta(context, skPath, merged); await (0, config_1.writeBaseDeltasFile)(app); app.handleMessage(plugin.id, { context: 'vessels.self', updates: [ { meta: [ { path: skPath, value: merged } ] } ] }); return true; }, reportOutputMessages: (count) => { app.emit(deltastats_1.CONNECTION_WRITE_EVENT_NAME, { providerId: plugin.id, count }); } }); appCopy.putPath = putPath; appCopy.subscriptionmanager = { subscribe: (command, unsubscribes, errorCallback, callback, user) => { const safeCallback = (delta) => { try { callback(delta); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`${packageName} subscription callback error: ${message}`); if (err instanceof Error && err.stack) { console.error(err.stack); } app.setPluginError(plugin.id, `Runtime error: ${message}`); } }; // Honour command.sourcePolicy so a plugin can opt into the // unfiltered stream (every source) instead of the priority-resolved // preferred-only default. Without this, plugins whose use case is // per-source — historians writing all sources, calibrators pinning // a specific device — silently never see non-preferred deltas // because plugin subscriptions read streambundle.buses (the // post-toPreferredDelta bus). Same plumbing the WS interface uses. // // Resolve excludeSelf: true to [plugin.id] here so the engine // downstream only ever sees excludeSources. Plugins emitting // multiple labels (e.g. "myplugin.windFromPolars") should set // excludeSources explicitly; excludeSelf is the simple case // where the plugin's $source is plugin.id verbatim. const excludeSources = mergeExcludeSelf(command.excludeSources, command.excludeSelf, plugin.id); app.subscriptionmanager.subscribe(command, unsubscribes, errorCallback, safeCallback, user, command.sourcePolicy, excludeSources); }, unsubscribe: (msg, unsubscribes) => { app.subscriptionmanager.unsubscribe(msg, unsubscribes); } }; const weatherApi = app.weatherApi; appCopy.registerWeatherProvider = (provider) => { weatherApi.register(plugin.id, provider); }; const historyApiRegistry = app.historyApiHttpRegistry; delete appCopy.historyApiHttpRegistry; // expose only the plugin-specific proxy appCopy.registerHistoryApiProvider = (provider) => { historyApiRegistry.registerHistoryApiProvider(plugin.id, provider); onStopHandlers[plugin.id].push(() => { historyApiRegistry.unregisterHistoryApiProvider(plugin.id); }); }; const resourcesApi = app.resourcesApi; appCopy.registerResourceProvider = (provider) => { resourcesApi.register(plugin.id, provider); }; const autopilotApi = app.autopilotApi; appCopy.registerAutopilotProvider = (provider, devices) => { autopilotApi.register(plugin.id, provider, devices); }; appCopy.autopilotUpdate = (deviceId, apInfo) => { autopilotApi.apUpdate(plugin.id, deviceId, apInfo); }; const courseApi = app.courseApi; appCopy.getCourse = () => { return courseApi.getCourse(); }; appCopy.clearDestination = () => { return courseApi.clearDestination(); }; appCopy.setDestination = (dest) => { return courseApi.destination(dest); }; appCopy.activateRoute = (dest) => { return courseApi.activeRoute(dest); }; appCopy.notifications = { list: () => app.notificationApi.list(), getId: (id) => app.notificationApi.getId(id), getPath: (path) => app.notificationApi.getPath(path), raise: (options) => app.notificationApi.raise(options), mob: (message) => app.notificationApi.mob(message), update: (id, options) => app.notificationApi.update(id, options), clear: (id) => app.notificationApi.clear(id), silence: (id) => app.notificationApi.silence(id), silenceAll: () => app.notificationApi.silenceAll(), acknowledge: (id) => app.notificationApi.acknowledge(id), acknowledgeAll: () => app.notificationApi.acknowledgeAll() }; delete appCopy.notificationApi; // expose only the plugin-specific methods try { const moduleDir = path_1.default.join(location, packageName); const pluginConstructor = await (0, modules_1.importOrRequire)(moduleDir); plugin = pluginConstructor(appCopy); } catch (e) { console.error(`${packageName} failed to start: ${e.message}`); console.error(e); app.setProviderError(packageName, `Failed to start: ${e.message}`); return; } onStopHandlers[plugin.id] = []; if (app.pluginsMap[plugin.id]) { console.log(`WARNING: found multiple copies of plugin with id ${plugin.id} at ${location} and ${app.pluginsMap[plugin.id].packageLocation}`); return; } appCopy.handleMessage = handleMessageWrapper(app, plugin.id); const boundEventMethods = app.wrappedEmitter.bindMethodsById(`plugin:${plugin.id}`); lodash_1.default.assign(appCopy, boundEventMethods); appCopy.savePluginOptions = (configuration, cb) => { savePluginOptions(plugin.id, { ...getPluginOptions(plugin.id), configuration }, cb); }; appCopy.readPluginOptions = () => { return getPluginOptions(plugin.id); }; appCopy.getDataDirPath = () => dirForPluginId(plugin.id); appCopy.registerPutHandler = (context, aPath, callback, source) => { appCopy.handleMessage(plugin.id, { updates: [ { meta: [ { path: aPath, value: { supportsPut: true } } ] } ] }); onStopHandlers[plugin.id].push(app.registerActionHandler(context, aPath, source || plugin.id, callback)); }; appCopy.registerActionHandler = appCopy.registerPutHandler; appCopy.registerHistoryProvider = (provider) => { app.registerHistoryProvider(provider); const apiList = app.apis; apiList.push('historyplayback'); apiList.push('historysnapshot'); onStopHandlers[plugin.id].push(() => { app.unregisterHistoryProvider(provider); }); }; const startupOptions = getPluginOptions(plugin.id); const restart = (newConfiguration) => { const pluginOptions = getPluginOptions(plugin.id); pluginOptions.configuration = newConfiguration; savePluginOptions(plugin.id, pluginOptions, (err) => { if (err) { console.error(err); } else { stopPlugin(plugin).then(() => { doPluginStart(app, plugin, location, newConfiguration, restart); emitPluginsChanged(); }); } }); }; if (isEnabledByPackageEnableDefault(startupOptions, metadata)) { startupOptions.enabled = true; startupOptions.configuration = {}; plugin.enabledByDefault = true; // Persist the default-enabled state to disk so the plugin can be disabled later savePluginOptions(plugin.id, startupOptions, (err) => { if (err) { console.error(`Error saving default-enabled options for ${plugin.id}:`, err); } }); } plugin.enableDebug = startupOptions.enableDebug; plugin.version = metadata.version; plugin.packageName = metadata.name; plugin.keywords = metadata.keywords; plugin.packageLocation = location; if (startupOptions && startupOptions.enabled) { doPluginStart(app, plugin, location, startupOptions.configuration, restart); } plugin.enableLogging = startupOptions.enableLogging; app.plugins.push(plugin); app.pluginsMap[plugin.id] = plugin; const router = express_1.default.Router(); router.get('/', (req, res) => { const currentOptions = getPluginOptions(plugin.id); const enabledByDefault = isEnabledByPackageEnableDefault(currentOptions, metadata); res.json({ enabled: enabledByDefault || currentOptions.enabled, enabledByDefault, id: plugin.id, name: plugin.name, version: plugin.version }); }); router.post('/config', (req, res) => { savePluginOptions(plugin.id, req.body, (err) => { if (err) { console.error(err); res.status(500); res.json(err); return; } res.json('Saved configuration for plugin ' + plugin.id); stopPlugin(plugin).then(() => { const options = getPluginOptions(plugin.id); plugin.enableLogging = options.enableLogging; plugin.enableDebug = options.enableDebug; if (options.enabled) { doPluginStart(app, plugin, location, options.configuration, restart); } emitPluginsChanged(); }); }); }); router.get('/config', (req, res) => { res.json(getPluginOptions(plugin.id)); }); if (typeof plugin.registerWithRouter === 'function') { plugin.registerWithRouter(router); if (typeof plugin.getOpenApi === 'function') { app.setPluginOpenApi(plugin.id, plugin.getOpenApi()); } } app.use(backwardsCompat('/plugins/' + plugin.id), router); if (typeof plugin.signalKApiRoutes === 'function') { app.use('/signalk/v1/api', plugin.signalKApiRoutes(express_1.default.Router())); } } }; const isEnabledByPackageEnableDefault = (options, metadata) => lodash_1.default.isUndefined(options.enabled) && metadata['signalk-plugin-enabled-by-default']; //# sourceMappingURL=plugins.js.map