signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
196 lines • 8.04 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.createRawMetricsClient = createRawMetricsClient;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const debug_1 = require("../debug");
const github_releases_1 = require("./github-releases");
const safe_name_1 = require("./safe-name");
const debug = (0, debug_1.createDebug)('signalk-server:appstore:raw-metrics');
const FETCH_TIMEOUT_MS = 15_000;
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h — matches detail TTL
async function fetchJson(url) {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { Accept: 'application/json' }
});
if (!res.ok) {
debug.enabled && debug('GET %s returned %d', url, res.status);
return undefined;
}
return (await res.json());
}
catch (err) {
debug.enabled && debug('GET %s failed: %O', url, err);
return undefined;
}
}
async function fetchGithubRepo(owner, repo) {
const body = await fetchJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
if (!body)
return undefined;
return {
stars: typeof body.stargazers_count === 'number'
? body.stargazers_count
: undefined
};
}
async function fetchOpenIssueCount(owner, repo) {
// The repo endpoint's open_issues_count includes pull requests. Issues
// search lets us scope to is:issue and gives us the real count.
const q = `repo:${owner}/${repo}+is:issue+is:open`;
// GitHub's search API treats `+` as a literal AND between qualifiers,
// but encodeURIComponent escapes it to `%2B`. Restore the `+`s after
// encoding so the qualifiers stay independent — every other character
// we care about (slash, colon) still gets the percent-encoding it
// needs.
const body = await fetchJson(`https://api.github.com/search/issues?q=${encodeURIComponent(q).replace(/%2B/g, '+')}&per_page=1`);
if (!body)
return undefined;
return typeof body.total_count === 'number' ? body.total_count : undefined;
}
async function fetchContributorCount(owner, repo) {
// Ask for just 1 per page; github returns a Link header we could parse for
// the last-page index. Without auth we may hit rate limits on popular
// repos; we tolerate missing data rather than erroring out.
try {
const res = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contributors?per_page=1&anon=true`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { Accept: 'application/json' }
});
if (!res.ok)
return undefined;
const link = res.headers.get('link');
if (link) {
// GitHub's Link header for paginated lists contains "page=<N>" on the
// rel="last" entry — that's the number of pages which equals the
// number of contributors given per_page=1.
const m = /<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/.exec(link);
if (m) {
const n = parseInt(m[1], 10);
if (!Number.isNaN(n))
return n;
}
}
// No Link header means 0 or 1 contributors — inspect body length.
const body = (await res.json());
return Array.isArray(body) ? body.length : undefined;
}
catch (err) {
debug.enabled && debug('contributors fetch failed: %O', err);
return undefined;
}
}
async function fetchNpmWeeklyDownloads(pkgName) {
const body = await fetchJson(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(pkgName).replace('%40', '@')}`);
return body && typeof body.downloads === 'number' ? body.downloads : undefined;
}
function createRawMetricsClient(cacheDir) {
const dir = path_1.default.join(cacheDir, 'raw-metrics');
function ensureDir() {
try {
fs_1.default.mkdirSync(dir, { recursive: true });
}
catch (err) {
debug.enabled && debug('mkdir %s failed: %O', dir, err);
}
}
function fileFor(pkgName) {
return path_1.default.join(dir, `${(0, safe_name_1.safeName)(pkgName)}.json`);
}
function readDisk(pkgName, expectedSlug) {
try {
const file = fileFor(pkgName);
if (!fs_1.default.existsSync(file))
return undefined;
const entry = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
if (Date.now() - entry.writtenAt > CACHE_TTL_MS)
return undefined;
if (entry.slug !== expectedSlug)
return undefined;
return entry;
}
catch (err) {
debug.enabled && debug('readDisk failed: %O', err);
return undefined;
}
}
function writeDisk(pkgName, payload, slug) {
ensureDir();
try {
fs_1.default.writeFileSync(fileFor(pkgName), JSON.stringify({
writtenAt: Date.now(),
payload,
slug
}), 'utf8');
}
catch (err) {
debug.enabled && debug('writeDisk failed: %O', err);
}
}
return {
async get(pkgName, githubUrl) {
const slug = (0, github_releases_1.parseGithubSlug)(githubUrl);
const slugKey = slug ? `${slug.owner}/${slug.repo}` : undefined;
const disk = readDisk(pkgName, slugKey);
if (disk)
return disk.payload;
const sources = {};
// Kick off all four in parallel. Each independently tolerates
// failure — we assemble whatever we get back.
const [repo, openIssues, contributors, downloads] = await Promise.all([
slug ? fetchGithubRepo(slug.owner, slug.repo) : undefined,
slug ? fetchOpenIssueCount(slug.owner, slug.repo) : undefined,
slug ? fetchContributorCount(slug.owner, slug.repo) : undefined,
fetchNpmWeeklyDownloads(pkgName)
]);
if (repo)
sources.github = true;
if (typeof contributors === 'number')
sources.contributors = true;
if (typeof downloads === 'number')
sources.npm = true;
const sample = {
stars: repo?.stars,
openIssues,
contributors,
downloadsPerWeek: downloads,
sources: sources.github || sources.npm || sources.contributors
? sources
: undefined
};
// Only persist if we actually got something useful. Empty samples
// aren't worth caching and would delay a retry on the next visit.
if (sample.stars !== undefined ||
sample.openIssues !== undefined ||
sample.contributors !== undefined ||
sample.downloadsPerWeek !== undefined) {
writeDisk(pkgName, sample, slugKey);
}
return sample;
},
invalidate() {
try {
if (fs_1.default.existsSync(dir))
fs_1.default.rmSync(dir, { recursive: true, force: true });
}
catch (err) {
debug.enabled && debug('invalidate failed: %O', err);
}
}
};
}
//# sourceMappingURL=raw-metrics.js.map