Files
verdaccio-cache-manager/backend/pkglist-pull.js
T
yinjun.chen1 bb7d45efcf
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s
feat: 新增热门 npm 包清单,定时收录榜单并支持按清单拉取缓存
- 内置热门包池(248条/12品类) ∪ npm-search 榜单(14组关键词top300),按包名去重增量收录
- 榜单源优先私服 /-/v1/search,不可用时回退官方 registry,仅提示来源不阻塞
- 清单 CRUD + 手动/定时拉取(失败重试3次) + 进度轮询 + 失败包红色角标
- 清单全部完成即失效并联动停止定时拉取,写入新 pending 包自动恢复
- 前端:我的缓存页包清单抽屉、用户名下拉定时配置弹窗、mock 分支同步
2026-09-21 15:47:22 +08:00

202 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 按 npm 包清单拉取 —— 以私服 VERDACCIO_BASE_URL 为镜像源(上游按 Verdaccio uplinks 路由)
*
* 与 sync-latest.js 的差异:
* sync-latest:存量包查最新版(跳过已缓存版本)
* 本模块:按清单逐包拉取(清单即待拉取目标;版本为空时解析 dist-tags.latest)
*
* 拉取流程(异步状态机,前端轮询进度):
* 1. 取清单全部 status='pending' 条目作为目标;
* 2. 首轮拉取全部;
* 3. 失败的包自动连续重试 3 次(round 1..3),每轮只处理上一轮失败项;
* 4. 重试 3 次后仍失败的条目保持在 failedList(状态仍 pending,供角标展示与下次重拉),
* 其余成功条目置 status='done';
* 5. 全部成功后清单失效:pending 为 0 → 手动拉取提示「当前无可用npm包」、
* 定时拉取自动停止,直至清单写入新包。
*
* 环境变量:
* VERDACCIO_BASE_URL 私服地址(默认为 sync-latest 同款缺省值;查询与代拉均走它)
*/
import { getPendingRows, markDone, countPending, getPkgList } from './pkglist.js'
const VERDACCIO_BASE = (process.env.VERDACCIO_BASE_URL || 'http://127.0.0.1:4873').replace(/\/+$/, '')
const QUERY_TIMEOUT = 15000 // 元数据查询超时
const FETCH_TIMEOUT = 10 * 60_000 // tgz 拉取超时(大包需要时间)
const CONCURRENT = 3 // 同时拉取的包数
const MAX_RETRY = 3 // 失败自动连续重试次数
// —— 拉取状态(前端轮询进度;与 syncState 对称,另含轮次信息) ——
export const pkgPullState = {
running: false, done: false,
total: 0, processed: 0,
success: 0, failed: 0,
failedList: [], // [{ name, reason }]
round: 0, // 当前轮次 0=首轮, 1..3=重试轮
current: null, // 正在处理的包名
source: 'manual', // manual | auto(定时)
error: null,
startedAt: null, finishedAt: null,
}
function resetState(source) {
pkgPullState.done = false
pkgPullState.total = 0
pkgPullState.processed = 0
pkgPullState.success = 0
pkgPullState.failed = 0
pkgPullState.failedList = []
pkgPullState.round = 0
pkgPullState.current = null
pkgPullState.source = source
pkgPullState.error = null
pkgPullState.startedAt = new Date().toISOString()
}
// 编码包名:scoped 包 @scope/name → %40scope%2fname
const enc = name => encodeURIComponent(name)
// tarball 文件名:@scope/name → name-version.tgz(Verdaccio 落盘命名不携带 scope 前缀)
const fileOf = (name, version) => `${name.split('/').pop()}-${version}.tgz`
/** GET JSON,带超时;404 返回 null,其余非 2xx 抛可读错误 */
async function fetchJson(url, timeout) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeout)
try {
const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } })
if (r.status === 404) return null
if (!r.ok) throw new Error(`私服响应 ${r.status}`)
return await r.json()
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('请求超时')
throw e
} finally {
clearTimeout(timer)
}
}
/** 触发 Verdaccio 代理拉取:走到该端点时 Verdaccio 会从 uplink 拉取并落盘缓存 */
async function triggerVerdaccio(name, version) {
const url = `${VERDACCIO_BASE}/${enc(name)}/-/${enc(fileOf(name, version))}`
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
try {
const r = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/octet-stream' } })
if (!r.ok) throw new Error(`私服响应 ${r.status}(请确认 VERDACCIO_BASE_URL 配置正确)`)
if (r.body) {
// 丢弃响应体:Verdaccio 已把包写进存储,本次只需等待传输完成
for await (const _ of r.body) { /* drain */ }
}
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('拉取超时')
throw e
} finally {
clearTimeout(timer)
}
}
/**
* 拉取单个清单条目:版本为空 → 解析私服 dist-tags.latest(永远最新)
* 成功返回 true;失败抛错(由上层收集 reason)
*/
async function pullOne(item) {
let version = item.version
if (!version) {
const meta = await fetchJson(`${VERDACCIO_BASE}/${enc(item.pkg_name)}`, QUERY_TIMEOUT)
if (!meta || !meta['dist-tags']) throw new Error('私服无此包或未提供元数据(404)')
version = meta['dist-tags'].latest
if (!version) throw new Error('私服未提供 latest 版本')
}
await triggerVerdaccio(item.pkg_name, version)
return true
}
/**
* 启动按清单拉取(同步启动,立即返回;拉取在后台异步执行,前端轮询进度)
* - running 时重复调用直接返回 started:false
* - 清单失效(无 pending)→ started:false,active:false(手动由路由层提示,定时自动停止)
* @param {{ addLog: Function, invalidateScan: Function, source?: 'manual'|'auto' }} opts
* @returns {{ started: boolean, active: boolean, pendingCount: number }}
*/
export function runPkgListPull({ addLog, invalidateScan, source = 'manual' }) {
if (pkgPullState.running) return { started: false, active: countPending() > 0, pendingCount: countPending() }
const pending0 = countPending()
if (pending0 === 0) {
return { started: false, active: false, pendingCount: 0 }
}
resetState(source)
pkgPullState.running = true
console.log(`[pkglist-pull] 开始按清单拉取(source=${source},共 ${pending0} 个待拉取包,经私服 ${VERDACCIO_BASE})`)
// 后台执行(不阻塞请求/事件循环主路径),结果通过 pkgPullState 暴露
;(async () => {
try {
// 目标集合:先取一次 pending 快照;每成功一个置 done 并计数
let targets = getPendingRows()
pkgPullState.total = targets.length
// —— 首轮 + 重试轮:轮次 0 处理全部,轮次 1..3 仅处理上一轮失败项 ——
for (let round = 0; round <= MAX_RETRY && targets.length > 0; round++) {
pkgPullState.round = round
pkgPullState.failed = 0
pkgPullState.failedList = []
const currentTargets = [...targets]
let next = 0
const worker = async () => {
while (next < currentTargets.length) {
const item = currentTargets[next++]
pkgPullState.current = item.pkg_name
try {
await pullOne(item)
pkgPullState.success += 1
markDone(item.id) // 成功 → 清单 side done
} catch (e) {
pkgPullState.failed += 1
pkgPullState.failedList.push({ name: item.pkg_name, reason: (e && e.message) || '拉取失败' })
}
pkgPullState.processed += 1
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENT, currentTargets.length) }, worker))
// 本轮结束:失败项作为下一轮重试目标(重试前先等所有 worker 完成)
targets = getPendingRows().filter(t => pkgPullState.failedList.some(f => f.name === t.pkg_name))
if (round < MAX_RETRY && targets.length > 0) {
console.log(`[pkglist-pull] 第 ${round + 1} 轮结束:失败 ${targets.length} 个,进入第 ${round + 2} 轮重试`)
}
}
// —— 汇总:失败条目保持 pending(下次可再拉),其余已 done ——
// 注意:循环结束后的 pkgPullState.failedList 即最后一轮失败项的真实 reason
//(若全部成功则为空数组),直接保留即可,不要用统一文案覆盖。
if (!pkgPullState.failedList.length) {
pkgPullState.failed = 0
}
pkgPullState.round = Math.min(pkgPullState.round, MAX_RETRY) // 标记已尽力完成重试
pkgPullState.current = null
addLog({
action: 'list-pull',
pkgName: null,
deletedCount: pkgPullState.success,
deletedVersions: JSON.stringify({
source,
round: pkgPullState.round,
total: pkgPullState.total,
success: pkgPullState.success,
failed: pkgPullState.failedList,
}),
})
invalidateScan() // 有新包入库 → 废弃扫描缓存,列表下次请求重扫
console.log(`[pkglist-pull] 完成(source=${source}):成功 ${pkgPullState.success} · 失败 ${pkgPullState.failed}`)
} catch (e) {
pkgPullState.error = e.message
console.error('[pkglist-pull] 任务异常:', e.message)
} finally {
pkgPullState.running = false
pkgPullState.done = true
pkgPullState.finishedAt = new Date().toISOString()
}
})() // 立即执行(异步,不阻塞调用方)
return { started: true, active: true, pendingCount: pending0 }
}
export { VERDACCIO_BASE as PKGLIST_VERDACCIO_BASE }