UNPKG

signalk-server

Version:

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

199 lines 7.72 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.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