UNPKG

signalk-server

Version:

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

359 lines (358 loc) 17.9 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any */ /* * Copyright 2016 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; }; })(); const Bacon = __importStar(require("baconjs")); const geolib_1 = require("geolib"); const lodash_1 = __importStar(require("lodash")); const debug_1 = require("./debug"); const streambundle_1 = require("./streambundle"); const debug = (0, debug_1.createDebug)('signalk-server:subscriptionmanager'); // Run a delta through a per-subscription priority engine and return // the filtered delta, or null if every update was emptied. The // engine mutates update.values in place (a clone is made first so // other subscribers/bridges sharing the source delta keep their copy // intact), then anything with no surviving values is dropped. function runPerSubEngine(engine, delta, now, selfContext) { const cloned = { context: delta.context, updates: delta.updates.map((u) => { if ('values' in u && Array.isArray(u.values)) { return { ...u, values: u.values.slice() }; } return { ...u }; }) }; const result = engine(cloned, now, selfContext); if (!result || !result.updates) return null; const surviving = result.updates.filter((u) => { if ('values' in u) { return Array.isArray(u.values) && u.values.length > 0; } // meta-updates and other shapes pass through unchanged return true; }); if (surviving.length === 0) return null; return { ...result, updates: surviving }; } class SubscriptionManager { streambundle; selfContext; app; constructor(app) { this.streambundle = app.streambundle; this.selfContext = app.selfContext; this.app = app; } subscribe(command, unsubscribes, errorCallback, callback, user, sourcePolicy, excludeSources) { const contextFilter = contextMatcher(this.selfContext, this.app, command, errorCallback); // Exclude semantics only make sense under the priority cascade. // Under sourcePolicy='all' the caller has opted into raw fan-out // and partial filtering would be surprising — log and ignore. // Sanitize at the boundary: a WebSocket message or plugin command // can carry empty strings or non-string entries, and an array // length > 0 of garbage shouldn't flip the subscription into // per-engine mode. const sanitizedExcludes = Array.isArray(excludeSources) ? excludeSources.filter((ref) => typeof ref === 'string' && ref.length > 0) : []; const effectiveExcludes = sourcePolicy === 'all' || sanitizedExcludes.length === 0 ? undefined : sanitizedExcludes; if (sourcePolicy === 'all' && sanitizedExcludes.length > 0) { debug("ignoring excludeSources under sourcePolicy:'all' — excludes only apply to 'preferred'"); } // When the caller asks for exclude semantics, route through a // per-subscription priority engine fed from the unfiltered bus // (every source). Without excludes, keep the existing fast paths: // 'preferred' reads from the pre-filtered global bus, 'all' from // the unfiltered bus, neither needs a per-subscription engine. const perSubEngine = effectiveExcludes && this.app.buildSubscriptionEngine ? this.app.buildSubscriptionEngine(effectiveExcludes) : null; const useUnfiltered = sourcePolicy === 'all' || perSubEngine !== null; const buses = useUnfiltered ? this.streambundle.unfilteredBuses : this.streambundle.buses; if (Array.isArray(command.subscribe)) { handleSubscribeRows(this.app, command.subscribe, unsubscribes, buses, contextFilter, callback, errorCallback, user, sourcePolicy, perSubEngine, this.selfContext); // listen to new keys and then use the same logic to check if we // want to subscribe, passing in a map with just that single bus unsubscribes.push(this.streambundle.keys.onValue((path) => { const newBuses = {}; newBuses[path] = useUnfiltered ? this.streambundle.getUnfilteredBus(path) : this.streambundle.getBus(path); handleSubscribeRows(this.app, command.subscribe, unsubscribes, newBuses, contextFilter, callback, errorCallback, user, sourcePolicy, perSubEngine, this.selfContext); })); } // Handle announceNewPaths: announce all paths matching context (once each) // This allows clients with granular subscriptions to discover available paths // without subscribing to everything continuously if (command.announceNewPaths) { const announcedPaths = new Set(); // 1. Announce ALL existing paths matching context (send cached deltas once) // With a per-subscription engine in play, fetch every cached // source (sourcePolicy='all') and re-run the engine so the // bootstrap snapshot honours the exclude mask the same way live // deltas do. Without this, the announce-path replay would leak // the excluded source on its very first emission. const existingDeltas = this.app.deltaCache.getCachedDeltas(contextFilter, user, undefined, perSubEngine ? 'all' : sourcePolicy); if (existingDeltas) { const now = new Date(); existingDeltas.forEach((delta) => { const filtered = perSubEngine ? runPerSubEngine(perSubEngine, delta, now, this.selfContext) : delta; if (!filtered) return; // Track which paths we've announced filtered.updates?.forEach((update) => { update.values?.forEach((vp) => { if (vp.path) { announcedPaths.add(vp.path); } }); }); callback(filtered); }); } // 2. Listen for NEW paths appearing later and announce once unsubscribes.push(this.streambundle.keys.onValue((path) => { if (announcedPaths.has(path)) { return; // Already announced this path } announcedPaths.add(path); // Subscribe to the bus to get the first value for this new path // We can't rely on deltaCache here because it might not have // received the value yet (race condition with keys.onValue) const bus = useUnfiltered ? this.streambundle.getUnfilteredBus(path) : this.streambundle.getBus(path); const unsubscribeBus = bus .filter(contextFilter) .take(1) // Only take the first value .map(streambundle_1.toDelta) .onValue((delta) => { if (perSubEngine) { const filtered = runPerSubEngine(perSubEngine, delta, new Date(), this.selfContext); if (filtered) callback(filtered); } else { callback(delta); } }); // Add to unsubscribes so it gets cleaned up unsubscribes.push(unsubscribeBus); })); } } unsubscribe(msg, unsubscribes) { if (msg.unsubscribe && msg.context === '*' && msg.unsubscribe && msg.unsubscribe.length === 1 && msg.unsubscribe[0].path === '*') { debug('Unsubscribe all'); unsubscribes.forEach((unsubscribe) => unsubscribe()); // clear unsubscribes unsubscribes.length = 0; } else { throw new Error(`Only '{"context":"*","unsubscribe":[{"path":"*"}]}' supported, received ${JSON.stringify(msg)}`); } } } function handleSubscribeRows(app, rows, unsubscribes, buses, filter, callback, errorCallback, user, sourcePolicy, perSubEngine, selfContext) { rows.reduce((acc, subscribeRow) => { if (subscribeRow.path !== undefined) { handleSubscribeRow(app, subscribeRow, unsubscribes, buses, filter, callback, errorCallback, user, sourcePolicy, perSubEngine, selfContext); } return acc; }, unsubscribes); } function handleSubscribeRow(app, subscribeRow, unsubscribes, buses, filter, callback, errorCallback, user, sourcePolicy, perSubEngine, selfContext) { const matcher = pathMatcher(subscribeRow.path); // iterate over all the buses, checking if we want to subscribe to its values (0, lodash_1.forOwn)(buses, (bus, key) => { if (matcher(key)) { debug('Subscribing to key ' + key); let filteredBus = bus.filter(filter); if (subscribeRow.minPeriod) { if (subscribeRow.policy && subscribeRow.policy !== 'instant') { errorCallback(`minPeriod assumes policy 'instant', ignoring policy ${subscribeRow.policy}`); } const minPeriodValue = Number(subscribeRow.minPeriod); debug('minPeriod:' + subscribeRow.minPeriod); if (isNaN(minPeriodValue)) { errorCallback(`invalid minPeriod value '${subscribeRow.minPeriod}', ignoring`); } else if (key !== '') { // we can not apply minPeriod for empty path subscriptions debug('debouncing'); filteredBus = filteredBus.debounceImmediate(minPeriodValue); } } else if (subscribeRow.period || (subscribeRow.policy && subscribeRow.policy === 'fixed')) { if (subscribeRow.policy && subscribeRow.policy !== 'fixed') { errorCallback(`period assumes policy 'fixed', ignoring policy ${subscribeRow.policy}`); } else if (key !== '') { // we can not apply period for empty path subscriptions const interval = Number(subscribeRow.period) || 1000; filteredBus = filteredBus .bufferWithTime(interval) .flatMapLatest((bufferedValues) => { const uniqueValues = (0, lodash_1.default)(bufferedValues) .reverse() .uniqBy((value) => value.context + ':' + value.$source + ':' + value.path) .value(); return Bacon.fromArray(uniqueValues); }); } } if (subscribeRow.format && subscribeRow.format !== 'delta') { errorCallback('Only delta format supported, using it'); } if (subscribeRow.policy && !['instant', 'fixed'].some((s) => s === subscribeRow.policy)) { errorCallback(`Only 'instant' and 'fixed' policies supported, ignoring policy ${subscribeRow.policy}`); } // With a per-subscription priority engine in play, run each // delta through the engine before delivering. The engine carries // the user's saved priority groups/overrides minus the excluded // sources, so the subscriber sees a single priority-resolved // value per path with the cascade respected — the same shape // the global preferred-only bus would deliver, but with the // plugin's own (or otherwise excluded) sources removed from the // candidate set. if (perSubEngine) { const engineStream = filteredBus .map(streambundle_1.toDelta) .flatMap((delta) => { const filtered = runPerSubEngine(perSubEngine, delta, new Date(), selfContext ?? ''); return filtered ? Bacon.once(filtered) : Bacon.never(); }); unsubscribes.push(engineStream.onValue(callback)); } else { unsubscribes.push(filteredBus.map(streambundle_1.toDelta).onValue(callback)); } // Bootstrap snapshot: fetch every source's last cached value // (sourcePolicy='all') when a per-subscription engine owns this // subscription, then replay through the engine so the snapshot // honours the exclude mask. Without the override the snapshot // would carry the global preferred winner — which may BE the // excluded source — and the subscriber would see it once on // startup. const latest = app.deltaCache.getCachedDeltas(filter, user, key, perSubEngine ? 'all' : sourcePolicy); if (latest) { if (perSubEngine) { const now = new Date(); for (const delta of latest) { const filtered = runPerSubEngine(perSubEngine, delta, now, selfContext ?? ''); if (filtered) callback(filtered); } } else { latest.forEach(callback); } } } }); } function pathMatcher(path = '*') { const pattern = path .replace(/[\\^$+?()[\]{}|]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*/g, '.*'); const matcher = new RegExp('^' + pattern + '$'); return (aPath) => matcher.test(aPath); } function contextMatcher(selfContext, app, subscribeCommand, errorCallback) { debug.enabled && debug('subscribeCommand:' + JSON.stringify(subscribeCommand)); if (subscribeCommand.context) { if ((0, lodash_1.isString)(subscribeCommand.context)) { const pattern = subscribeCommand.context .replace(/[\\^$+?()[\]{}|]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*/g, '.*'); const matcher = new RegExp('^' + pattern + '$'); return (normalizedDeltaData) => matcher.test(normalizedDeltaData.context) || ((subscribeCommand.context === 'vessels.self' || subscribeCommand.context === 'self') && normalizedDeltaData.context === selfContext); } else if ('radius' in subscribeCommand.context) { if (!Number.isFinite((0, lodash_1.get)(subscribeCommand.context, 'radius')) || !Number.isFinite((0, lodash_1.get)(subscribeCommand.context, 'position.latitude')) || !Number.isFinite((0, lodash_1.get)(subscribeCommand.context, 'position.longitude'))) { errorCallback('Please specify a radius and position for relativePosition'); return () => false; } return (normalizedDeltaData) => checkPosition(app, subscribeCommand.context, normalizedDeltaData); } } return () => true; } function checkPosition(app, origin, normalizedDelta) { const vessel = (0, lodash_1.get)(app.signalk.root, normalizedDelta.context); const position = (0, lodash_1.get)(vessel, 'navigation.position'); return (position && position.value && Number.isFinite(position.value.latitude) && Number.isFinite(position.value.longitude) && (0, geolib_1.isPointWithinRadius)(position.value, origin.position, origin.radius)); } module.exports = SubscriptionManager; //# sourceMappingURL=subscriptionmanager.js.map