signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
359 lines (358 loc) • 17.9 kB
JavaScript
;
/* 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