UNPKG

signalk-server

Version:

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

196 lines 8.04 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.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