Files
deepseek-harness/packages/boot/plugin-market/src/index.ts
T
turtle1999 9c8522bb64 feat(plugin-detail): show Markdown plugin details on bundle and market entry pages
Plugins bundle pages and Discover entry pages show the plugin's long
description first, as Markdown with images, in a height-limited region
without its own heading. The document is the file locale meta.detail
names, otherwise the package README, otherwise (market entries only)
the declared repository's README.

The new dsh-plugin-detail library selects documents and reads local
package directories and npm file CDNs without state. plugin-manager
reads installed and offered bundles through readBundleDetail; plugin-market
reads market entries through readEntryDetail, single-file APIs, and a
size-limited partial clone cache serialized across processes. Raw HTML is
omitted, relative links stay text, and relative images load from Host
routes with a sandbox CSP.
2026-10-19 19:13:22 +08:00

517 lines
26 KiB
TypeScript

/** Host reader of configured plugin market indexes for the Discover page and the `plugin_market` tool. */
import { isAbsolute, join, resolve } from 'node:path'
import type { Context, Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import semver from 'semver'
import { evaluatePluginCompatibility, getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot'
import { marketInstallTarget, type MarketIndex, type MarketPlugin, type MarketVersion } from '@deepseek-ai/dsh-plugin-market-index'
import type { BundleInfo } from '@deepseek-ai/dsh-plugin-manager/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
DetailFailure, detailImageResponse, languageChain, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles,
} from '@deepseek-ai/dsh-plugin-detail'
import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types'
import { DetailReader, repositoryOf, type DetailLocation, type RepositoryLocation } from './detail.ts'
import { repositoryFiles, type RepositoryFileOptions } from './detail-files.ts'
import { normalizeIndexUrl } from './index-url.ts'
import { RepositoryCache } from './repository-cache.ts'
import { parseIndexInWorker } from './parse-thread.ts'
import { MarketSearch } from './search.ts'
// Type-only: the `pluginManager` service, `settings.update`, the Loader's `loader/volatile-update` event, and the
// `connection` Fetch routes.
import type {} from '@deepseek-ai/dsh-plugin-manager'
import type {} from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {
MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketListingEntry, MarketListingVersion, MarketQuery, MarketSearchResult,
PluginDetail,
} from './types.ts'
export type * from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Configured plugin market index sources and their validated listings. */
pluginMarket: PluginMarket
}
}
/** Index sources and fetch limits. */
export interface Config {
/** Index URLs in display order; Settings and `setIndexes` write this field without remounting the service. */
indexes: Volatile<string[]>
/** Deadline for one index fetch, including the body. */
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
/** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
/** Largest detail document in bytes. */
detailMaxBytes: number
/** Largest image beside a detail document in bytes. */
detailImageMaxBytes: number
/** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
detailRepositoryMaxBytes: number
/** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
detailCacheMaxBytes: number
/** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
detailCacheDir?: string
/** Deadline of one Git command of the clone fallback. */
detailGitTimeoutMs: number
/** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources: NpmFileSource[]
/** Hosts serving the GitLab repository files API. */
detailGitlabHosts: string[]
}
/** The exact diagnostic of a failure, including a non-Error rejection. */
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Causes followed below a rejection; a deeper or cyclic chain is cut there. */
const MAX_CAUSES = 4
/** A rejection with its cause chain: a Node `fetch` failure is a bare `fetch failed` whose reason is its cause. */
function causeChainOf(error: unknown, depth = 0): string {
return error instanceof Error && error.cause !== undefined && depth < MAX_CAUSES
? `${error.message}: ${causeChainOf(error.cause, depth + 1)}`
: messageOf(error)
}
/** The Fetch route serving images beside detail documents. */
const DETAIL_FILE_PATH = '/api/plugin-market/detail-file'
/** Registries whose packages the public npm file CDNs serve. */
const PUBLIC_REGISTRIES = new Set(['registry.npmjs.org', 'registry.npmmirror.com'])
/** Largest page `search` returns. */
const MAX_LIMIT = 100
/** One fetched index document with its search state. */
interface IndexDocument {
readonly index: MarketIndex
/** Unix milliseconds. */
readonly fetchedAt: number
readonly search: MarketSearch
}
interface CachedIndex {
readonly document: IndexDocument
readonly expiresAt: number
}
/** Fetches, validates, and caches configured market indexes; annotates entries with the profile's installation facts. */
export default class PluginMarket extends TypertRemoteService {
static inject = ['pluginManager']
static Config = z.object({
indexes: z.array(z.string().pattern(/^https?:\/\/\S+$/u)).default([]).volatile(),
fetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000),
maxBytes: z.natural().min(1).default(128 * 1024 * 1024),
cacheTtlMs: z.natural().default(600000),
detailMaxBytes: z.natural().min(1).default(1024 * 1024),
detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024),
detailRepositoryMaxBytes: z.natural().min(1).default(50 * 1024 * 1024),
detailCacheMaxBytes: z.natural().min(1).default(500 * 1024 * 1024),
detailCacheDir: z.string(),
detailGitTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(60000),
detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]),
detailGitlabHosts: z.array(z.string()).default(['gitlab.com']),
})
private readonly lifetime = new AbortController()
private readonly cache = new Map<string, CachedIndex>()
private readonly pending = new Map<string, Promise<IndexDocument>>()
private readonly errors = new Map<string, string>()
private readonly entryId: string | undefined
private readonly repositories: RepositoryCache
private readonly details: DetailReader
constructor(ctx: Context, private readonly config: Config) {
super(ctx, 'pluginMarket')
this.entryId = ctx.fiber.entry?.options.id
const profile = ctx.get('profileContext')
const cacheDir = profile === undefined
? config.detailCacheDir
: resolve(profile.dir, config.detailCacheDir ?? join('.plugin-market', 'repositories'))
if (cacheDir === undefined || !isAbsolute(cacheDir)) throw new Error('plugin-market: detailCacheDir must be an absolute path when the service runs without a dsh profile')
const lifetime = this.lifetime.signal
this.repositories = new RepositoryCache({
dir: cacheDir, maxRepositoryBytes: config.detailRepositoryMaxBytes, maxCacheBytes: config.detailCacheMaxBytes,
// The URL forms repositories are read from, and ssh, which `url.<base>.insteadOf` may route them to.
timeoutMs: config.detailGitTimeoutMs, ttlMs: config.cacheTtlMs, transports: ['https', 'http', 'ssh'], lifetime,
// Bounds how far a fetch overshoots `detailRepositoryMaxBytes`; directory walks cost little at this rate.
sizePollMs: 200,
})
this.details = new DetailReader(
{ maxDocumentBytes: config.detailMaxBytes, maxImageBytes: config.detailImageMaxBytes, ttlMs: config.cacheTtlMs },
this.detailRepositoryOptions(),
)
ctx.inject(['connection'], (scope) => {
scope.effect(() => scope.connection.fetch.register({
path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request),
}), `plugin-market: ${DETAIL_FILE_PATH}`)
})
ctx.on('loader/volatile-update', () => {
const configured = new Set(this.sources().flatMap(source => source.error === undefined ? [source.url] : []))
for (const url of [...this.cache.keys(), ...this.errors.keys()]) {
if (!configured.has(url)) {
this.cache.delete(url)
this.errors.delete(url)
}
}
ctx.emit('plugin-market/changed')
})
ctx.effect(() => async () => {
this.lifetime.abort(new Error('Plugin market disposed'))
await Promise.allSettled([...this.pending.values(), this.repositories.settled()])
})
}
/**
* List the configured index sources without fetching them.
* @returns every source in order, with the title of its last successful read and, when the last read failed, its error.
*/
@Remote
listIndexes(): MarketIndexSummary[] {
return this.sources().map(({ url, error: invalid }) => {
const index = this.cache.get(url)?.document.index
const error = invalid ?? this.errors.get(url)
return {
url,
...index === undefined ? {} : { title: index.title, ...index.description === undefined ? {} : { description: index.description } },
...error === undefined ? {} : { error },
}
})
}
/**
* Replace the configured index sources through Settings, without remounting this service.
* `plugin-market/changed` announces the committed list. A URL already configured is kept as written, so a client can
* add or remove sources beside one that `listIndexes` reports as unusable.
* @param urls - index URLs in display order.
* @throws {RemoteError} `plugin-market/invalid-index-url` for an invalid new URL or a repeated URL;
* `plugin-market/settings-unavailable` without Settings or a profile entry.
*/
@Remote
async setIndexes(urls: string[]): Promise<void> {
const configured = new Set(this.config.indexes.get())
const normalized = urls.map(url => configured.has(url) ? url : normalizeIndexUrl(url))
// A kept URL compares by its canonical form when it has one, so a new spelling of it counts as a repeat.
const keys = normalized.map(url => URL.canParse(url) ? new URL(url).href : url)
const repeated = normalized.find((_url, position) => keys.indexOf(keys[position] as string) !== position)
if (repeated !== undefined) {
throw new RemoteError('plugin-market/invalid-index-url', `Index source listed twice: ${repeated}`, { url: repeated, reason: 'duplicate' })
}
const settings = this.ctx.get('settings')
if (settings === undefined || this.entryId === undefined) {
throw new RemoteError('plugin-market/settings-unavailable', 'Index sources require the settings service and a profile entry', {})
}
await settings.update(this.entryId, { indexes: normalized })
}
/**
* Return one page of the plugins of a configured index that match a query. Concurrent reads of a URL share one
* fetch; a successful document is reused for `cacheTtlMs` unless `refresh` is set. Matching and ordering read only
* the document; installation status and compatibility are computed for the returned page on every call.
* @param url - a configured index URL.
* @param query - text, filters, order, page, and whether to bypass the cached document.
* @returns the index header, the page's plugins with each plugin's default version and installation status and each
* version's compatibility and install arguments, the number of matches, and the next page's offset.
* @throws {RemoteError} `plugin-market/invalid-query`, `plugin-market/unknown-index`, `plugin-market/invalid-index-url`,
* `plugin-market/fetch-failed`, or `plugin-market/invalid-index`.
*/
@Remote
async search(url: string, query: MarketQuery): Promise<MarketSearchResult> {
if (!Number.isSafeInteger(query.offset) || query.offset < 0) {
throw new RemoteError('plugin-market/invalid-query', 'offset must be a non-negative integer', { field: 'offset', reason: 'range' })
}
if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > MAX_LIMIT) {
throw new RemoteError('plugin-market/invalid-query', `limit must be an integer from 1 to ${String(MAX_LIMIT)}`, { field: 'limit', reason: 'range' })
}
const document = await this.document(url, query.refresh === true, query.fetchedAt)
const matching = await document.search.select(query)
const page = matching.slice(query.offset, query.offset + query.limit)
const listing = await this.annotator()
const next = query.offset + page.length
const { index } = document
return {
url,
title: index.title,
...index.description === undefined ? {} : { description: index.description },
metadata: index.metadata,
fetchedAt: document.fetchedAt,
entries: page.map(listing),
total: matching.length,
nextOffset: next < matching.length ? next : null,
}
}
/**
* Read one plugin of a configured index, using the cached document as `search` does.
* @param url - a configured index URL.
* @param name - the plugin's package name.
* @returns the plugin with its default version, installation status, and annotated versions; undefined when the
* index does not list it.
* @throws {RemoteError} `plugin-market/unknown-index`, `plugin-market/invalid-index-url`, `plugin-market/fetch-failed`,
* or `plugin-market/invalid-index`.
*/
async entry(url: string, name: string): Promise<MarketListingEntry | undefined> {
const plugin = (await this.document(url, false)).index.plugins.find(candidate => candidate.name === name)
return plugin === undefined ? undefined : (await this.annotator())(plugin)
}
/**
* Read the long description of one listed version of a plugin market entry, from the version's source: the npm file
* CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names
* in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm
* version without one, of its `package.json` `repository`, at the default branch. An npm version from another
* registry has no readable package files, so only the entry's `repository` is read.
* @param url - a configured index URL.
* @param name - the plugin's package name.
* @param version - one listed version.
* @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
* @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names
* npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not
* configured are not asked.
* @returns the document with its origin and image document id, or `none`.
* @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version,
* `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.
*/
@Remote
async readEntryDetail(
url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] },
): Promise<PluginDetail> {
const languages = this.languages(language)
const plugin = (await this.document(url, false)).index.plugins.find(candidate => candidate.name === name)
const listed = plugin?.versions.find(candidate => candidate.version === version)
if (plugin === undefined || listed === undefined) {
throw new RemoteError('plugin-market/unknown-plugin', `${url} lists no ${name}@${version}`, { name, version })
}
const declared = plugin.repository
const found = declared === undefined ? undefined : repositoryOf(declared.url)
let repository: RepositoryLocation | undefined = found === undefined ? undefined : { ...found, directory: declared?.directory ?? '' }
const { source } = listed
let location: DetailLocation
if (source.type === 'git') {
const ref = repositoryOf(source.url)
/* v8 ignore next -- parseMarketIndex admits only https Git URLs with an owner and a project */
if (ref === undefined) return { status: 'none' }
const dir = source.path ?? ''
// A repository that names the source directory itself adds nothing to the source.
if (repository?.url === ref.url && repository.directory === dir) repository = undefined
location = { files: repositoryFiles(ref, source.commit, this.detailRepositoryOptions()), dir, repository }
} else if (source.registry === undefined || PUBLIC_REGISTRIES.has(new URL(source.registry).host)) {
location = this.npmLocation(name, version, repository ?? 'manifest', options?.npmFileSources ?? [])
} else {
location = { dir: '', repository }
}
return await this.readDetail(location, languages, options?.refresh === true)
}
private languages(language: string): string[] {
try {
return languageChain(language)
} catch (error) {
throw new RemoteError('plugin-market/invalid-query', messageOf(error), { field: 'language', reason: 'format' }, { cause: error })
}
}
private detailRepositoryOptions(): RepositoryFileOptions {
const { config } = this
return {
timeoutMs: config.fetchTimeoutMs, lifetime: this.lifetime.signal,
gitlabHosts: config.detailGitlabHosts.map(host => host.toLowerCase()), cache: this.repositories,
}
}
/** The detail location of a published npm version on the configured CDNs, the preferred ones first. */
private npmLocation(name: string, version: string, repository: RepositoryLocation | 'manifest', preferred: readonly string[]): DetailLocation {
const sources = npmFileSourceOrder(this.config.detailNpmFileSources, preferred)
return { dir: '', repository, files: npmFiles(name, version, sources, { timeoutMs: this.config.fetchTimeoutMs, lifetime: this.lifetime.signal }) }
}
private async readDetail(location: DetailLocation, languages: readonly string[], refresh: boolean): Promise<PluginDetail> {
try {
return await this.details.read(location, languages, refresh)
} catch (error) {
if (!(error instanceof DetailFailure)) throw error
throw new RemoteError('plugin-market/detail-unavailable', error.message, { reason: error.reason }, { cause: error })
}
}
/** Answer `GET ${DETAIL_FILE_PATH}?document=<id>&path=<destination>` with one image beside a read detail document. */
private async serveDetailFile(request: Request): Promise<Response> {
const params = new URL(request.url).searchParams
const document = params.get('document')
const path = params.get('path')
// Images stay cached as long as their document's result.
const cache = `private, max-age=${String(Math.floor(this.config.cacheTtlMs / 1000))}`
if (document === null || path === null) return detailImageResponse('invalid', cache)
try {
return detailImageResponse(await this.details.image(document, path), cache)
} catch (error) {
if (!(error instanceof DetailFailure)) throw error
return detailImageResponse(error.reason, cache)
}
}
private async document(url: string, refresh: boolean, fetchedAt?: number): Promise<IndexDocument> {
const source = this.sources().find(candidate => candidate.url === url)
if (source === undefined) throw new RemoteError('plugin-market/unknown-index', `Not a configured index source: ${url}`, { url })
if (source.error !== undefined) {
throw new RemoteError('plugin-market/invalid-index-url', source.error, { url, reason: 'configured' })
}
const cached = this.cache.get(url)
const usable = cached !== undefined && (cached.expiresAt > Date.now() || cached.document.fetchedAt === fetchedAt)
return !refresh && usable ? cached.document : await this.fetchShared(url)
}
/** Annotate plugins with the profile's bundles and exemptions as they are now. */
private async annotator(): Promise<(plugin: MarketPlugin) => MarketListingEntry> {
const [bundles, { exemptions }] = [await this.ctx.pluginManager.listBundles(), this.ctx.pluginManager.listVersionExemptions()]
const runtimeVersion = getDshRuntimeVersion()
return plugin => listingOf(plugin, bundles, exemptions, runtimeVersion)
}
/**
* The configured sources as written, each with the refusal that makes it unusable. Settings and profile files can
* write any http(s) string, so every use validates instead of the schema; an unusable source is listed with its error
* and never fetched, and a repeated URL counts once.
*/
private sources(): { readonly url: string; readonly error?: string }[] {
const seen = new Set<string>()
return this.config.indexes.get().flatMap((url) => {
if (seen.has(url)) return []
seen.add(url)
try {
return normalizeIndexUrl(url) === url ? [{ url }] : [{ url, error: `Index URL is not in canonical form; use ${normalizeIndexUrl(url)}` }]
} catch (error) {
return [{ url, error: messageOf(error) }]
}
})
}
private configured(url: string): boolean {
return this.sources().some(source => source.url === url && source.error === undefined)
}
private fetchShared(url: string): Promise<IndexDocument> {
let pending = this.pending.get(url)
if (pending === undefined) {
pending = this.fetchIndex(url).then((index) => {
// Distinct per URL even when two fetches finish within one millisecond.
const fetchedAt = Math.max(Date.now(), (this.cache.get(url)?.document.fetchedAt ?? 0) + 1)
const document = { index, fetchedAt, search: new MarketSearch(index) }
if (this.configured(url)) {
this.cache.set(url, { document, expiresAt: Date.now() + this.config.cacheTtlMs })
this.errors.delete(url)
}
return document
}, (error: unknown) => {
if (this.configured(url)) this.errors.set(url, messageOf(error))
throw error
}).finally(() => { this.pending.delete(url) })
this.pending.set(url, pending)
}
return pending
}
private async fetchIndex(url: string): Promise<MarketIndex> {
const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.config.fetchTimeoutMs)])
const failed = (reason: string, cause?: unknown): RemoteError =>
new RemoteError('plugin-market/fetch-failed', `Could not fetch ${url}: ${reason}`, { url, reason }, { cause })
let response: Response
try {
response = await fetch(url, { signal, redirect: 'error', headers: { accept: 'application/json' } })
} catch (error) {
throw failed(causeChainOf(error), error)
}
if (!response.ok) {
await response.body?.cancel()
throw failed(`HTTP ${String(response.status)}`)
}
const bytes = await this.readBody(response, failed)
const result = await parseIndexInWorker(bytes, url, this.lifetime.signal).catch((error: unknown) => {
throw failed(messageOf(error), error)
})
if (result.ok) return result.index
throw new RemoteError('plugin-market/invalid-index', `${url}: ${result.message}`, { url, path: result.path, reason: result.reason })
}
/** Read the whole body, refusing it as soon as it exceeds `maxBytes`. */
private async readBody(response: Response, failed: (reason: string, cause?: unknown) => RemoteError): Promise<Uint8Array> {
const limit = this.config.maxBytes
const tooLarge = `body exceeds ${String(limit)} bytes`
if (Number(response.headers.get('content-length') ?? 0) > limit) {
await response.body?.cancel()
throw failed(tooLarge)
}
const chunks: Uint8Array[] = []
let size = 0
try {
for await (const chunk of response.body ?? []) {
size += chunk.byteLength
if (size > limit) throw failed(tooLarge)
chunks.push(chunk)
}
} catch (error) {
if (error instanceof RemoteError) throw error
throw failed(messageOf(error), error)
}
const body = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return body
}
}
function listingOf(
plugin: MarketPlugin, bundles: readonly BundleInfo[], exemptions: Readonly<Record<string, readonly string[]>>, runtimeVersion: string,
): MarketListingEntry {
const versions = plugin.versions.map((version): MarketListingVersion => ({
...version,
install: marketInstallTarget(plugin.name, version),
compatibility: compatibilityOf(plugin.name, version, exemptions, runtimeVersion),
}))
const compatible = versions.filter(version => version.compatibility.compatible)
const stable = plugin.versions.some(version => semver.prerelease(version.version) === null)
const defaultVersion = compatible.find(version => !stable || semver.prerelease(version.version) === null)?.version
/* v8 ignore next -- parseMarketIndex guarantees a non-empty version list */
const measured = defaultVersion ?? versions[0]?.version ?? ''
return {
...plugin,
versions,
...defaultVersion === undefined ? {} : { defaultVersion },
installation: installationOf(plugin.name, measured, bundles),
}
}
function installationOf(name: string, version: string, bundles: readonly BundleInfo[]): MarketInstallation {
const bundle = bundles.find(candidate => candidate.name === name)
// An on-demand catalog entry is listed before the profile installs it.
if (bundle === undefined || (!bundle.installed && bundle.availability !== 'installation')) return { status: 'not-installed' }
if (!bundle.installed) return { status: 'provided', ...bundle.version === undefined ? {} : { installedVersion: bundle.version } }
if (bundle.version === undefined) return { status: 'different-version' }
const installedVersion = bundle.version
if (installedVersion === version) return { status: 'installed', installedVersion }
const newer = semver.valid(installedVersion) !== null && semver.gt(version, installedVersion)
return { status: newer ? 'update-available' : 'different-version', installedVersion }
}
function compatibilityOf(
name: string, version: MarketVersion, exemptions: Readonly<Record<string, readonly string[]>>, runtimeVersion: string,
): MarketCompatibility {
const manifest = { name, version: version.version, ...version.engines.dsh === undefined ? {} : { engines: { dsh: version.engines.dsh } } }
const issue = evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)
if (issue === undefined) return { compatible: true }
return { compatible: false, runtimeVersion: issue.runtimeVersion, requirements: issue.requirements, exempted: issue.exempted }
}