/** * Discovery-time DSH compatibility metadata. * * The public catalog does not carry npm manifests. Fetching every manifest * while the market opens would turn one catalog request into more than a * thousand registry requests, so this module supplies a bounded, on-demand * index. Successful public manifest facts are cached beside the market's * profile state; conclusions are never cached because they depend on the DSH * version of the process serving the page. */ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { compareSemver, isSemver, satisfiesRange } from './check.ts' import { classifyPeer } from './compatibility.ts' import { marketFetch } from './net.ts' export type HostCompatibilityStatus = 'compatible' | 'incompatible' | 'unknown' export type HostCompatibilityBasis = 'manifest' | 'undeclared' | 'unavailable' export interface HostRequirementDeclaration { kind: 'engine' | 'peer' /** Present only for a peer-derived declaration. */ package?: string range: string } export interface HostCompatibility { status: HostCompatibilityStatus basis: HostCompatibilityBasis /** Human-readable intersection of every raw declaration. */ requirement: string | null declarations: HostRequirementDeclaration[] } export interface NpmManifestFacts { version: string | null enginesDsh: string | null peerDependencies: Record } interface CacheEntry { checkedAt: number facts: NpmManifestFacts } interface CacheFile { schema: 'dsh-market/discovery-compatibility-cache/v1' entries: Record } type FetchLike = ( url: string, init?: { signal?: AbortSignal; headers?: Record }, ) => Promise const CACHE_SCHEMA = 'dsh-market/discovery-compatibility-cache/v1' as const const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000 const FAILURE_COOLDOWN_MS = 5 * 60 * 1000 const OUTAGE_COOLDOWN_MS = 30 * 1000 const FETCH_TIMEOUT_MS = 8_000 const DEFAULT_CONCURRENCY = 8 const MAX_CACHE_ENTRIES = 5_000 const MAX_RANGE_LENGTH = 256 function range(value: unknown): string | null { if (typeof value !== 'string') return null const trimmed = value.trim() return trimmed !== '' && trimmed.length <= MAX_RANGE_LENGTH ? trimmed : null } function record(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } /** Keep only the small public subset of an npm manifest needed by discovery. */ export function manifestFacts(value: unknown): NpmManifestFacts { const manifest = record(value) ?? {} const engines = record(manifest.engines) // #577: the ecosystem declares the host requirement in BOTH shapes — // top-level `engines.dsh` and `dsh.engines.dsh` under the manifest's own // `dsh` field (the natural home, and what e.g. @linxin666/dsh-web-all // publishes). Neither position is authoritative, so read both; when a // manifest carries both, the top-level declaration wins. const dshEngines = record(record(manifest.dsh)?.engines) const peers = record(manifest.peerDependencies) const peerDependencies: Record = {} for (const [name, raw] of Object.entries(peers ?? {})) { if (!name.startsWith('@deepseek-ai/')) continue const declared = range(raw) if (declared !== null) peerDependencies[name] = declared } return { version: range(manifest.version), enginesDsh: range(engines?.dsh) ?? range(dshEngines?.dsh), peerDependencies, } } function validFacts(value: unknown): value is NpmManifestFacts { const facts = record(value) const peers = record(facts?.peerDependencies) return facts !== null && (facts.version === null || range(facts.version) === facts.version) && (facts.enginesDsh === null || range(facts.enginesDsh) === facts.enginesDsh) && peers !== null && Object.entries(peers).every(([name, item]) => name.startsWith('@deepseek-ai/') && range(item) === item) } function displayRequirement(declarations: HostRequirementDeclaration[]): string | null { const unique = [...new Set(declarations.map(item => item.range))] return unique.length === 0 ? null : unique.join(' ∩ ') } function rangeResult(hostVersion: string, declared: string): boolean | null { return satisfiesRange(hostVersion, declared, { includePrerelease: true }) } /** * Host peers imply the DSH release-line floor, but old ecosystem packages * often carry caret ranges such as `^0.0.1` whose computed 0.x upper bound * was never intended as a host ceiling. Reuse install preflight's directional * policy: below-min and explicit upper/exact violations are definite; a * newer host above an implicit caret/tilde ceiling remains compatible. */ function declarationResult( declaration: HostRequirementDeclaration, hostVersion: string, ): boolean | null { const satisfied = rangeResult(hostVersion, declaration.range) if (declaration.kind === 'engine' || satisfied !== false) return satisfied const verdict = classifyPeer( 'discovery', declaration.package ?? '@deepseek-ai/dsh', declaration.range, hostVersion, false, ) if (verdict.kind === 'risk') return false if (verdict.kind === 'warning' && verdict.warning.reason === 'aboveMax') return true return null } /** * Derive the current host verdict from raw manifest facts. * * Every valid declaration is conjunctive: an explicit `engines.dsh` and all * host peers must agree. A malformed declaration keeps a passing result * unknown, but cannot erase a definite mismatch from another declaration. */ export function deriveHostCompatibility( facts: NpmManifestFacts | null, hostVersion: string | null, hostPackages: ReadonlySet, ): HostCompatibility { if (facts === null) { return { status: 'unknown', basis: 'unavailable', requirement: null, declarations: [] } } const declarations: HostRequirementDeclaration[] = [] if (facts.enginesDsh !== null) { declarations.push({ kind: 'engine', range: facts.enginesDsh }) } for (const [name, declared] of Object.entries(facts.peerDependencies)) { // Cordis (4.x) and schemastery (3.x) are host packages, but are not on // DSH's lockstep 0.x release line and therefore say nothing about the // DSH version. `hostPackages` is the local install inventory plus its // curated fallback, so future lockstep DSH packages are picked up too. if (!hostPackages.has(name) || !/^@deepseek-ai\/dsh(?:-|$)/.test(name)) continue declarations.push({ kind: 'peer', package: name, range: declared }) } const requirement = displayRequirement(declarations) if (declarations.length === 0) { return { status: 'unknown', basis: 'undeclared', requirement: null, declarations: [] } } if (hostVersion === null) { return { status: 'unknown', basis: 'manifest', requirement, declarations } } const outcomes = declarations.map(item => declarationResult(item, hostVersion)) const status: HostCompatibilityStatus = outcomes.some(item => item === false) ? 'incompatible' : outcomes.every(item => item === true) ? 'compatible' : 'unknown' return { status, basis: 'manifest', requirement, declarations } } /** * Durable, bounded lookup of npm `latest` manifests. * * Failed requests are intentionally memory-only and short-lived: a mirror * outage must not become a day-long false "undeclared" result on disk. */ export class DiscoveryManifestIndex { private readonly entries = new Map() private readonly failures = new Map() private readonly inflight = new Map>() private readonly fetcher: FetchLike private readonly now: () => number private readonly ttlMs: number private readonly concurrency: number private loaded = false private consecutiveFailures = 0 private unavailableUntil = 0 private dirty = false private activeFetches = 0 private readonly fetchWaiters: Array<() => void> = [] constructor( private readonly cacheFile: string, options: { fetcher?: FetchLike; now?: () => number; ttlMs?: number; concurrency?: number } = {}, ) { this.fetcher = options.fetcher ?? marketFetch this.now = options.now ?? Date.now this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS this.concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY)) } private load(): void { if (this.loaded) return this.loaded = true try { const parsed = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as Partial if (parsed.schema !== CACHE_SCHEMA || record(parsed.entries) === null) return for (const [name, raw] of Object.entries(parsed.entries as Record)) { const entry = record(raw) if (entry === null || typeof entry.checkedAt !== 'number' || !validFacts(entry.facts)) continue this.entries.set(name, { checkedAt: entry.checkedAt, facts: entry.facts }) } } catch { /* first run or a truncated cache — fetch fresh */ } } private persist(): void { try { mkdirSync(dirname(this.cacheFile), { recursive: true, mode: 0o700 }) const newest = [...this.entries.entries()] .sort(([, a], [, b]) => b.checkedAt - a.checkedAt) .slice(0, MAX_CACHE_ENTRIES) this.entries.clear() for (const [name, entry] of newest) this.entries.set(name, entry) const data: CacheFile = { schema: CACHE_SCHEMA, entries: Object.fromEntries(newest) } writeFileSync(this.cacheFile, JSON.stringify(data), { mode: 0o600 }) } catch { /* cache failure degrades to in-memory lookups */ } } /** One semaphore for the whole index, including overlapping HTTP batches. */ private async withFetchPermit(operation: () => Promise): Promise { if (this.activeFetches >= this.concurrency) { await new Promise(resolve => this.fetchWaiters.push(resolve)) } this.activeFetches += 1 try { return await operation() } finally { this.activeFetches -= 1 this.fetchWaiters.shift()?.() } } private async fetchOne(name: string, registry: string, record = true): Promise { this.load() const now = this.now() const cached = this.entries.get(name) if (cached !== undefined && now - cached.checkedAt < this.ttlMs) return cached.facts if ((this.failures.get(name) ?? 0) > now || this.unavailableUntil > now) return null // Advisory and recording lookups keep separate in-flight slots: an // advisory call must not be answered by — or hand its answer to — a // recording one, or `record` would stop meaning anything when the two // overlap on the same package. const key = record ? name : `${name}\u0000advisory` const pending = this.inflight.get(key) if (pending !== undefined) return await pending const request = (async (): Promise => { try { const response = await this.withFetchPermit(async () => await this.fetcher( `${registry}/${encodeURIComponent(name)}/latest`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'application/json', 'user-agent': 'dsh-market' }, }, )) if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) const facts = manifestFacts(await response.json()) if (record) { this.entries.set(name, { checkedAt: this.now(), facts }) this.dirty = true this.failures.delete(name) this.consecutiveFailures = 0 } return facts } catch { if (record) { const failedAt = this.now() this.failures.set(name, failedAt + FAILURE_COOLDOWN_MS) this.consecutiveFailures += 1 if (this.consecutiveFailures >= this.concurrency) { this.unavailableUntil = failedAt + OUTAGE_COOLDOWN_MS } } return null } finally { this.inflight.delete(key) } })() this.inflight.set(key, request) return await request } /** * Facts for ONE named release, rather than for `latest`. * * The install and update routes can name the release they are about to * install — the compatibility dialog resolves one for this host (#581), and * the update route resolves the channel's target — and judging those * against `latest` is wrong in both directions: it refuses the compatible * older release the dialog just found for this host (because the newest * release declares a range this host misses), and it would equally pass a * pinned release that is itself incompatible. * * Deliberately outside the cache. The index is keyed by package name, and a * version-keyed one would grow with every release anyone ever pinned to * answer a question asked once per install. Nothing is recorded either: a * pre-flight verdict must not decide what the diagnostics panel sees next * (#619). * * A release whose manifest cannot be read is `null`, like every other * unreadable manifest: absence of a claim is not a verdict. */ async lookupVersion(name: string, version: string, registry: string): Promise { try { const response = await this.withFetchPermit(async () => await this.fetcher( `${registry}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'application/json', 'user-agent': 'dsh-market' }, }, )) if (!response.ok) return null return manifestFacts(await response.json()) } catch { return null } } /** * Look up a bounded batch while never exceeding the configured fan-out. * * `record: false` answers the caller from the cache or the network but * writes nothing back — not a success, not a failure, not the outage * counters. A pre-flight check uses this: it runs before an operation is * allowed to proceed, so whatever it learns must not decide what the * diagnostics panel sees next (#619). */ async lookup( names: readonly string[], registry: string, options: { record?: boolean } = {}, ): Promise> { const record = options.record !== false const unique = [...new Set(names)] const result: Record = {} let next = 0 const worker = async (): Promise => { while (next < unique.length) { const name = unique[next++]! result[name] = await this.fetchOne(name, registry, record) } } await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker)) if (this.dirty) { this.dirty = false this.persist() } return result } } /** * The newest release of `npmName` whose own declarations this host satisfies. * * The answer to "the newest version is too new for this host — what CAN I * install?" (#581), which the refusal dialog used to answer with nothing. * * Only a CONFIRMED `compatible` verdict from {@link deriveHostCompatibility} * passes. `unknown` — no declaration, or a host version nobody can read — is * skipped rather than offered: pinning a release as "compatible" on the * strength of a missing field would be the same guess this whole check exists * to avoid. * * Prereleases are included, and ordered properly (a release outranks its own * prereleases): in this ecosystem the host line is often a prerelease, and * plugins declare against it by name — skipping them would report "none * found" where the fix exists. * * `minimumVersionExclusive` restricts the search to releases NEWER than the * installed one, which is what an update needs: suggesting a downgrade is not * an update. * * @returns the version, or null when the packument cannot be read or nothing * in the history declares itself compatible. */ export async function findCompatibleVersion( npmName: string, hostVersion: string, hostPackages: ReadonlySet, registry: string, fetcher: FetchLike = marketFetch, minimumVersionExclusive: string | null = null, ): Promise { let doc: unknown try { const res = await fetcher( `${registry}/${encodeURIComponent(npmName)}`, { signal: AbortSignal.timeout(15_000), headers: { accept: 'application/json', 'user-agent': 'dsh-market' } }, ) if (!res.ok) return null doc = await res.json() } catch { return null } const versions = record(record(doc)?.versions) if (versions === null) return null const candidates = Object.keys(versions) .filter(version => isSemver(version) && (minimumVersionExclusive === null || compareSemver(version, minimumVersionExclusive) > 0)) .sort((left, right) => compareSemver(right, left)) for (const version of candidates) { if (deriveHostCompatibility(manifestFacts(versions[version]), hostVersion, hostPackages).status === 'compatible') { return version } } return null }