@chatcode/cco-market 1.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Versioned, read-only diagnostics shared by the host route and client. */
|
|
2
|
+
export const DIAGNOSTIC_SCHEMA = 'dsh-market/diagnostics/v1' as const
|
|
3
|
+
|
|
4
|
+
/** Conservative first set of known, identity-sensitive host contracts. */
|
|
5
|
+
export const KNOWN_SHARED_HOST_PACKAGES = [
|
|
6
|
+
'@deepseek-ai/cordis',
|
|
7
|
+
'@deepseek-ai/dsh-attachment',
|
|
8
|
+
'@deepseek-ai/dsh-llm',
|
|
9
|
+
'@deepseek-ai/dsh-system-prompt',
|
|
10
|
+
'@deepseek-ai/dsh-tools',
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
const knownSharedHostPackages = new Set<string>(KNOWN_SHARED_HOST_PACKAGES)
|
|
14
|
+
|
|
15
|
+
export interface PackageManifestFact {
|
|
16
|
+
packageName: string
|
|
17
|
+
manifest: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SharedHostPackageDependencyFinding {
|
|
21
|
+
code: 'shared-host-package-dependency'
|
|
22
|
+
severity: 'warning'
|
|
23
|
+
subject: {
|
|
24
|
+
kind: 'package'
|
|
25
|
+
name: string
|
|
26
|
+
}
|
|
27
|
+
evidence: {
|
|
28
|
+
basis: 'manifest-declaration'
|
|
29
|
+
dependency: string
|
|
30
|
+
declaredRange: string
|
|
31
|
+
declaredIn: 'dependencies'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DiagnosticFinding = SharedHostPackageDependencyFinding
|
|
36
|
+
|
|
37
|
+
export interface DiagnosticReportV1 {
|
|
38
|
+
schema: typeof DIAGNOSTIC_SCHEMA
|
|
39
|
+
findings: DiagnosticFinding[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Report exact manifest declarations; the resolved dependency tree is not inspected. */
|
|
47
|
+
function inspectKnownHostDependencyDeclarations(
|
|
48
|
+
packageName: string,
|
|
49
|
+
manifest: unknown,
|
|
50
|
+
): SharedHostPackageDependencyFinding[] {
|
|
51
|
+
if (!isRecord(manifest) || !isRecord(manifest.dependencies)) return []
|
|
52
|
+
|
|
53
|
+
const findings: SharedHostPackageDependencyFinding[] = []
|
|
54
|
+
for (const dependency of Object.keys(manifest.dependencies).sort()) {
|
|
55
|
+
const declaredRange = manifest.dependencies[dependency]
|
|
56
|
+
if (!knownSharedHostPackages.has(dependency) || typeof declaredRange !== 'string') continue
|
|
57
|
+
findings.push({
|
|
58
|
+
code: 'shared-host-package-dependency',
|
|
59
|
+
severity: 'warning',
|
|
60
|
+
subject: { kind: 'package', name: packageName },
|
|
61
|
+
evidence: {
|
|
62
|
+
basis: 'manifest-declaration',
|
|
63
|
+
dependency,
|
|
64
|
+
declaredRange,
|
|
65
|
+
declaredIn: 'dependencies',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
return findings
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build a stable diagnostic envelope from installed package manifests. */
|
|
73
|
+
export function diagnosePackageManifests(packages: readonly PackageManifestFact[]): DiagnosticReportV1 {
|
|
74
|
+
const sortedPackages = [...packages].sort((a, b) =>
|
|
75
|
+
a.packageName < b.packageName ? -1 : a.packageName > b.packageName ? 1 : 0,
|
|
76
|
+
)
|
|
77
|
+
return {
|
|
78
|
+
schema: DIAGNOSTIC_SCHEMA,
|
|
79
|
+
findings: sortedPackages.flatMap(({ packageName, manifest }) => {
|
|
80
|
+
if (!isRecord(manifest) || manifest.dsh === undefined) return []
|
|
81
|
+
return inspectKnownHostDependencyDeclarations(packageName, manifest)
|
|
82
|
+
}),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery-time DSH compatibility metadata.
|
|
3
|
+
*
|
|
4
|
+
* The public catalog does not carry npm manifests. Fetching every manifest
|
|
5
|
+
* while the market opens would turn one catalog request into more than a
|
|
6
|
+
* thousand registry requests, so this module supplies a bounded, on-demand
|
|
7
|
+
* index. Successful public manifest facts are cached beside the market's
|
|
8
|
+
* profile state; conclusions are never cached because they depend on the DSH
|
|
9
|
+
* version of the process serving the page.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { dirname } from 'node:path'
|
|
14
|
+
import { satisfiesRange } from './check.ts'
|
|
15
|
+
import { classifyPeer } from './compatibility.ts'
|
|
16
|
+
import { marketFetch } from './net.ts'
|
|
17
|
+
|
|
18
|
+
export type HostCompatibilityStatus = 'compatible' | 'incompatible' | 'unknown'
|
|
19
|
+
export type HostCompatibilityBasis = 'manifest' | 'undeclared' | 'unavailable'
|
|
20
|
+
|
|
21
|
+
export interface HostRequirementDeclaration {
|
|
22
|
+
kind: 'engine' | 'peer'
|
|
23
|
+
/** Present only for a peer-derived declaration. */
|
|
24
|
+
package?: string
|
|
25
|
+
range: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HostCompatibility {
|
|
29
|
+
status: HostCompatibilityStatus
|
|
30
|
+
basis: HostCompatibilityBasis
|
|
31
|
+
/** Human-readable intersection of every raw declaration. */
|
|
32
|
+
requirement: string | null
|
|
33
|
+
declarations: HostRequirementDeclaration[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NpmManifestFacts {
|
|
37
|
+
version: string | null
|
|
38
|
+
enginesDsh: string | null
|
|
39
|
+
peerDependencies: Record<string, string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CacheEntry {
|
|
43
|
+
checkedAt: number
|
|
44
|
+
facts: NpmManifestFacts
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CacheFile {
|
|
48
|
+
schema: 'dsh-market/discovery-compatibility-cache/v1'
|
|
49
|
+
entries: Record<string, CacheEntry>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type FetchLike = (
|
|
53
|
+
url: string,
|
|
54
|
+
init?: { signal?: AbortSignal; headers?: Record<string, string> },
|
|
55
|
+
) => Promise<Response>
|
|
56
|
+
|
|
57
|
+
const CACHE_SCHEMA = 'dsh-market/discovery-compatibility-cache/v1' as const
|
|
58
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
|
|
59
|
+
const FAILURE_COOLDOWN_MS = 5 * 60 * 1000
|
|
60
|
+
const OUTAGE_COOLDOWN_MS = 30 * 1000
|
|
61
|
+
const FETCH_TIMEOUT_MS = 8_000
|
|
62
|
+
const DEFAULT_CONCURRENCY = 8
|
|
63
|
+
const MAX_CACHE_ENTRIES = 5_000
|
|
64
|
+
const MAX_RANGE_LENGTH = 256
|
|
65
|
+
|
|
66
|
+
function range(value: unknown): string | null {
|
|
67
|
+
if (typeof value !== 'string') return null
|
|
68
|
+
const trimmed = value.trim()
|
|
69
|
+
return trimmed !== '' && trimmed.length <= MAX_RANGE_LENGTH ? trimmed : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
73
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
74
|
+
? value as Record<string, unknown>
|
|
75
|
+
: null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Keep only the small public subset of an npm manifest needed by discovery. */
|
|
79
|
+
export function manifestFacts(value: unknown): NpmManifestFacts {
|
|
80
|
+
const manifest = record(value) ?? {}
|
|
81
|
+
const engines = record(manifest.engines)
|
|
82
|
+
const peers = record(manifest.peerDependencies)
|
|
83
|
+
const peerDependencies: Record<string, string> = {}
|
|
84
|
+
for (const [name, raw] of Object.entries(peers ?? {})) {
|
|
85
|
+
if (!name.startsWith('@deepseek-ai/')) continue
|
|
86
|
+
const declared = range(raw)
|
|
87
|
+
if (declared !== null) peerDependencies[name] = declared
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
version: range(manifest.version),
|
|
91
|
+
enginesDsh: range(engines?.dsh),
|
|
92
|
+
peerDependencies,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validFacts(value: unknown): value is NpmManifestFacts {
|
|
97
|
+
const facts = record(value)
|
|
98
|
+
const peers = record(facts?.peerDependencies)
|
|
99
|
+
return facts !== null
|
|
100
|
+
&& (facts.version === null || range(facts.version) === facts.version)
|
|
101
|
+
&& (facts.enginesDsh === null || range(facts.enginesDsh) === facts.enginesDsh)
|
|
102
|
+
&& peers !== null
|
|
103
|
+
&& Object.entries(peers).every(([name, item]) =>
|
|
104
|
+
name.startsWith('@deepseek-ai/') && range(item) === item)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function displayRequirement(declarations: HostRequirementDeclaration[]): string | null {
|
|
108
|
+
const unique = [...new Set(declarations.map(item => item.range))]
|
|
109
|
+
return unique.length === 0 ? null : unique.join(' ∩ ')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rangeResult(hostVersion: string, declared: string): boolean | null {
|
|
113
|
+
return satisfiesRange(hostVersion, declared, { includePrerelease: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Host peers imply the DSH release-line floor, but old ecosystem packages
|
|
118
|
+
* often carry caret ranges such as `^0.0.1` whose computed 0.x upper bound
|
|
119
|
+
* was never intended as a host ceiling. Reuse install preflight's directional
|
|
120
|
+
* policy: below-min and explicit upper/exact violations are definite; a
|
|
121
|
+
* newer host above an implicit caret/tilde ceiling remains compatible.
|
|
122
|
+
*/
|
|
123
|
+
function declarationResult(
|
|
124
|
+
declaration: HostRequirementDeclaration,
|
|
125
|
+
hostVersion: string,
|
|
126
|
+
): boolean | null {
|
|
127
|
+
const satisfied = rangeResult(hostVersion, declaration.range)
|
|
128
|
+
if (declaration.kind === 'engine' || satisfied !== false) return satisfied
|
|
129
|
+
const verdict = classifyPeer(
|
|
130
|
+
'discovery',
|
|
131
|
+
declaration.package ?? '@deepseek-ai/dsh',
|
|
132
|
+
declaration.range,
|
|
133
|
+
hostVersion,
|
|
134
|
+
false,
|
|
135
|
+
)
|
|
136
|
+
if (verdict.kind === 'risk') return false
|
|
137
|
+
if (verdict.kind === 'warning' && verdict.warning.reason === 'aboveMax') return true
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Derive the current host verdict from raw manifest facts.
|
|
143
|
+
*
|
|
144
|
+
* Every valid declaration is conjunctive: an explicit `engines.dsh` and all
|
|
145
|
+
* host peers must agree. A malformed declaration keeps a passing result
|
|
146
|
+
* unknown, but cannot erase a definite mismatch from another declaration.
|
|
147
|
+
*/
|
|
148
|
+
export function deriveHostCompatibility(
|
|
149
|
+
facts: NpmManifestFacts | null,
|
|
150
|
+
hostVersion: string | null,
|
|
151
|
+
hostPackages: ReadonlySet<string>,
|
|
152
|
+
): HostCompatibility {
|
|
153
|
+
if (facts === null) {
|
|
154
|
+
return { status: 'unknown', basis: 'unavailable', requirement: null, declarations: [] }
|
|
155
|
+
}
|
|
156
|
+
const declarations: HostRequirementDeclaration[] = []
|
|
157
|
+
if (facts.enginesDsh !== null) {
|
|
158
|
+
declarations.push({ kind: 'engine', range: facts.enginesDsh })
|
|
159
|
+
}
|
|
160
|
+
for (const [name, declared] of Object.entries(facts.peerDependencies)) {
|
|
161
|
+
// Cordis (4.x) and schemastery (3.x) are host packages, but are not on
|
|
162
|
+
// DSH's lockstep 0.x release line and therefore say nothing about the
|
|
163
|
+
// DSH version. `hostPackages` is the local install inventory plus its
|
|
164
|
+
// curated fallback, so future lockstep DSH packages are picked up too.
|
|
165
|
+
if (!hostPackages.has(name) || !/^@deepseek-ai\/dsh(?:-|$)/.test(name)) continue
|
|
166
|
+
declarations.push({ kind: 'peer', package: name, range: declared })
|
|
167
|
+
}
|
|
168
|
+
const requirement = displayRequirement(declarations)
|
|
169
|
+
if (declarations.length === 0) {
|
|
170
|
+
return { status: 'unknown', basis: 'undeclared', requirement: null, declarations: [] }
|
|
171
|
+
}
|
|
172
|
+
if (hostVersion === null) {
|
|
173
|
+
return { status: 'unknown', basis: 'manifest', requirement, declarations }
|
|
174
|
+
}
|
|
175
|
+
const outcomes = declarations.map(item => declarationResult(item, hostVersion))
|
|
176
|
+
const status: HostCompatibilityStatus = outcomes.some(item => item === false)
|
|
177
|
+
? 'incompatible'
|
|
178
|
+
: outcomes.every(item => item === true)
|
|
179
|
+
? 'compatible'
|
|
180
|
+
: 'unknown'
|
|
181
|
+
return { status, basis: 'manifest', requirement, declarations }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Durable, bounded lookup of npm `latest` manifests.
|
|
186
|
+
*
|
|
187
|
+
* Failed requests are intentionally memory-only and short-lived: a mirror
|
|
188
|
+
* outage must not become a day-long false "undeclared" result on disk.
|
|
189
|
+
*/
|
|
190
|
+
export class DiscoveryManifestIndex {
|
|
191
|
+
private readonly entries = new Map<string, CacheEntry>()
|
|
192
|
+
private readonly failures = new Map<string, number>()
|
|
193
|
+
private readonly inflight = new Map<string, Promise<NpmManifestFacts | null>>()
|
|
194
|
+
private readonly fetcher: FetchLike
|
|
195
|
+
private readonly now: () => number
|
|
196
|
+
private readonly ttlMs: number
|
|
197
|
+
private readonly concurrency: number
|
|
198
|
+
private loaded = false
|
|
199
|
+
private consecutiveFailures = 0
|
|
200
|
+
private unavailableUntil = 0
|
|
201
|
+
private dirty = false
|
|
202
|
+
private activeFetches = 0
|
|
203
|
+
private readonly fetchWaiters: Array<() => void> = []
|
|
204
|
+
|
|
205
|
+
constructor(
|
|
206
|
+
private readonly cacheFile: string,
|
|
207
|
+
options: { fetcher?: FetchLike; now?: () => number; ttlMs?: number; concurrency?: number } = {},
|
|
208
|
+
) {
|
|
209
|
+
this.fetcher = options.fetcher ?? marketFetch
|
|
210
|
+
this.now = options.now ?? Date.now
|
|
211
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
|
|
212
|
+
this.concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private load(): void {
|
|
216
|
+
if (this.loaded) return
|
|
217
|
+
this.loaded = true
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as Partial<CacheFile>
|
|
220
|
+
if (parsed.schema !== CACHE_SCHEMA || record(parsed.entries) === null) return
|
|
221
|
+
for (const [name, raw] of Object.entries(parsed.entries as Record<string, unknown>)) {
|
|
222
|
+
const entry = record(raw)
|
|
223
|
+
if (entry === null || typeof entry.checkedAt !== 'number' || !validFacts(entry.facts)) continue
|
|
224
|
+
this.entries.set(name, { checkedAt: entry.checkedAt, facts: entry.facts })
|
|
225
|
+
}
|
|
226
|
+
} catch { /* first run or a truncated cache — fetch fresh */ }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private persist(): void {
|
|
230
|
+
try {
|
|
231
|
+
mkdirSync(dirname(this.cacheFile), { recursive: true, mode: 0o700 })
|
|
232
|
+
const newest = [...this.entries.entries()]
|
|
233
|
+
.sort(([, a], [, b]) => b.checkedAt - a.checkedAt)
|
|
234
|
+
.slice(0, MAX_CACHE_ENTRIES)
|
|
235
|
+
this.entries.clear()
|
|
236
|
+
for (const [name, entry] of newest) this.entries.set(name, entry)
|
|
237
|
+
const data: CacheFile = { schema: CACHE_SCHEMA, entries: Object.fromEntries(newest) }
|
|
238
|
+
writeFileSync(this.cacheFile, JSON.stringify(data), { mode: 0o600 })
|
|
239
|
+
} catch { /* cache failure degrades to in-memory lookups */ }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** One semaphore for the whole index, including overlapping HTTP batches. */
|
|
243
|
+
private async withFetchPermit<T>(operation: () => Promise<T>): Promise<T> {
|
|
244
|
+
if (this.activeFetches >= this.concurrency) {
|
|
245
|
+
await new Promise<void>(resolve => this.fetchWaiters.push(resolve))
|
|
246
|
+
}
|
|
247
|
+
this.activeFetches += 1
|
|
248
|
+
try {
|
|
249
|
+
return await operation()
|
|
250
|
+
} finally {
|
|
251
|
+
this.activeFetches -= 1
|
|
252
|
+
this.fetchWaiters.shift()?.()
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async fetchOne(name: string, registry: string): Promise<NpmManifestFacts | null> {
|
|
257
|
+
this.load()
|
|
258
|
+
const now = this.now()
|
|
259
|
+
const cached = this.entries.get(name)
|
|
260
|
+
if (cached !== undefined && now - cached.checkedAt < this.ttlMs) return cached.facts
|
|
261
|
+
if ((this.failures.get(name) ?? 0) > now || this.unavailableUntil > now) return null
|
|
262
|
+
const pending = this.inflight.get(name)
|
|
263
|
+
if (pending !== undefined) return await pending
|
|
264
|
+
|
|
265
|
+
const request = (async (): Promise<NpmManifestFacts | null> => {
|
|
266
|
+
try {
|
|
267
|
+
const response = await this.withFetchPermit(async () => await this.fetcher(
|
|
268
|
+
`${registry}/${encodeURIComponent(name)}/latest`,
|
|
269
|
+
{
|
|
270
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
271
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
|
|
272
|
+
},
|
|
273
|
+
))
|
|
274
|
+
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`)
|
|
275
|
+
const facts = manifestFacts(await response.json())
|
|
276
|
+
this.entries.set(name, { checkedAt: this.now(), facts })
|
|
277
|
+
this.dirty = true
|
|
278
|
+
this.failures.delete(name)
|
|
279
|
+
this.consecutiveFailures = 0
|
|
280
|
+
return facts
|
|
281
|
+
} catch {
|
|
282
|
+
const failedAt = this.now()
|
|
283
|
+
this.failures.set(name, failedAt + FAILURE_COOLDOWN_MS)
|
|
284
|
+
this.consecutiveFailures += 1
|
|
285
|
+
if (this.consecutiveFailures >= this.concurrency) {
|
|
286
|
+
this.unavailableUntil = failedAt + OUTAGE_COOLDOWN_MS
|
|
287
|
+
}
|
|
288
|
+
return null
|
|
289
|
+
} finally {
|
|
290
|
+
this.inflight.delete(name)
|
|
291
|
+
}
|
|
292
|
+
})()
|
|
293
|
+
this.inflight.set(name, request)
|
|
294
|
+
return await request
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Look up a bounded batch while never exceeding the configured fan-out. */
|
|
298
|
+
async lookup(names: readonly string[], registry: string): Promise<Record<string, NpmManifestFacts | null>> {
|
|
299
|
+
const unique = [...new Set(names)]
|
|
300
|
+
const result: Record<string, NpmManifestFacts | null> = {}
|
|
301
|
+
let next = 0
|
|
302
|
+
const worker = async (): Promise<void> => {
|
|
303
|
+
while (next < unique.length) {
|
|
304
|
+
const name = unique[next++]!
|
|
305
|
+
result[name] = await this.fetchOne(name, registry)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker))
|
|
309
|
+
if (this.dirty) {
|
|
310
|
+
this.dirty = false
|
|
311
|
+
this.persist()
|
|
312
|
+
}
|
|
313
|
+
return result
|
|
314
|
+
}
|
|
315
|
+
}
|