diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-08 15:58:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-08 15:58:14 +0300 |
| commit | ffa0892ef87d927eef78fe8068de4d90201fb20b (patch) | |
| tree | 8292dc70710aa4caa3a29b93a676524d415fb486 /workers/plugin-marketplace/src/index.ts | |
| parent | 9e01168b140ce8e3821131345dc82bc2bf9994eb (diff) | |
chore: move marketplace worker to website repository
Diffstat (limited to 'workers/plugin-marketplace/src/index.ts')
| -rw-r--r-- | workers/plugin-marketplace/src/index.ts | 1269 |
1 files changed, 0 insertions, 1269 deletions
diff --git a/workers/plugin-marketplace/src/index.ts b/workers/plugin-marketplace/src/index.ts deleted file mode 100644 index 2e5b9dfb..00000000 --- a/workers/plugin-marketplace/src/index.ts +++ /dev/null @@ -1,1269 +0,0 @@ -import { parse } from "smol-toml"; - -const SNAPSHOT_KEY = "plugins/index.json"; -const SCAN_CACHE_KEY = "plugins/scan-cache.json"; -const SNAPSHOT_CACHE_CONTROL = "public, max-age=300, s-maxage=1800, stale-while-revalidate=3600"; -const GITHUB_QUERY = "topic:herdr-plugin is:public"; -const GITHUB_API_VERSION = "2022-11-28"; -const GITHUB_API_URL = "https://api.github.com"; -const GITHUB_SEARCH_URL = `${GITHUB_API_URL}/search/repositories`; -const GITHUB_GRAPHQL_URL = `${GITHUB_API_URL}/graphql`; -const BLACKLIST_REPO_KEY_PREFIX = "repo:"; -const PLUGIN_MANIFEST_FILE = "herdr-plugin.toml"; -const PER_PAGE = 100; -const MAX_REPOS = 1000; -const GRAPHQL_BATCH_SIZE = 50; -const NETWORK_CONCURRENCY = 5; -const MANIFEST_MAX_BYTES = 32 * 1024; -const MAX_TREE_ENTRIES = 20_000; -const MAX_MANIFESTS_PER_REPOSITORY = 100; -const MAX_TOTAL_MANIFESTS = 5000; -const MAX_TOTAL_MANIFEST_BYTES = 16 * 1024 * 1024; -const PLUGIN_NAME_MAX_CHARS = 120; -const PLUGIN_VERSION_MAX_CHARS = 64; -const PLUGIN_DESCRIPTION_MAX_CHARS = 500; -const REQUEST_TIMEOUT_MS = 10_000; -const SCAN_CACHE_SCHEMA_VERSION = 1; -const STAR_HISTORY_KEY = "plugins/star-history.json"; -const STAR_HISTORY_BACKUP_KEY_PREFIX = "backups/star-history/"; -const STAR_HISTORY_SCHEMA_VERSION = 1; -const STAR_HISTORY_MAX_AGE_DAYS = 60; -const STAR_BASELINE_TOLERANCE_DAYS = 2; -const DAY_MS = 86_400_000; - -const PLUGIN_PLATFORMS = new Set(["linux", "macos", "windows"]); -const REGULAR_BLOB_MODES = new Set(["100644", "100755"]); - -type R2Bucket = { - put( - key: string, - value: string, - options?: { - httpMetadata?: { - contentType?: string; - cacheControl?: string; - }; - }, - ): Promise<unknown>; - get(key: string): Promise<{ text(): Promise<string> } | null>; -}; - -type KVNamespace = { - list(options?: { prefix?: string; cursor?: string }): Promise<{ - keys: Array<{ name: string }>; - cursor?: string; - }>; -}; - -type ExecutionContext = { - waitUntil(promise: Promise<unknown>): void; -}; - -type ScheduledController = unknown; - -export type Env = { - PLUGIN_MARKETPLACE_BUCKET: R2Bucket; - PLUGIN_MARKETPLACE_BACKUP_BUCKET: R2Bucket; - PLUGIN_MARKETPLACE_BLACKLIST?: KVNamespace; - GITHUB_TOKEN?: string; -}; - -type FetchLike = typeof fetch; - -type RefreshOptions = { - fetch?: FetchLike; - now?: Date; - logger?: Pick<Console, "error">; -}; - -type GitHubRepository = Record<string, unknown>; - -type RepositoryListing = { - id: number; - fullName: string; - owner: string; - name: string; - description: string | null; - url: string; - stars: number; - forks: number; - openIssues: number; - language: string | null; - topics: string[]; - createdAt: string | null; - updatedAt: string | null; - pushedAt: string | null; - defaultBranch: string; -}; - -export type PluginManifestListing = { - path: string; - id: string; - name: string; - version: string; - minHerdrVersion: string; - description: string | null; - platforms: string[] | null; -}; - -export type PluginListing = Omit<RepositoryListing, "defaultBranch"> & { - headCommit: string; - firstSeenAt: string; - starsDelta7d: number | null; - starsDelta30d: number | null; - manifests: PluginManifestListing[]; -}; - -export type StarHistorySample = { - date: string; - stars: number; -}; - -export type StarHistoryEntry = { - repositoryId: number; - fullName: string; - firstSeenAt: string; - samples: StarHistorySample[]; -}; - -export type StarHistory = { - schemaVersion: 1; - entries: StarHistoryEntry[]; -}; - -export type PluginSnapshot = { - schemaVersion: 1; - generatedAt: string; - pluginCount: number; - repositoryCount: number; - source: { - provider: "github"; - query: string; - totalCount: number; - collectedCount: number; - blacklistedCount: number; - missingManifestCount: number; - invalidManifestCount: number; - duplicateManifestCount: number; - skippedRepositoryCount: number; - unavailableRepositoryCount: number; - truncated: boolean; - warnings?: string[]; - }; - plugins: PluginListing[]; -}; - -type ScanCacheEntry = { - repositoryId: number; - fullName: string; - defaultBranch: string; - pushedAt: string | null; - headCommit: string; - manifestFileCount: number; - invalidManifestCount: number; - duplicateManifestCount: number; - warning: string | null; - retryable: boolean; - manifests: PluginManifestListing[]; -}; - -type ScanCache = { - schemaVersion: 1; - entries: ScanCacheEntry[]; -}; - -type ResolvedRepository = { - repository: RepositoryListing; - headCommit: string; - cached: ScanCacheEntry | null; -}; - -type TreeManifestCandidate = { - repository: RepositoryListing; - headCommit: string; - path: string; - size: number; -}; - -type PendingRepositoryScan = { - resolved: ResolvedRepository; - manifestFileCount: number; - invalidManifestCount: number; - warning: string | null; - retryable: boolean; - candidates: TreeManifestCandidate[]; -}; - -type GitHubTreeEntry = { - path: string; - mode: string; - type: string; - size: number | null; -}; - -export type RefreshResult = - | { ok: true; snapshot: PluginSnapshot } - | { ok: false; error: string }; - -export default { - fetch(): Response { - return jsonResponse({ error: "Not found" }, 404, "no-store"); - }, - - scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): void { - ctx.waitUntil(refreshPlugins(env)); - }, -}; - -export async function refreshPlugins( - env: Env, - options: RefreshOptions = {}, -): Promise<RefreshResult> { - const logger = options.logger ?? console; - try { - const token = env.GITHUB_TOKEN?.trim(); - if (!token) { - throw new Error("GITHUB_TOKEN is not configured"); - } - - const fetchFn = options.fetch ?? fetch; - const search = await fetchGitHubRepositories(fetchFn, token); - const repositories = normalizeRepositories(search.repositories); - const blockedRepositories = await readBlacklistedRepositories(env); - const candidates = repositories.filter( - (repository) => !blockedRepositories.has(repository.fullName.toLowerCase()), - ); - const blacklistedCount = repositories.length - candidates.length; - const cache = await readScanCache(env.PLUGIN_MARKETPLACE_BUCKET, logger); - if (cache.entries.length > 0 && repositories.length * 2 < cache.entries.length) { - throw new Error( - `GitHub repository inventory collapsed from ${cache.entries.length} to ${repositories.length}; remove ${SCAN_CACHE_KEY} to accept an intentional reset`, - ); - } - const cachedById = new Map(cache.entries.map((entry) => [entry.repositoryId, entry])); - - const { resolved, unavailableRepositoryCount } = await resolveRepositoryHeads( - fetchFn, - token, - candidates, - cachedById, - ); - const resolvedIds = new Set(resolved.map(({ repository }) => repository.id)); - const missingCachedRepository = candidates.find( - (repository) => cachedById.has(repository.id) && !resolvedIds.has(repository.id), - ); - if ( - missingCachedRepository || - (candidates.length > 0 && resolved.length * 2 < candidates.length) - ) { - throw new Error( - `GitHub head resolution omitted marketplace repositories${missingCachedRepository ? ` including ${missingCachedRepository.fullName}` : ""}`, - ); - } - const changed = resolved.filter( - ({ headCommit, cached }) => !cached || cached.headCommit !== headCommit || cached.retryable, - ); - const scannedById = await scanChangedRepositories(fetchFn, token, changed); - - const nextEntries = resolved.map(({ repository, headCommit, cached }) => { - const scanned = scannedById.get(repository.id); - if (scanned) return scanned; - if (!cached || cached.headCommit !== headCommit) { - throw new Error(`missing marketplace scan result for ${repository.fullName}`); - } - return { - ...cached, - fullName: repository.fullName, - defaultBranch: repository.defaultBranch, - pushedAt: repository.pushedAt, - }; - }); - const entriesById = new Map(nextEntries.map((entry) => [entry.repositoryId, entry])); - const cards = resolved.flatMap(({ repository }) => { - const entry = entriesById.get(repository.id); - if (!entry || entry.manifests.length === 0) return []; - const { defaultBranch: _defaultBranch, ...card } = repository; - return [{ ...card, headCommit: entry.headCommit, manifests: entry.manifests }]; - }); - - const now = options.now ?? new Date(); - // History updates are read-modify-write without a conditional put. - // Overlapping runs are rare (30-minute cron, sub-minute refreshes) and the - // worst case is a daily sample recording a slightly later observation, so - // the race is accepted instead of building compare-and-swap on R2. - const history = await readStarHistory(env.PLUGIN_MARKETPLACE_BUCKET); - const nextHistory = updateStarHistory(history, cards, now); - const historyById = new Map(nextHistory.entries.map((entry) => [entry.repositoryId, entry])); - const plugins: PluginListing[] = cards.map((card) => { - const entry = historyById.get(card.id); - return { - ...card, - firstSeenAt: entry?.firstSeenAt ?? now.toISOString(), - starsDelta7d: entry ? starsDeltaSince(entry, card.stars, now, 7) : null, - starsDelta30d: entry ? starsDeltaSince(entry, card.stars, now, 30) : null, - }; - }); - - const pluginCount = plugins.reduce((total, plugin) => total + plugin.manifests.length, 0); - const warnings = nextEntries.flatMap((entry) => (entry.warning ? [entry.warning] : [])); - if (search.truncated) { - warnings.unshift( - `GitHub returned ${search.totalCount} results; only the first ${search.repositories.length} were collected.`, - ); - } - const generatedAt = now.toISOString(); - const snapshot: PluginSnapshot = { - schemaVersion: 1, - generatedAt, - pluginCount, - repositoryCount: plugins.length, - source: { - provider: "github", - query: GITHUB_QUERY, - totalCount: search.totalCount, - collectedCount: search.repositories.length, - blacklistedCount, - missingManifestCount: nextEntries.filter( - (entry) => entry.manifestFileCount === 0 && !entry.warning, - ).length, - invalidManifestCount: nextEntries.reduce( - (total, entry) => total + entry.invalidManifestCount, - 0, - ), - duplicateManifestCount: nextEntries.reduce( - (total, entry) => total + entry.duplicateManifestCount, - 0, - ), - skippedRepositoryCount: nextEntries.filter((entry) => entry.warning).length, - unavailableRepositoryCount, - truncated: search.truncated, - ...(warnings.length > 0 ? { warnings } : {}), - }, - plugins, - }; - - const nextCache: ScanCache = { schemaVersion: SCAN_CACHE_SCHEMA_VERSION, entries: nextEntries }; - // Star history is the only object that cannot be rebuilt, so the first run - // of each UTC day copies it to a dated key in the private backup bucket. - // The dated key's existence marks the day as backed up, which stays correct - // when history is empty or a repository joins mid-day. Backups are kept - // forever; the backup put runs before the primary puts so a failure leaves - // the primary untouched and the next run retries the same dated key. The - // get-then-put pair is not atomic; like the history read-modify-write - // above, overlapping runs are accepted because the worst case is the dated - // backup holding an observation from seconds later. - const backupKey = `${STAR_HISTORY_BACKUP_KEY_PREFIX}${isoDate(now)}.json`; - if ((await env.PLUGIN_MARKETPLACE_BACKUP_BUCKET.get(backupKey)) === null) { - await env.PLUGIN_MARKETPLACE_BACKUP_BUCKET.put(backupKey, JSON.stringify(nextHistory), { - httpMetadata: { - contentType: "application/json; charset=utf-8", - cacheControl: "no-store", - }, - }); - } - await env.PLUGIN_MARKETPLACE_BUCKET.put(STAR_HISTORY_KEY, JSON.stringify(nextHistory), { - httpMetadata: { - contentType: "application/json; charset=utf-8", - cacheControl: "no-store", - }, - }); - await env.PLUGIN_MARKETPLACE_BUCKET.put(SCAN_CACHE_KEY, JSON.stringify(nextCache), { - httpMetadata: { - contentType: "application/json; charset=utf-8", - cacheControl: "no-store", - }, - }); - await env.PLUGIN_MARKETPLACE_BUCKET.put(SNAPSHOT_KEY, JSON.stringify(snapshot), { - httpMetadata: { - contentType: "application/json; charset=utf-8", - cacheControl: SNAPSHOT_CACHE_CONTROL, - }, - }); - return { ok: true, snapshot }; - } catch (error) { - const message = error instanceof Error ? error.message : "unknown refresh error"; - logger.error(`plugin marketplace refresh failed: ${message}`); - return { ok: false, error: message }; - } -} - -async function fetchGitHubRepositories( - fetchFn: FetchLike, - token: string, - timeoutMs = REQUEST_TIMEOUT_MS, -): Promise<{ repositories: GitHubRepository[]; totalCount: number; truncated: boolean }> { - const repositories: GitHubRepository[] = []; - let totalCount = 0; - - for (let page = 1; repositories.length < MAX_REPOS; page += 1) { - const url = new URL(GITHUB_SEARCH_URL); - url.searchParams.set("q", GITHUB_QUERY); - url.searchParams.set("per_page", String(PER_PAGE)); - url.searchParams.set("page", String(page)); - url.searchParams.set("sort", "stars"); - url.searchParams.set("order", "desc"); - - const response = await fetchWithTimeout(fetchFn, url, githubRequestInit(token), timeoutMs); - if (!response.ok) { - throw new Error(`GitHub search failed with status ${response.status}`); - } - const body = await response.json(); - if (!isObject(body) || typeof body.total_count !== "number" || !Array.isArray(body.items)) { - throw new Error("GitHub search returned malformed JSON"); - } - if (body.incomplete_results === true) { - throw new Error("GitHub search returned incomplete results"); - } - - totalCount = body.total_count; - repositories.push(...body.items.slice(0, MAX_REPOS - repositories.length)); - if (repositories.length >= totalCount || body.items.length === 0) break; - } - - return { - repositories, - totalCount, - truncated: totalCount > repositories.length, - }; -} - -async function resolveRepositoryHeads( - fetchFn: FetchLike, - token: string, - repositories: RepositoryListing[], - cachedById: Map<number, ScanCacheEntry>, -): Promise<{ resolved: ResolvedRepository[]; unavailableRepositoryCount: number }> { - const resolvedById = new Map<number, ResolvedRepository>(); - const needResolution: RepositoryListing[] = []; - - for (const repository of repositories) { - const cached = cachedById.get(repository.id) ?? null; - if ( - cached && - cached.fullName.toLowerCase() === repository.fullName.toLowerCase() && - cached.defaultBranch === repository.defaultBranch && - !cached.retryable && - cached.pushedAt !== null && - cached.pushedAt === repository.pushedAt - ) { - resolvedById.set(repository.id, { repository, headCommit: cached.headCommit, cached }); - } else { - needResolution.push(repository); - } - } - - const batches = chunk(needResolution, GRAPHQL_BATCH_SIZE); - const results = await mapConcurrent(batches, NETWORK_CONCURRENCY, async (batch) => { - const data = await fetchGraphqlData(fetchFn, token, repositoryHeadQuery(batch)); - return batch.flatMap((repository, index) => { - const raw = data[`repo${index}`]; - if (!isObject(raw)) return []; - const branch = isObject(raw.defaultBranchRef) ? readString(raw.defaultBranchRef.name) : null; - const target = isObject(raw.defaultBranchRef?.target) ? raw.defaultBranchRef.target : null; - const headCommit = isObject(target) ? readString(target.oid) : null; - if ( - branch !== repository.defaultBranch || - !headCommit || - !/^[0-9a-f]{40,64}$/i.test(headCommit) - ) { - return []; - } - return [{ repository, headCommit, cached: cachedById.get(repository.id) ?? null }]; - }); - }); - for (const resolved of results.flat()) { - resolvedById.set(resolved.repository.id, resolved); - } - - return { - resolved: repositories.flatMap((repository) => { - const value = resolvedById.get(repository.id); - return value ? [value] : []; - }), - unavailableRepositoryCount: repositories.length - resolvedById.size, - }; -} - -async function scanChangedRepositories( - fetchFn: FetchLike, - token: string, - repositories: ResolvedRepository[], -): Promise<Map<number, ScanCacheEntry>> { - const pending = await mapConcurrent(repositories, NETWORK_CONCURRENCY, (resolved) => - fetchRepositoryTree(fetchFn, token, resolved), - ); - const candidates: TreeManifestCandidate[] = []; - let candidateBytes = 0; - for (const scan of pending) { - const scanBytes = scan.candidates.reduce((total, candidate) => total + candidate.size, 0); - if ( - candidates.length + scan.candidates.length > MAX_TOTAL_MANIFESTS || - candidateBytes + scanBytes > MAX_TOTAL_MANIFEST_BYTES - ) { - scan.warning = `${scan.resolved.repository.fullName} was skipped because the marketplace scan budget was exhausted.`; - scan.retryable = true; - scan.candidates = []; - continue; - } - candidates.push(...scan.candidates); - candidateBytes += scanBytes; - } - const contents = await fetchManifestContents(fetchFn, token, candidates); - const entries = new Map<number, ScanCacheEntry>(); - - for (const scan of pending) { - const manifests: PluginManifestListing[] = []; - let invalidManifestCount = scan.invalidManifestCount; - for (const candidate of scan.candidates) { - const content = contents.get(manifestCandidateKey(candidate)); - const metadata = typeof content === "string" ? parseManifestSummary(content) : null; - if (!metadata) { - invalidManifestCount += 1; - continue; - } - manifests.push({ path: candidate.path, ...metadata }); - } - const { manifests: uniqueManifests, duplicateCount } = deduplicateManifests(manifests); - const { repository, headCommit } = scan.resolved; - entries.set(repository.id, { - repositoryId: repository.id, - fullName: repository.fullName, - defaultBranch: repository.defaultBranch, - pushedAt: repository.pushedAt, - headCommit, - manifestFileCount: scan.manifestFileCount, - invalidManifestCount, - duplicateManifestCount: duplicateCount, - warning: scan.warning, - retryable: scan.retryable, - manifests: uniqueManifests, - }); - } - return entries; -} - -async function fetchRepositoryTree( - fetchFn: FetchLike, - token: string, - resolved: ResolvedRepository, -): Promise<PendingRepositoryScan> { - const { repository, headCommit } = resolved; - const url = new URL( - `${GITHUB_API_URL}/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.name)}/git/trees/${headCommit}`, - ); - url.searchParams.set("recursive", "1"); - const response = await fetchWithTimeout(fetchFn, url, githubRequestInit(token), REQUEST_TIMEOUT_MS); - if (!response.ok) { - throw new Error(`GitHub tree fetch failed for ${repository.fullName} with status ${response.status}`); - } - const body = await response.json(); - if (!isObject(body) || !Array.isArray(body.tree)) { - throw new Error(`GitHub tree fetch returned malformed JSON for ${repository.fullName}`); - } - if (body.truncated === true || body.tree.length > MAX_TREE_ENTRIES) { - return skippedRepositoryScan(resolved, `${repository.fullName} was skipped because its Git tree is too large.`); - } - - const manifestEntries = body.tree - .map(normalizeTreeEntry) - .filter((entry): entry is GitHubTreeEntry => entry !== null) - .filter( - (entry) => - isDiscoverableManifestPath(entry.path) && - entry.type === "blob" && - REGULAR_BLOB_MODES.has(entry.mode), - ); - if (manifestEntries.length > MAX_MANIFESTS_PER_REPOSITORY) { - return skippedRepositoryScan( - resolved, - `${repository.fullName} was skipped because it contains more than ${MAX_MANIFESTS_PER_REPOSITORY} plugin manifests.`, - ); - } - - let invalidManifestCount = 0; - const candidates: TreeManifestCandidate[] = []; - for (const entry of manifestEntries) { - if ( - entry.size === null || - entry.size < 0 || - entry.size > MANIFEST_MAX_BYTES - ) { - invalidManifestCount += 1; - continue; - } - candidates.push({ repository, headCommit, path: entry.path, size: entry.size }); - } - - return { - resolved, - manifestFileCount: manifestEntries.length, - invalidManifestCount, - warning: null, - retryable: false, - candidates, - }; -} - -function skippedRepositoryScan( - resolved: ResolvedRepository, - warning: string, -): PendingRepositoryScan { - return { - resolved, - manifestFileCount: 0, - invalidManifestCount: 0, - warning, - retryable: false, - candidates: [], - }; -} - -async function fetchManifestContents( - fetchFn: FetchLike, - token: string, - candidates: TreeManifestCandidate[], -): Promise<Map<string, string | null>> { - const batches = chunk(candidates, GRAPHQL_BATCH_SIZE); - const results = await mapConcurrent(batches, NETWORK_CONCURRENCY, async (batch) => { - const data = await fetchGraphqlData(fetchFn, token, manifestContentQuery(batch)); - return batch.flatMap((candidate, index) => { - const raw = data[`item${index}`]; - const manifest = isObject(raw) && isObject(raw.manifest) ? raw.manifest : null; - if (!manifest) { - throw new Error( - `GitHub manifest fetch omitted ${candidate.repository.fullName}:${candidate.path}`, - ); - } - if (manifest.isBinary === true) { - return [[manifestCandidateKey(candidate), null] as const]; - } - if (typeof manifest.text !== "string") { - throw new Error( - `GitHub manifest fetch returned malformed data for ${candidate.repository.fullName}:${candidate.path}`, - ); - } - return [[manifestCandidateKey(candidate), manifest.text] as const]; - }); - }); - return new Map(results.flat()); -} - -async function fetchGraphqlData( - fetchFn: FetchLike, - token: string, - query: string, -): Promise<Record<string, unknown>> { - const response = await fetchWithTimeout( - fetchFn, - new URL(GITHUB_GRAPHQL_URL), - { - ...githubRequestInit(token), - method: "POST", - body: JSON.stringify({ query }), - }, - REQUEST_TIMEOUT_MS, - ); - if (!response.ok) { - throw new Error(`GitHub marketplace query failed with status ${response.status}`); - } - const body = await response.json(); - if (!isObject(body) || !isObject(body.data)) { - throw new Error("GitHub marketplace query returned malformed JSON"); - } - if ( - Array.isArray(body.errors) && - body.errors.some((error) => !isMissingAliasGraphqlError(error)) - ) { - throw new Error("GitHub marketplace query returned GraphQL errors"); - } - return body.data; -} - -function isMissingAliasGraphqlError(error: unknown): boolean { - if (!isObject(error) || error.type !== "NOT_FOUND" || !Array.isArray(error.path)) { - return false; - } - const alias = error.path[0]; - return typeof alias === "string" && /^(repo|item)\d+$/.test(alias); -} - -function repositoryHeadQuery(repositories: RepositoryListing[]): string { - const selections = repositories.map((repository, index) => { - const owner = JSON.stringify(repository.owner); - const name = JSON.stringify(repository.name); - return `repo${index}: repository(owner: ${owner}, name: ${name}) { - defaultBranchRef { name target { ... on Commit { oid } } } - }`; - }); - return `query PluginMarketplaceHeads {\n${selections.join("\n")}\n}`; -} - -function manifestContentQuery(candidates: TreeManifestCandidate[]): string { - const selections = candidates.map((candidate, index) => { - const owner = JSON.stringify(candidate.repository.owner); - const name = JSON.stringify(candidate.repository.name); - const expression = JSON.stringify(`${candidate.headCommit}:${candidate.path}`); - return `item${index}: repository(owner: ${owner}, name: ${name}) { - manifest: object(expression: ${expression}) { ... on Blob { text isBinary } } - }`; - }); - return `query PluginMarketplaceManifests {\n${selections.join("\n")}\n}`; -} - -export function parseManifestSummary(manifestText: string): Omit<PluginManifestListing, "path"> | null { - try { - const manifest = parse(manifestText.replace(/^\uFEFF/, "")); - if (!isObject(manifest)) return null; - const id = readTrimmedString(manifest.id); - const name = readTrimmedString(manifest.name); - const version = readTrimmedString(manifest.version); - const minHerdrVersion = readTrimmedString(manifest.min_herdr_version); - if ( - !id || - id.length > 120 || - !/^[A-Za-z0-9:._-]+$/.test(id) || - !name || - name.length > PLUGIN_NAME_MAX_CHARS || - !version || - version.length > PLUGIN_VERSION_MAX_CHARS || - !minHerdrVersion || - minHerdrVersion.length > PLUGIN_VERSION_MAX_CHARS || - !isHerdrVersion(minHerdrVersion) - ) { - return null; - } - - let description: string | null = null; - if (manifest.description !== undefined) { - if (typeof manifest.description !== "string") return null; - description = manifest.description.trim() || null; - if (description && description.length > PLUGIN_DESCRIPTION_MAX_CHARS) return null; - } - - let platforms: string[] | null = null; - if (manifest.platforms !== undefined) { - if ( - !Array.isArray(manifest.platforms) || - manifest.platforms.length === 0 || - manifest.platforms.length > PLUGIN_PLATFORMS.size || - !manifest.platforms.every( - (platform) => typeof platform === "string" && PLUGIN_PLATFORMS.has(platform), - ) - ) { - return null; - } - platforms = [...manifest.platforms]; - if (new Set(platforms).size !== platforms.length) return null; - } - - return { id, name, version, minHerdrVersion, description, platforms }; - } catch { - return null; - } -} - -export function updateStarHistory( - history: StarHistory, - cards: Array<{ id: number; fullName: string; stars: number }>, - now: Date, -): StarHistory { - const today = isoDate(now); - const oldestKeptMs = now.getTime() - STAR_HISTORY_MAX_AGE_DAYS * DAY_MS; - const entriesById = new Map(history.entries.map((entry) => [entry.repositoryId, entry])); - const nextEntries = new Map<number, StarHistoryEntry>(); - - for (const card of cards) { - const existing = entriesById.get(card.id); - const samples = (existing?.samples ?? []).filter( - (sample) => dateMs(sample.date) >= oldestKeptMs, - ); - // One sample per UTC day, first observation wins, so intraday cron runs - // keep a stable baseline for delta computation. - if (samples[samples.length - 1]?.date !== today) { - samples.push({ date: today, stars: card.stars }); - } - nextEntries.set(card.id, { - repositoryId: card.id, - fullName: card.fullName, - firstSeenAt: existing?.firstSeenAt ?? now.toISOString(), - samples, - }); - } - - // Keep history for repositories that dropped out of the snapshot (renamed, - // temporarily invalid manifest, blacklist experiments) so they rejoin with - // their record intact, until their samples age out. - for (const entry of history.entries) { - if (nextEntries.has(entry.repositoryId)) continue; - const samples = entry.samples.filter((sample) => dateMs(sample.date) >= oldestKeptMs); - if (samples.length > 0) { - nextEntries.set(entry.repositoryId, { ...entry, samples }); - } - } - - return { - schemaVersion: STAR_HISTORY_SCHEMA_VERSION, - entries: [...nextEntries.values()].sort((a, b) => a.repositoryId - b.repositoryId), - }; -} - -export function starsDeltaSince( - entry: StarHistoryEntry, - currentStars: number, - now: Date, - windowDays: number, -): number | null { - // The baseline must sit close to the window boundary. A repository that was - // delisted for weeks would otherwise report months of growth as a short - // window and dominate trending rankings. - const cutoffMs = now.getTime() - windowDays * DAY_MS; - const oldestAcceptedMs = cutoffMs - STAR_BASELINE_TOLERANCE_DAYS * DAY_MS; - let baseline: StarHistorySample | null = null; - for (const sample of entry.samples) { - const sampleMs = dateMs(sample.date); - if (sampleMs > cutoffMs) break; - if (sampleMs >= oldestAcceptedMs) baseline = sample; - } - return baseline ? currentStars - baseline.stars : null; -} - -// Unlike the scan cache, star history cannot be rebuilt from GitHub, so any -// invalid state fails the refresh instead of being discarded and overwritten. -async function readStarHistory(bucket: R2Bucket): Promise<StarHistory> { - const object = await bucket.get(STAR_HISTORY_KEY); - if (!object) return { schemaVersion: STAR_HISTORY_SCHEMA_VERSION, entries: [] }; - const invalid = (reason: string): Error => - new Error( - `invalid plugin marketplace star history (${reason}); remove ${STAR_HISTORY_KEY} to accept losing recorded history`, - ); - let value: unknown; - try { - value = JSON.parse(await object.text()); - } catch { - throw invalid("malformed JSON"); - } - if ( - !isObject(value) || - value.schemaVersion !== STAR_HISTORY_SCHEMA_VERSION || - !Array.isArray(value.entries) - ) { - throw invalid("unsupported shape"); - } - const entries = value.entries.map(readStarHistoryEntry); - if (entries.some((entry) => entry === null)) { - throw invalid("invalid entry"); - } - return { schemaVersion: STAR_HISTORY_SCHEMA_VERSION, entries: entries as StarHistoryEntry[] }; -} - -function readStarHistoryEntry(value: unknown): StarHistoryEntry | null { - if (!isObject(value) || !Array.isArray(value.samples)) return null; - const repositoryId = readInteger(value.repositoryId); - const fullName = readString(value.fullName); - const firstSeenAt = readIsoString(value.firstSeenAt); - const samples = value.samples.map(readStarHistorySample); - if ( - repositoryId === null || - repositoryId <= 0 || - !fullName || - !firstSeenAt || - samples.some((sample) => sample === null) - ) { - return null; - } - return { - repositoryId, - fullName, - firstSeenAt, - samples: (samples as StarHistorySample[]).sort((a, b) => a.date.localeCompare(b.date)), - }; -} - -function readStarHistorySample(value: unknown): StarHistorySample | null { - if (!isObject(value)) return null; - const date = readString(value.date); - const stars = readNonNegativeIntegerOrNull(value.stars); - if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date)) || stars === null) { - return null; - } - return { date, stars }; -} - -function isoDate(now: Date): string { - return now.toISOString().slice(0, 10); -} - -async function readScanCache( - bucket: R2Bucket, - logger: Pick<Console, "error">, -): Promise<ScanCache> { - const empty = (): ScanCache => ({ schemaVersion: SCAN_CACHE_SCHEMA_VERSION, entries: [] }); - const object = await bucket.get(SCAN_CACHE_KEY); - if (!object) return empty(); - try { - const value: unknown = JSON.parse(await object.text()); - if ( - !isObject(value) || - value.schemaVersion !== SCAN_CACHE_SCHEMA_VERSION || - !Array.isArray(value.entries) - ) { - throw new Error("unsupported cache shape"); - } - const entries = value.entries.map(readScanCacheEntry); - if (entries.some((entry) => entry === null)) { - throw new Error("invalid cache entry"); - } - return { schemaVersion: SCAN_CACHE_SCHEMA_VERSION, entries: entries as ScanCacheEntry[] }; - } catch (error) { - const message = error instanceof Error ? error.message : "unknown cache error"; - logger.error(`discarding invalid plugin marketplace scan cache: ${message}`); - return empty(); - } -} - -function readScanCacheEntry(value: unknown): ScanCacheEntry | null { - if (!isObject(value) || !Array.isArray(value.manifests)) return null; - const repositoryId = readInteger(value.repositoryId); - const fullName = readString(value.fullName); - const defaultBranch = readString(value.defaultBranch); - const pushedAt = value.pushedAt === null ? null : readIsoString(value.pushedAt); - const headCommit = readString(value.headCommit); - const manifestFileCount = readNonNegativeIntegerOrNull(value.manifestFileCount); - const invalidManifestCount = readNonNegativeIntegerOrNull(value.invalidManifestCount); - const duplicateManifestCount = readNonNegativeIntegerOrNull(value.duplicateManifestCount); - const warning = value.warning === null ? null : readString(value.warning); - const retryable = typeof value.retryable === "boolean" ? value.retryable : null; - const manifests = value.manifests.map(readCachedManifest); - if ( - repositoryId === null || - !fullName || - !defaultBranch || - (value.pushedAt !== null && pushedAt === null) || - !headCommit || - !/^[0-9a-f]{40,64}$/i.test(headCommit) || - manifestFileCount === null || - invalidManifestCount === null || - duplicateManifestCount === null || - retryable === null || - (value.warning !== null && !warning) || - manifests.some((manifest) => manifest === null) - ) { - return null; - } - return { - repositoryId, - fullName, - defaultBranch, - pushedAt, - headCommit, - manifestFileCount, - invalidManifestCount, - duplicateManifestCount, - warning, - retryable, - manifests: manifests as PluginManifestListing[], - }; -} - -function readCachedManifest(value: unknown): PluginManifestListing | null { - if (!isObject(value)) return null; - const path = readString(value.path); - const id = readString(value.id); - const name = readString(value.name); - const version = readString(value.version); - const minHerdrVersion = readString(value.minHerdrVersion); - const description = value.description === null ? null : readString(value.description); - const platforms = value.platforms === null ? null : readStringArrayOrNull(value.platforms); - if ( - !path || - !isManifestPath(path) || - !id || - !name || - !version || - !minHerdrVersion || - (value.description !== null && !description) || - (value.platforms !== null && platforms === null) - ) { - return null; - } - return { path, id, name, version, minHerdrVersion, description, platforms }; -} - -export function normalizeRepositories(repositories: GitHubRepository[]): RepositoryListing[] { - const byId = new Map<number, RepositoryListing>(); - for (const repository of repositories.map(normalizeRepository)) { - if (repository && !byId.has(repository.id)) byId.set(repository.id, repository); - } - return [...byId.values()].sort(compareRepositories); -} - -async function readBlacklistedRepositories(env: Env): Promise<Set<string>> { - const kv = env.PLUGIN_MARKETPLACE_BLACKLIST; - const blockedRepositories = new Set<string>(); - if (!kv) return blockedRepositories; - - let cursor: string | undefined; - do { - const page = await kv.list({ prefix: BLACKLIST_REPO_KEY_PREFIX, cursor }); - for (const key of page.keys) { - const repository = key.name.slice(BLACKLIST_REPO_KEY_PREFIX.length).trim().toLowerCase(); - if (repository.includes("/")) blockedRepositories.add(repository); - } - cursor = page.cursor; - } while (cursor); - return blockedRepositories; -} - -function normalizeRepository(repo: GitHubRepository): RepositoryListing | null { - if ( - readBoolean(repo.disabled) || - readBoolean(repo.archived) || - readBoolean(repo.fork) || - readBoolean(repo.private) || - readString(repo.visibility) === "private" - ) { - return null; - } - - const fullNameParts = splitFullName(readString(repo.full_name)); - const ownerObject = isObject(repo.owner) ? repo.owner : {}; - const owner = firstString(readString(ownerObject.login), fullNameParts.owner); - const name = firstString(readString(repo.name), fullNameParts.name); - const defaultBranch = readString(repo.default_branch); - if (!owner || !name || !defaultBranch) return null; - - const fullName = readString(repo.full_name) ?? `${owner}/${name}`; - const url = readString(repo.html_url); - const id = readInteger(repo.id); - if (!url || !isValidGitHubRepoUrl(url, owner, name) || id === null || id <= 0) return null; - - return { - id, - fullName, - owner, - name, - description: readNullableString(repo.description), - url, - stars: readNonNegativeInteger(repo.stargazers_count), - forks: readNonNegativeInteger(repo.forks_count), - openIssues: readNonNegativeInteger(repo.open_issues_count), - language: readNullableString(repo.language), - topics: readStringArray(repo.topics), - createdAt: readIsoString(repo.created_at), - updatedAt: readIsoString(repo.updated_at), - pushedAt: readIsoString(repo.pushed_at), - defaultBranch, - }; -} - -function normalizeTreeEntry(value: unknown): GitHubTreeEntry | null { - if (!isObject(value)) return null; - const path = readString(value.path); - const mode = readString(value.mode); - const type = readString(value.type); - if (!path || !mode || !type) return null; - return { path, mode, type, size: readNonNegativeIntegerOrNull(value.size) }; -} - -function isManifestPath(path: string): boolean { - return path === PLUGIN_MANIFEST_FILE || path.endsWith(`/${PLUGIN_MANIFEST_FILE}`); -} - -function isDiscoverableManifestPath(path: string): boolean { - if (!isManifestPath(path)) return false; - const ignoredSegments = new Set([ - "test", - "tests", - "fixture", - "fixtures", - "testdata", - "__fixtures__", - ]); - return !path - .split("/") - .slice(0, -1) - .some((segment) => ignoredSegments.has(segment.toLowerCase())); -} - -function deduplicateManifests(manifests: PluginManifestListing[]): { - manifests: PluginManifestListing[]; - duplicateCount: number; -} { - const preferred = [...manifests].sort((a, b) => { - const depth = (path: string) => path.split("/").length; - return depth(a.path) - depth(b.path) || a.path.localeCompare(b.path); - }); - const byId = new Map<string, PluginManifestListing>(); - let duplicateCount = 0; - for (const manifest of preferred) { - if (byId.has(manifest.id)) { - duplicateCount += 1; - } else { - byId.set(manifest.id, manifest); - } - } - return { - manifests: [...byId.values()].sort((a, b) => a.path.localeCompare(b.path)), - duplicateCount, - }; -} - -function manifestCandidateKey(candidate: TreeManifestCandidate): string { - return `${candidate.repository.id}:${candidate.path}`; -} - -function compareRepositories(a: RepositoryListing, b: RepositoryListing): number { - return ( - b.stars - a.stars || - dateMs(b.pushedAt) - dateMs(a.pushedAt) || - a.fullName.localeCompare(b.fullName) - ); -} - -function githubRequestInit(token: string): RequestInit { - return { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "User-Agent": "herdr-plugin-marketplace", - "X-GitHub-Api-Version": GITHUB_API_VERSION, - }, - }; -} - -async function fetchWithTimeout( - fetchFn: FetchLike, - url: URL, - init: RequestInit, - timeoutMs: number, -): Promise<Response> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetchFn(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - } -} - -function chunk<T>(values: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let index = 0; index < values.length; index += size) { - chunks.push(values.slice(index, index + size)); - } - return chunks; -} - -async function mapConcurrent<T, R>( - values: T[], - concurrency: number, - operation: (value: T) => Promise<R>, -): Promise<R[]> { - const results = new Array<R>(values.length); - let nextIndex = 0; - const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { - while (nextIndex < values.length) { - const index = nextIndex; - nextIndex += 1; - results[index] = await operation(values[index]); - } - }); - await Promise.all(workers); - return results; -} - -function jsonResponse(body: unknown, status: number, cacheControl: string): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - "Content-Type": "application/json", - "Cache-Control": cacheControl, - }, - }); -} - -function isValidGitHubRepoUrl(url: string, owner: string, name: string): boolean { - try { - const parsed = new URL(url); - const segments = parsed.pathname.split("/").filter(Boolean); - return ( - parsed.protocol === "https:" && - parsed.hostname === "github.com" && - segments.length === 2 && - segments[0].toLowerCase() === owner.toLowerCase() && - segments[1].toLowerCase() === name.toLowerCase() - ); - } catch { - return false; - } -} - -function splitFullName(fullName: string | null): { owner: string | null; name: string | null } { - if (!fullName) return { owner: null, name: null }; - const [owner, name, extra] = fullName.split("/"); - return owner && name && !extra ? { owner, name } : { owner: null, name: null }; -} - -function firstString(...values: Array<string | null>): string | null { - return values.find((value) => value !== null) ?? null; -} - -function readString(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - -function readTrimmedString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function readNullableString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - -function readStringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; -} - -function readStringArrayOrNull(value: unknown): string[] | null { - return Array.isArray(value) && value.every((item) => typeof item === "string") - ? value - : null; -} - -function readInteger(value: unknown): number | null { - return typeof value === "number" && Number.isInteger(value) ? value : null; -} - -function readNonNegativeInteger(value: unknown): number { - return readNonNegativeIntegerOrNull(value) ?? 0; -} - -function readNonNegativeIntegerOrNull(value: unknown): number | null { - const integer = readInteger(value); - return integer !== null && integer >= 0 ? integer : null; -} - -function readBoolean(value: unknown): boolean { - return value === true; -} - -function readIsoString(value: unknown): string | null { - return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : null; -} - -function isHerdrVersion(value: string): boolean { - const normalized = value.startsWith("v") ? value.slice(1) : value; - const parts = normalized.split("."); - return ( - parts.length === 3 && - parts.every((part) => /^\d+$/.test(part) && Number(part) <= 0xffff_ffff) - ); -} - -function dateMs(value: string | null): number { - return value ? Date.parse(value) || 0 : 0; -} - -function isObject(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} |
