UNPKG

signalk-server

Version:

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

425 lines 17 kB
"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