import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * Package-registry connector — one connector for the version streams of the main software * registries. Each registry has its own JSON (or text) shape; all are normalized to a keyed list * of versions (`key = version`) plus a `latest` item whose `version` field changes on every * release, so the pipeline sees "new version published" and "latest tag moved" as list events. * * Config: { registry: npm | pypi | crates | rubygems | nuget | packagist | hex | goproxy | homebrew | dockerhub, * name: "react" | "requests" | "serde" | "rails" | "Newtonsoft.Json" | "laravel/framework" | "phoenix" * | "github.com/gin-gonic/gin" | "node" | "library/nginx", * maxItems?: 50 } * The sensor `url` may be anything (kept for display); the API URL is derived from registry + name * unless `config.url` is provided. */ export type Registry = "npm" | "pypi" | "crates" | "rubygems" | "nuget" | "packagist" | "hex" | "goproxy" | "homebrew" | "dockerhub"; interface PackageConfig { registry?: Registry; name?: string; url?: string; maxItems?: number; } interface VersionItem { key: string; [k: string]: unknown; version: string; title: string; url: string; publishedAt: string | null; summary: string; } export function registryApiUrl(registry: Registry, name: string): string { const enc = (s: string): string => s.split("/").map(encodeURIComponent).join("/"); switch (registry) { case "npm": return `https://registry.npmjs.org/${name.startsWith("@") ? "@" + encodeURIComponent(name.slice(1)) : encodeURIComponent(name)}`; case "pypi": return `https://pypi.org/pypi/${encodeURIComponent(name)}/json`; case "crates": return `https://crates.io/api/v1/crates/${encodeURIComponent(name)}`; case "rubygems": return `https://rubygems.org/api/v1/versions/${encodeURIComponent(name)}.json`; case "nuget": return `https://api.nuget.org/v3-flatcontainer/${encodeURIComponent(name.toLowerCase())}/index.json`; case "packagist": return `https://repo.packagist.org/p2/${enc(name)}.json`; case "hex": return `https://hex.pm/api/packages/${encodeURIComponent(name)}`; case "goproxy": return `https://proxy.golang.org/${enc(name.toLowerCase())}/@v/list`; case "homebrew": return `https://formulae.brew.sh/api/formula/${encodeURIComponent(name)}.json`; case "dockerhub": return `https://hub.docker.com/v2/repositories/${enc(name.includes("/") ? name : `library/${name}`)}/tags?page_size=50&ordering=last_updated`; } } export function registryPageUrl(registry: Registry, name: string, version?: string): string { switch (registry) { case "npm": return `https://www.npmjs.com/package/${name}${version ? `/v/${version}` : ""}`; case "pypi": return `https://pypi.org/project/${name}/${version ? version + "/" : ""}`; case "crates": return `https://crates.io/crates/${name}${version ? `/${version}` : ""}`; case "rubygems": return `https://rubygems.org/gems/${name}${version ? `/versions/${version}` : ""}`; case "nuget": return `https://www.nuget.org/packages/${name}${version ? `/${version}` : ""}`; case "packagist": return `https://packagist.org/packages/${name}${version ? `#${version}` : ""}`; case "hex": return `https://hex.pm/packages/${name}${version ? `/${version}` : ""}`; case "goproxy": return `https://pkg.go.dev/${name}${version ? `@${version}` : ""}`; case "homebrew": return `https://formulae.brew.sh/formula/${name}`; case "dockerhub": return name.startsWith("library/") || !name.includes("/") ? `https://hub.docker.com/_/${name.replace(/^library\//, "")}${version ? `/tags?name=${version}` : ""}` : `https://hub.docker.com/r/${name}${version ? `/tags?name=${version}` : ""}`; } } const PRERELEASE = /(alpha|beta|rc|canary|next|dev|preview|snapshot|nightly|experimental|insiders|pre)/i; export class PackageConnector implements WebSensorConnector { mode = "list" as const; metadata(): ConnectorMetadata { return { key: "package", name: "Package registry", sensorTypes: ["REST_API", "JSON"], description: "Version streams of npm, PyPI, crates.io, RubyGems, NuGet, Packagist, Hex, Go proxy, Homebrew and Docker Hub", version: "1.0.0" }; } private resolve(endpoint: SensorEndpoint): { registry: Registry; name: string; url: string } { const cfg = endpoint.config as PackageConfig; if (!cfg.registry || !cfg.name) throw new NormalizeError("bad_config", "package connector needs config.registry and config.name"); return { registry: cfg.registry, name: cfg.name, url: cfg.url ?? registryApiUrl(cfg.registry, cfg.name) }; } async fetch(endpoint: SensorEndpoint): Promise { let target: { registry: Registry; name: string; url: string }; try { target = this.resolve(endpoint); } catch (e) { return { sensorId: endpoint.id, url: endpoint.url, fetchedAt: new Date(), notModified: false, error: { code: "bad_config", message: (e as Error).message }, meta: { status: 0, url: endpoint.url, finalUrl: endpoint.url, contentType: null, contentLength: 0, etag: null, lastModified: null, durationMs: 0, redirects: 0, method: "GET", headers: {} } }; } const accept = target.registry === "npm" ? "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8" : target.registry === "goproxy" ? "text/plain, */*;q=0.5" : "application/json, */*;q=0.5"; const obs = await httpFetchWithRetry(endpoint.id, target.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept, headers: target.registry === "crates" ? { "user-agent": "WebSensorBot/0.1 (+https://www.websensor.io/bot; contact@websensor.io)" } : undefined, timeoutMs: 40_000, maxBytes: 60 * 1024 * 1024 }); return { ...obs, url: endpoint.url }; } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const { registry, name } = this.resolve(endpoint); const cfg = endpoint.config as PackageConfig; const text = obs.body.toString("utf8"); let versions: VersionItem[]; let latest: string | undefined; let description = ""; const page = (v?: string): string => registryPageUrl(registry, name, v); const item = (version: string, publishedAt: string | null, extra: Record = {}): VersionItem => ({ key: version, version, title: `${name} ${version}`, url: page(version), publishedAt: publishedAt && !Number.isNaN(new Date(publishedAt).getTime()) ? new Date(publishedAt).toISOString() : null, summary: "", prerelease: PRERELEASE.test(version), ...extra }); if (registry === "goproxy") { versions = text .split(/\r?\n/) .map((s) => s.trim()) .filter(Boolean) .map((v) => item(v, null)); versions.sort((a, b) => semverCompare(b.version, a.version)); latest = versions.find((v) => !v.prerelease)?.version ?? versions[0]?.version; } else { let j: unknown; try { j = JSON.parse(text); } catch { throw new NormalizeError("bad_json", `${registry} response is not JSON`); } const o = j as Record; switch (registry) { case "npm": { const time = (o.time ?? {}) as Record; const tags = (o["dist-tags"] ?? {}) as Record; description = String(o.description ?? ""); versions = Object.keys((o.versions ?? {}) as object).map((v) => item(v, time[v] ?? null)); if (!versions.length) versions = Object.keys(time).filter((k) => k !== "created" && k !== "modified").map((v) => item(v, time[v] ?? null)); latest = tags.latest; versions.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "")); break; } case "pypi": { const info = (o.info ?? {}) as Record; description = String(info.summary ?? ""); latest = String(info.version ?? ""); const rel = (o.releases ?? {}) as Record; versions = Object.entries(rel).map(([v, files]) => item(v, files[0]?.upload_time_iso_8601 ?? null, { yanked: files.some((f) => f.yanked) })); versions.sort((a, b) => (b.publishedAt ?? "").localeCompare(a.publishedAt ?? "")); break; } case "crates": { const crate = (o.crate ?? {}) as Record; description = String(crate.description ?? ""); latest = String(crate.max_stable_version ?? crate.max_version ?? ""); versions = ((o.versions ?? []) as Record[]).map((v) => item(String(v.num), (v.created_at as string) ?? null, { yanked: Boolean(v.yanked) })); break; } case "rubygems": { versions = ((Array.isArray(j) ? j : []) as Record[]).map((v) => item(String(v.number), (v.created_at as string) ?? null, { prerelease: Boolean(v.prerelease) })); latest = versions.find((v) => !v.prerelease)?.version; description = String(((Array.isArray(j) ? j[0] : {}) as Record)?.summary ?? ""); break; } case "nuget": { versions = ((o.versions ?? []) as string[]).map((v) => item(v, null)).reverse(); latest = versions.find((v) => !v.prerelease)?.version ?? versions[0]?.version; break; } case "packagist": { const pk = ((o.packages ?? {}) as Record[]>)[name] ?? []; versions = pk.filter((v) => typeof v.version === "string").map((v) => item(String(v.version), (v.time as string) ?? null)); latest = versions.find((v) => !v.prerelease && !/dev/.test(v.version))?.version; description = String(pk[0]?.description ?? ""); break; } case "hex": { const meta = (o.meta ?? {}) as Record; description = String(meta.description ?? ""); versions = ((o.releases ?? []) as Record[]).map((v) => item(String(v.version), (v.inserted_at as string) ?? null)); latest = String(((o.latest_stable_version ?? o.latest_version) as string) ?? versions[0]?.version ?? ""); break; } case "homebrew": { const vs = (o.versions ?? {}) as Record; description = String(o.desc ?? ""); latest = String(vs.stable ?? ""); versions = latest ? [item(latest, null, { revision: o.revision ?? 0, bottle: vs.bottle })] : []; break; } case "dockerhub": { versions = ((o.results ?? []) as Record[]).map((t) => item(String(t.name), (t.last_updated as string) ?? null, { digest: String(t.digest ?? "").slice(0, 32) })); latest = versions.find((v) => v.version === "latest") ? "latest" : versions[0]?.version; break; } default: throw new NormalizeError("bad_config", `unsupported registry ${String(registry)}`); } } const max = cfg.maxItems ?? 50; const items: { key: string; [k: string]: unknown }[] = versions.slice(0, max); const latestItem = versions.find((v) => v.version === latest); items.unshift({ key: "latest", kind: "latest", version: latest ?? "", title: `${name} latest → ${latest ?? "?"}`, url: page(latest), summary: description.slice(0, 400), publishedAt: latestItem?.publishedAt ?? null, digest: (latestItem as { digest?: string } | undefined)?.digest }); // For Docker Hub, tag content changes without a new tag name (same key, new digest) → compare digest too. const compareFields = registry === "dockerhub" ? ["version", "digest"] : registry === "homebrew" ? ["version", "revision"] : ["version"]; const canonical = items.map((i) => `${i.key}\t${compareFields.map((f) => String(i[f] ?? "")).join("\t")}`).join("\n"); const newest = items.map((i) => i.publishedAt as string | null).filter((x): x is string => Boolean(x)).sort().at(-1); return { mode: "list", items, compareFields, title: `${registry}:${name}`, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => String(i.title)).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { registry, name, latest, versionCount: versions.length, description: description.slice(0, 200) }, }; } } /** Loose semver comparison (handles v-prefix, prerelease after numeric parts). */ export function semverCompare(a: string, b: string): number { const pa = a.replace(/^v/, "").split(/[.+-]/); const pb = b.replace(/^v/, "").split(/[.+-]/); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i]; const y = pb[i]; if (x === undefined) return -1; if (y === undefined) return 1; const nx = Number(x); const ny = Number(y); if (!Number.isNaN(nx) && !Number.isNaN(ny)) { if (nx !== ny) return nx - ny; } else if (x !== y) return x.localeCompare(y); } return 0; }