signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
199 lines • 7.72 kB
JavaScript
;
/*
* 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.createIconProbeCache = createIconProbeCache;
exports.probeIconUrl = probeIconUrl;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const debug_1 = require("../debug");
const cdn_1 = require("./cdn");
const debug = (0, debug_1.createDebug)('signalk-server:appstore:icon-probe');
// When signalk.appIcon points at a path that isn't in the published tarball
// (common when source images live under public/ or assets/ but signalk.appIcon
// is relative to the repo root), try these alternative directories in order.
// Each candidate is ./{dir}/{basename-of-declared-path}.
const ALT_DIRS = ['public', 'assets', 'img', 'docs', 'dist', 'src'];
const HEAD_TIMEOUT_MS = 8_000;
// Hard cap on the cumulative time spent across all candidate URLs for
// a single declared path. With ~13 candidates × 8s timeout each, an
// unreachable CDN could otherwise stall a refresh for ~100s. When the
// budget is exhausted we return null *without* writing a negative
// cache entry, so a transient outage doesn't poison the cache for a
// day; the plugin gets re-probed on the next list-cache cycle. A
// definite resolution failure (every candidate returned non-OK) does
// write null with CACHE_NEG_TTL_MS.
const TOTAL_PROBE_BUDGET_MS = 12_000;
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week for successful resolutions
const CACHE_NEG_TTL_MS = 24 * 60 * 60 * 1000; // 1 day for negative (null) results,
function createIconProbeCache(cacheDir) {
const file = path_1.default.join(cacheDir, 'iconUrls.json');
let memo = {};
let loaded = false;
function load() {
if (loaded)
return;
loaded = true;
try {
if (fs_1.default.existsSync(file)) {
memo = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
}
}
catch (err) {
debug.enabled && debug('iconUrls cache load failed: %O', err);
memo = {};
}
}
function persist() {
try {
fs_1.default.mkdirSync(cacheDir, { recursive: true });
}
catch (err) {
debug.enabled && debug('iconUrls cache mkdir failed: %O', err);
return;
}
// Write to a sibling tempfile then rename so a crash mid-write
// can't leave a half-serialized JSON blob that the next load()
// throws on. rename within the same directory is atomic on POSIX
// and best-effort atomic on Windows.
const tmp = `${file}.${process.pid}.tmp`;
try {
fs_1.default.writeFileSync(tmp, JSON.stringify(memo), 'utf8');
fs_1.default.renameSync(tmp, file);
}
catch (err) {
debug.enabled && debug('iconUrls cache write failed: %O', err);
try {
fs_1.default.rmSync(tmp, { force: true });
}
catch {
/* leave the partial tmpfile alone */
}
}
}
function key(pkg, version, declaredPath) {
// Scoped packages start with '@' (e.g. '@signalk/foo'), so '@' as a
// delimiter is ambiguous. Use NUL, which cannot appear in any
// component (npm names, semver, file paths all reject it).
return `${pkg}\0${version}\0${declaredPath}`;
}
return {
get(pkg, version, declaredPath) {
load();
const entry = memo[key(pkg, version, declaredPath)];
if (!entry)
return undefined;
const ttl = entry.resolved === null ? CACHE_NEG_TTL_MS : CACHE_TTL_MS;
if (Date.now() - entry.probedAt > ttl)
return undefined;
return entry.resolved;
},
set(pkg, version, declaredPath, resolved) {
load();
memo[key(pkg, version, declaredPath)] = {
resolved,
probedAt: Date.now()
};
persist();
},
invalidate() {
memo = {};
loaded = true;
try {
if (fs_1.default.existsSync(file))
fs_1.default.unlinkSync(file);
}
catch (err) {
debug.enabled && debug('iconUrls cache invalidate failed: %O', err);
}
}
};
}
async function headOk(url, timeoutMs) {
try {
const res = await fetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(timeoutMs)
});
return res.ok;
}
catch (err) {
debug.enabled && debug('HEAD %s failed: %O', url, err);
return false;
}
}
function altCandidatesFor(pkg, version, declaredPath) {
const clean = declaredPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
if (!clean)
return [];
const base = clean.split('/').pop() || clean;
const seen = new Set();
const out = [];
// Prefer prefix-preserving candidates (./public/assets/icons/x.png) before
// basename-only candidates (./public/x.png). freeboard-sk needs the former,
// app-dock the latter.
for (const dir of ALT_DIRS) {
for (const tail of [clean, base]) {
const url = (0, cdn_1.resolveScreenshotUrl)(pkg, version, `./${dir}/${tail}`);
if (seen.has(url))
continue;
seen.add(url);
out.push(url);
}
}
return out;
}
/**
* Given a plugin package@version and a declared signalk.* relative path,
* return a resolved CDN URL that is actually reachable.
*
* If the declared path HEADs 200, returns that URL.
* Otherwise tries a small list of alternative directories (./public,
* ./assets, ./img, ./docs, ./dist, ./src) with the basename of the
* declared path. Returns the first URL that responds 200, or null when
* nothing works.
*
* Absolute URLs (http, https, data:) are passed through untouched and
* are NOT probed (trusted as-declared).
*/
async function probeIconUrl(pkg, version, declaredPath, cache) {
if ((0, cdn_1.isAbsoluteUrl)(declaredPath))
return declaredPath;
const cached = cache.get(pkg, version, declaredPath);
if (cached !== undefined)
return cached;
const startedAt = Date.now();
const remaining = () => TOTAL_PROBE_BUDGET_MS - (Date.now() - startedAt);
const headWithBudget = (url) => headOk(url, Math.max(1, Math.min(HEAD_TIMEOUT_MS, remaining())));
const primary = (0, cdn_1.resolveScreenshotUrl)(pkg, version, declaredPath);
if (await headWithBudget(primary)) {
cache.set(pkg, version, declaredPath, primary);
return primary;
}
for (const candidate of altCandidatesFor(pkg, version, declaredPath)) {
if (candidate === primary)
continue;
if (remaining() <= 0) {
// Don't burn the cache slot — leaving the entry undefined lets
// the next list-refresh retry once the CDN stops timing out.
return null;
}
if (await headWithBudget(candidate)) {
cache.set(pkg, version, declaredPath, candidate);
return candidate;
}
}
cache.set(pkg, version, declaredPath, null);
return null;
}
//# sourceMappingURL=icon-probe.js.map