signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
425 lines • 17 kB
JavaScript
"use strict";
/*
* Copyright 2026 Signal K contributors
*
* 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
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RegistryPluginDetailSchema = exports.RegistryPluginVersionRunSchema = exports.RegistryIndexSchema = exports.RegistryIndexEntrySchema = exports.RegistryBadgeSchema = void 0;
exports.createRegistryClient = createRegistryClient;
exports.badgesToIndicators = badgesToIndicators;
const typebox_1 = require("@sinclair/typebox");
const value_1 = require("@sinclair/typebox/value");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const debug_1 = require("../debug");
const safe_name_1 = require("./safe-name");
const schemas_1 = require("./schemas");
const debug = (0, debug_1.createDebug)('signalk-server:appstore:registry');
const DEFAULT_REGISTRY_BASE = 'https://signalk.org/signalk-plugin-registry';
const INDEX_TTL_MS = 60 * 60 * 1000; // 1 hour
const PLUGIN_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
const FETCH_TIMEOUT_MS = 20_000;
exports.RegistryBadgeSchema = typebox_1.Type.Union([
typebox_1.Type.Literal('compatible'),
typebox_1.Type.Literal('loads'),
typebox_1.Type.Literal('activates'),
typebox_1.Type.Literal('has-providers'),
typebox_1.Type.Literal('tested'),
typebox_1.Type.Literal('tests-failing'),
typebox_1.Type.Literal('npm-audit-ok'),
typebox_1.Type.Literal('audit-moderate'),
typebox_1.Type.Literal('audit-high'),
typebox_1.Type.Literal('audit-critical'),
typebox_1.Type.Literal('broken'),
typebox_1.Type.String()
]);
exports.RegistryIndexEntrySchema = typebox_1.Type.Object({
name: typebox_1.Type.String(),
version: typebox_1.Type.Optional(typebox_1.Type.String()),
composite_stable: typebox_1.Type.Optional(typebox_1.Type.Number()),
badges_stable: typebox_1.Type.Optional(typebox_1.Type.Array(exports.RegistryBadgeSchema)),
test_status: typebox_1.Type.Optional(typebox_1.Type.String()),
last_tested: typebox_1.Type.Optional(typebox_1.Type.String()),
installs: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
loads: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
activates: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
providers: typebox_1.Type.Optional(typebox_1.Type.Array(typebox_1.Type.String())),
// Upstream metrics published by signalk-plugin-registry >= 0.3.0.
// Fetched nightly with an authenticated GITHUB_TOKEN so individual
// signalk-server installs don't each hit api.github.com's 60/hr
// unauthenticated limit. Any subset may be absent.
stars: typebox_1.Type.Optional(typebox_1.Type.Number()),
open_issues: typebox_1.Type.Optional(typebox_1.Type.Number()),
contributors: typebox_1.Type.Optional(typebox_1.Type.Number()),
downloads_per_week: typebox_1.Type.Optional(typebox_1.Type.Number()),
github_url: typebox_1.Type.Optional(typebox_1.Type.String()),
// plugin-ci matrix published by signalk-plugin-registry >= 0.4.0.
// Same wire shape as PluginCiSchema in src/appstore/schemas.ts.
plugin_ci: typebox_1.Type.Optional(schemas_1.PluginCiSchema)
});
exports.RegistryIndexSchema = typebox_1.Type.Object({
generated: typebox_1.Type.Optional(typebox_1.Type.String()),
server_version: typebox_1.Type.Optional(typebox_1.Type.String()),
plugin_count: typebox_1.Type.Optional(typebox_1.Type.Number()),
plugins: typebox_1.Type.Array(exports.RegistryIndexEntrySchema)
});
exports.RegistryPluginVersionRunSchema = typebox_1.Type.Object({
tested: typebox_1.Type.Optional(typebox_1.Type.String()),
server_version: typebox_1.Type.Optional(typebox_1.Type.String()),
installs: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
loads: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
activates: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
has_schema: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
has_own_tests: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
own_tests_pass: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
tests_runnable: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
has_install_scripts: typebox_1.Type.Optional(typebox_1.Type.Boolean()),
audit_critical: typebox_1.Type.Optional(typebox_1.Type.Number()),
audit_high: typebox_1.Type.Optional(typebox_1.Type.Number()),
audit_moderate: typebox_1.Type.Optional(typebox_1.Type.Number()),
composite: typebox_1.Type.Optional(typebox_1.Type.Number()),
badges: typebox_1.Type.Optional(typebox_1.Type.Array(exports.RegistryBadgeSchema)),
test_status: typebox_1.Type.Optional(typebox_1.Type.String()),
detected_providers: typebox_1.Type.Optional(typebox_1.Type.Array(typebox_1.Type.String())),
unstubbed_accesses: typebox_1.Type.Optional(typebox_1.Type.Array(typebox_1.Type.Unknown()))
});
exports.RegistryPluginDetailSchema = typebox_1.Type.Object({
name: typebox_1.Type.String(),
versions: typebox_1.Type.Record(typebox_1.Type.String(), typebox_1.Type.Record(typebox_1.Type.String(), exports.RegistryPluginVersionRunSchema))
});
// Cache envelope schemas. The on-disk payload is JSON written by us, but
// past releases or partially-written files can leave a malformed or
// stale-shaped record on disk. Validating with the same TypeBox schemas
// the network path uses keeps the boundary check uniform.
const IndexEntrySchema = typebox_1.Type.Object({
writtenAt: typebox_1.Type.Number(),
payload: exports.RegistryIndexSchema
});
const PluginEntrySchema = typebox_1.Type.Object({
writtenAt: typebox_1.Type.Number(),
payload: exports.RegistryPluginDetailSchema
});
async function fetchJson(url, schema, timeoutMs) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
debug.enabled && debug('GET %s returned %d', url, res.status);
return undefined;
}
const body = await res.json();
if (!value_1.Value.Check(schema, body)) {
debug.enabled && debug('GET %s returned unexpected shape', url);
return undefined;
}
return body;
}
catch (err) {
debug.enabled && debug('GET %s failed: %O', url, err);
return undefined;
}
}
function createRegistryClient(options = {}) {
const baseUrl = (options.baseUrl ?? DEFAULT_REGISTRY_BASE).replace(/\/+$/, '');
const indexTtl = options.indexTtlMs ?? INDEX_TTL_MS;
const pluginTtl = options.pluginTtlMs ?? PLUGIN_TTL_MS;
const timeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS;
const cacheDir = options.cacheDir
? path_1.default.join(options.cacheDir, 'registry')
: undefined;
const indexFile = cacheDir ? path_1.default.join(cacheDir, 'index.json') : undefined;
const pluginsDir = cacheDir ? path_1.default.join(cacheDir, 'plugins') : undefined;
function ensureDir(dir) {
try {
fs_1.default.mkdirSync(dir, { recursive: true });
}
catch (err) {
debug.enabled && debug('mkdir %s failed: %O', dir, err);
}
}
function readIndexFromDisk() {
if (!indexFile)
return undefined;
try {
if (!fs_1.default.existsSync(indexFile))
return undefined;
const raw = fs_1.default.readFileSync(indexFile, 'utf8');
const parsed = JSON.parse(raw);
if (!value_1.Value.Check(IndexEntrySchema, parsed)) {
debug.enabled &&
debug('readIndex: cached file failed schema validation');
return undefined;
}
return parsed;
}
catch (err) {
debug.enabled && debug('readIndex failed: %O', err);
return undefined;
}
}
function writeIndexToDisk(payload) {
if (!cacheDir || !indexFile)
return;
ensureDir(cacheDir);
const entry = { writtenAt: Date.now(), payload };
try {
fs_1.default.writeFileSync(indexFile, JSON.stringify(entry), 'utf8');
}
catch (err) {
debug.enabled && debug('writeIndex failed: %O', err);
}
}
function readPluginFromDisk(name) {
if (!pluginsDir)
return undefined;
try {
const file = path_1.default.join(pluginsDir, `${(0, safe_name_1.safeName)(name)}.json`);
if (!fs_1.default.existsSync(file))
return undefined;
const parsed = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
if (!value_1.Value.Check(PluginEntrySchema, parsed)) {
debug.enabled &&
debug('readPlugin %s: cached file failed schema validation', name);
return undefined;
}
return parsed;
}
catch (err) {
debug.enabled && debug('readPlugin failed for %s: %O', name, err);
return undefined;
}
}
function writePluginToDisk(name, payload) {
if (!pluginsDir)
return;
ensureDir(pluginsDir);
const entry = { writtenAt: Date.now(), payload };
try {
fs_1.default.writeFileSync(path_1.default.join(pluginsDir, `${(0, safe_name_1.safeName)(name)}.json`), JSON.stringify(entry), 'utf8');
}
catch (err) {
debug.enabled && debug('writePlugin failed for %s: %O', name, err);
}
}
let indexMemo;
let lookupCache;
// Coalesces concurrent callers when the cache is stale so we don't
// fan out N parallel network fetches under load. One entry per
// plugin for getPlugin(); a single cell for the index.
let indexInFlight;
const pluginInFlight = new Map();
function buildLookup(idx) {
const map = new Map();
for (const entry of idx.plugins) {
map.set(entry.name, entry);
}
return map;
}
return {
async getIndex() {
if (indexMemo && Date.now() - indexMemo.writtenAt < indexTtl) {
return indexMemo.payload;
}
const disk = readIndexFromDisk();
if (disk && Date.now() - disk.writtenAt < indexTtl) {
indexMemo = disk;
lookupCache = buildLookup(disk.payload);
return disk.payload;
}
if (indexInFlight)
return indexInFlight;
indexInFlight = (async () => {
const fresh = await fetchJson(`${baseUrl}/index.json`, exports.RegistryIndexSchema, timeoutMs);
if (fresh) {
indexMemo = { writtenAt: Date.now(), payload: fresh };
lookupCache = buildLookup(fresh);
writeIndexToDisk(fresh);
return fresh;
}
if (disk) {
indexMemo = disk;
lookupCache = buildLookup(disk.payload);
return disk.payload;
}
return undefined;
})();
try {
return await indexInFlight;
}
finally {
indexInFlight = undefined;
}
},
async getIndexEntry(name) {
if (!lookupCache) {
await this.getIndex();
}
return lookupCache?.get(name);
},
async getPlugin(name) {
const disk = readPluginFromDisk(name);
if (disk && Date.now() - disk.writtenAt < pluginTtl) {
return disk.payload;
}
const existing = pluginInFlight.get(name);
if (existing)
return existing;
const promise = (async () => {
const fresh = await fetchJson(`${baseUrl}/plugins/${(0, safe_name_1.safeName)(name)}.json`, exports.RegistryPluginDetailSchema, timeoutMs);
if (fresh) {
writePluginToDisk(name, fresh);
return fresh;
}
return disk?.payload;
})();
pluginInFlight.set(name, promise);
try {
return await promise;
}
finally {
pluginInFlight.delete(name);
}
},
invalidate() {
indexMemo = undefined;
lookupCache = undefined;
indexInFlight = undefined;
pluginInFlight.clear();
if (indexFile && fs_1.default.existsSync(indexFile)) {
try {
fs_1.default.unlinkSync(indexFile);
}
catch (err) {
debug.enabled && debug('invalidate index failed: %O', err);
}
}
if (pluginsDir && fs_1.default.existsSync(pluginsDir)) {
try {
fs_1.default.rmSync(pluginsDir, { recursive: true, force: true });
}
catch (err) {
debug.enabled && debug('invalidate plugins dir failed: %O', err);
}
}
},
baseUrl() {
return baseUrl;
}
};
}
function badgesToIndicators(badges, composite) {
const checks = [];
const set = new Set(badges ?? []);
checks.push({
id: 'compatible',
status: set.has('compatible') ? 'ok' : 'fail',
title: 'Installs successfully',
subtitle: set.has('compatible')
? 'npm install --ignore-scripts succeeded'
: 'Plugin failed to install'
});
checks.push({
id: 'loads',
status: set.has('loads') ? 'ok' : set.has('compatible') ? 'fail' : 'warn',
title: 'Loads',
subtitle: set.has('loads')
? 'Plugin constructor returns a valid object'
: 'Plugin constructor did not return a valid object'
});
checks.push({
id: 'activates',
status: set.has('activates') ? 'ok' : set.has('loads') ? 'fail' : 'warn',
title: 'Activates',
subtitle: set.has('activates')
? 'start() completes with schema defaults'
: 'start() did not complete with schema defaults'
});
if (set.has('tested')) {
checks.push({
id: 'tested',
status: 'ok',
title: 'Plugin test suite',
subtitle: 'Plugin ships its own tests and they pass'
});
}
else if (set.has('tests-failing')) {
checks.push({
id: 'tested',
status: 'fail',
title: 'Plugin test suite',
subtitle: 'Plugin ships tests but they are failing'
});
}
else {
checks.push({
id: 'tested',
status: 'warn',
title: 'Plugin test suite',
subtitle: 'No plugin-level tests provided'
});
}
// The registry can publish multiple audit-* badges per plugin (one
// for each severity level present), so check the most severe first.
// Otherwise a plugin carrying both `audit-moderate` and
// `audit-critical` would have rendered as 'warn' instead of 'fail'.
if (set.has('npm-audit-ok')) {
checks.push({
id: 'audit',
status: 'ok',
title: 'Security audit',
subtitle: 'No npm audit vulnerabilities'
});
}
else if (set.has('audit-critical')) {
checks.push({
id: 'audit',
status: 'fail',
title: 'Security audit',
subtitle: 'Critical vulnerabilities reported'
});
}
else if (set.has('audit-high')) {
checks.push({
id: 'audit',
status: 'warn',
title: 'Security audit',
subtitle: 'High severity vulnerabilities reported'
});
}
else if (set.has('audit-moderate')) {
checks.push({
id: 'audit',
status: 'warn',
title: 'Security audit',
subtitle: 'Moderate vulnerabilities reported'
});
}
else {
checks.push({
id: 'audit',
status: 'warn',
title: 'Security audit',
subtitle: 'Audit status unknown'
});
}
if (set.has('has-providers')) {
checks.push({
id: 'has-providers',
status: 'ok',
title: 'Registers providers',
subtitle: 'Registers one or more Signal K providers (informational)'
});
}
return {
score: typeof composite === 'number' ? composite : 0,
checks
};
}
//# sourceMappingURL=registry.js.map