181 lines
6.4 KiB
JavaScript
181 lines
6.4 KiB
JavaScript
import { setTimeout as delay } from 'node:timers/promises'
|
|
import { parseStable } from '../../../../platform/lib/contracts.mjs'
|
|
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
|
|
|
|
export class MetadataUnavailableError extends Error {
|
|
constructor() {
|
|
super('signed update metadata has not been published')
|
|
this.name = 'MetadataUnavailableError'
|
|
this.code = 'METADATA_UNAVAILABLE'
|
|
}
|
|
}
|
|
|
|
async function responseBytes(response, label, maxBytes = 10 * 1024 * 1024) {
|
|
if (!response.ok) throw new HttpResponseError(label, response.status)
|
|
const bytes = Buffer.from(await response.arrayBuffer())
|
|
if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`)
|
|
return bytes
|
|
}
|
|
|
|
class HttpResponseError extends Error {
|
|
constructor(label, status) {
|
|
super(`${label} returned HTTP ${String(status)}`)
|
|
this.name = 'HttpResponseError'
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
function retryableRequest(error) {
|
|
return !(error instanceof HttpResponseError)
|
|
|| error.status === 408
|
|
|| error.status === 429
|
|
|| error.status >= 500
|
|
}
|
|
|
|
function exhaustedRequest(label, error, attempts) {
|
|
const timeout = error?.name === 'TimeoutError'
|
|
const detail = error instanceof Error && error.message !== '' ? `: ${error.message}` : ''
|
|
const wrapped = new Error(
|
|
timeout
|
|
? `${label} timed out after ${String(attempts)} attempts`
|
|
: `${label} failed after ${String(attempts)} attempts${detail}`,
|
|
{ cause: error },
|
|
)
|
|
wrapped.name = timeout ? 'TimeoutError' : 'Error'
|
|
if (error?.code !== undefined) wrapped.code = error.code
|
|
wrapped.requestExhausted = true
|
|
return wrapped
|
|
}
|
|
|
|
function timeoutForAttempt(initial, retry, attempt) {
|
|
return attempt === 1 ? initial : retry
|
|
}
|
|
|
|
export class MetadataClient {
|
|
constructor({
|
|
baseUrl,
|
|
trust,
|
|
fetchImpl = fetch,
|
|
attempts = 2,
|
|
retryMs = 250,
|
|
requestTimeoutMs = 5_000,
|
|
retryRequestTimeoutMs = requestTimeoutMs,
|
|
}) {
|
|
this.baseUrl = new URL(baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
|
|
if (this.baseUrl.protocol !== 'https:' && this.baseUrl.hostname !== '127.0.0.1' && this.baseUrl.hostname !== 'localhost') {
|
|
throw new Error('update metadata URL must use HTTPS')
|
|
}
|
|
this.trust = trust
|
|
this.fetchImpl = fetchImpl
|
|
this.attempts = attempts
|
|
this.retryMs = retryMs
|
|
this.requestTimeoutMs = requestTimeoutMs
|
|
this.retryRequestTimeoutMs = retryRequestTimeoutMs
|
|
}
|
|
|
|
async file(name) {
|
|
let lastError
|
|
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
|
|
try {
|
|
const response = await this.fetchImpl(new URL(name, this.baseUrl), {
|
|
signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)),
|
|
})
|
|
if (response.status === 404) throw new MetadataUnavailableError()
|
|
return await responseBytes(response, name)
|
|
} catch (error) {
|
|
if (error instanceof MetadataUnavailableError) throw error
|
|
if (!retryableRequest(error)) throw error
|
|
lastError = error
|
|
if (attempt < this.attempts) await delay(this.retryMs)
|
|
}
|
|
}
|
|
throw exhaustedRequest(name, lastError, this.attempts)
|
|
}
|
|
|
|
async verifiedPair(payloadName, signatureName, accept) {
|
|
let lastError
|
|
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
|
|
try {
|
|
const [payload, signatureBytes] = await Promise.all([
|
|
this.file(payloadName), this.file(signatureName),
|
|
])
|
|
await accept(payload, JSON.parse(signatureBytes.toString('utf8')))
|
|
return payload
|
|
} catch (error) {
|
|
if (error instanceof MetadataUnavailableError
|
|
|| error instanceof HttpResponseError
|
|
|| error?.requestExhausted === true) throw error
|
|
lastError = error
|
|
if (attempt < this.attempts) await delay(this.retryMs)
|
|
}
|
|
}
|
|
throw lastError
|
|
}
|
|
|
|
async check() {
|
|
await this.verifiedPair('keyring.json', 'keyring.sig.json', (bytes, signature) => (
|
|
this.trust.acceptKeyring(bytes, signature)
|
|
))
|
|
const stable = await this.verifiedPair('stable.json', 'stable.sig.json', (bytes, signature) => (
|
|
this.trust.acceptTarget(bytes, signature)
|
|
))
|
|
return Object.freeze({ bytes: stable, value: parseStable(stable) })
|
|
}
|
|
}
|
|
|
|
export class NpmRegistryClient {
|
|
constructor({
|
|
fetchImpl = fetch,
|
|
registry = 'https://registry.npmjs.org/',
|
|
packageName = '@deepseek-ai/dsh',
|
|
attempts = 2,
|
|
retryMs = 250,
|
|
requestTimeoutMs = 5_000,
|
|
retryRequestTimeoutMs = requestTimeoutMs,
|
|
}) {
|
|
this.fetchImpl = fetchImpl
|
|
this.registry = registry
|
|
this.packageName = packageName
|
|
this.attempts = attempts
|
|
this.retryMs = retryMs
|
|
this.requestTimeoutMs = requestTimeoutMs
|
|
this.retryRequestTimeoutMs = retryRequestTimeoutMs
|
|
}
|
|
|
|
async discover(policy = {}) {
|
|
const registry = policy.registry ?? this.registry
|
|
const packageName = policy.packageName ?? this.packageName
|
|
const packagePath = encodeURIComponent(packageName)
|
|
let bytes
|
|
let lastError
|
|
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
|
|
try {
|
|
const response = await this.fetchImpl(new URL(packagePath, registry), {
|
|
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
redirect: 'error',
|
|
signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)),
|
|
})
|
|
bytes = await responseBytes(response, 'npm packument', 20 * 1024 * 1024)
|
|
break
|
|
} catch (error) {
|
|
if (!retryableRequest(error)) throw error
|
|
lastError = error
|
|
if (attempt < this.attempts) await delay(this.retryMs)
|
|
}
|
|
}
|
|
if (bytes === undefined) throw exhaustedRequest('npm packument', lastError, this.attempts)
|
|
let packument
|
|
try { packument = JSON.parse(bytes.toString('utf8')) } catch { throw new Error('npm packument is not valid JSON') }
|
|
const latest = packument?.['dist-tags']?.latest
|
|
const version = typeof latest === 'string' ? packument?.versions?.[latest]?.version : undefined
|
|
if (version !== latest) throw new Error('npm packument has no coherent latest DSH version')
|
|
return Object.freeze({ version })
|
|
}
|
|
|
|
async latest(stable) {
|
|
const found = await this.discover(stable.officialDshPolicy)
|
|
const version = found.version
|
|
return compareDshVersions(version, stable.desired.dsh.version) > 0 ? Object.freeze({ version }) : null
|
|
}
|
|
}
|