Files
verdaccio-cache-manager/backend/scanner.js
T
陈银军 f84f21574f fix: 扫描缓存与真实大小计算,解决页面加载慢与数据虚增
- scanner: 大小改为统计实际 .tgz 文件(兼容 {version}.tgz 与 {pkg}-{version}.tgz 命名),
  不再用 unpackedSize 兜底虚增数倍
- server: 扫描结果内存缓存(首次懒扫描,后续请求秒回),新增 POST /api/scan 手动重扫,
  /api/packages 返回 scannedAt 时间戳,clean/删版本后自动失效缓存
- 前端: 概览页新增「重新扫描」按钮 + 「基于 xxx 扫描」时间展示;Top30 在真实环境
  (无访问统计)降级为「缓存占用 Top 30」按大小排序
- compose: 数据卷改 named volume,修复非 root 容器 SQLITE_CANTOPEN
2026-09-19 17:57:58 +08:00

239 lines
7.9 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.
/**
* Verdaccio 缓存目录扫描(§12.1)
* - storage 目录解析优先级:VERDACCIO_STORAGE 环境变量 → UI 设置(settings.storage_dir)
* - 递归扫描目录内所有 package.json,解析 versions/time,匹配 .tgz 计算占用,支持 scoped 包
* - 未配置或无效时进入 demo 模式(VCM_DEMO=1 或首次无配置)
*/
import fs from 'node:fs'
import path from 'node:path'
import { getSetting } from './db.js'
// —— storage 目录解析 ——
export function resolveStorageDir() {
return process.env.VERDACCIO_STORAGE || getSetting('storage_dir') || null
}
export function isValidStorage(dir) {
if (!dir) return false
try {
const st = fs.statSync(dir)
if (!st.isDirectory()) return false
fs.accessSync(dir, fs.constants.R_OK)
return true
} catch {
return false
}
}
export function validateStorage(dir) {
if (!isValidStorage(dir)) return { ok: false, message: '目录不存在或不可读' }
// 至少包含 package.json 或 .tgz 之一,才算有效缓存目录
const files = listFiles(dir, 1)
const hasPkg = files.some(f => f.endsWith('package.json'))
const hasTgz = files.some(f => f.endsWith('.tgz'))
if (!hasPkg && !hasTgz) {
return { ok: false, message: '目录中未找到 package.json / .tgz,不是有效的 Verdaccio 缓存目录' }
}
return { ok: true, message: 'ok' }
}
function listFiles(dir, depth = Infinity, out = []) {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const e of entries) {
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') continue
const full = path.join(dir, e.name)
if (e.isDirectory()) {
if (depth > 0) listFiles(full, depth - 1, out)
} else {
out.push(full)
}
}
return out
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return null
}
}
function fmtMb(bytes) {
return Number((bytes / 1024 / 1024).toFixed(1))
}
// 目录名 → 包名(支持 scoped:@scope/name)
function dirToPkgName(segments) {
if (segments.length >= 2 && segments[0].startsWith('@')) {
return `${segments[0]}/${segments[1]}`
}
return segments[segments.length - 1]
}
// 从 package.json 所在目录向上推导包名
function pkgNameFromDir(pkgDir, storageDir) {
const rel = path.relative(storageDir, pkgDir).split(path.sep)
return dirToPkgName(rel)
}
function parseVersion(v) {
// 去前缀 v、prerelease 后缀仅比较主体
return (String(v).replace(/^v/, '') || '').trim()
}
/**
* 扫描 storage 目录
* @returns {Array} 包列表(versionsDetail: [{version, size(MB), date}])
*/
export function scanStorage(storageDir) {
const packages = []
if (!isValidStorage(storageDir)) return packages
const pkgFiles = listFiles(storageDir).filter(f => f.endsWith('package.json'))
const storageRoot = path.resolve(storageDir)
for (const pkgFile of pkgFiles) {
const pkgDir = path.dirname(pkgFile)
const meta = readJson(pkgFile)
if (!meta) {
// 元数据损坏:标记异常包,不影响其他包
packages.push({
name: pkgNameFromDir(pkgDir, storageRoot),
status: 'broken',
versions: 0,
size: 0,
lastUpdated: null,
versionsDetail: [],
})
continue
}
const versionsObj = meta.versions || {}
const timeObj = meta.time || {}
const distTags = meta['dist-tags'] || {}
// 物理 .tgz 文件:先列目录建立文件名 → 大小索引
// (Verdaccio 各版本命名不同:{version}.tgz 或 {pkgname}-{version}.tgz,不猜、直接扫)
const tgzSizes = new Map()
let tgzTotal = 0
try {
for (const f of fs.readdirSync(pkgDir)) {
if (!f.endsWith('.tgz')) continue
const size = fs.statSync(path.join(pkgDir, f)).size
tgzTotal += size
tgzSizes.set(f.slice(0, -4), size)
}
} catch {}
const versionsDetail = []
// 总大小以实际 .tgz 文件为准;unpackedSize 是解压后大小,会数倍虚增,仅作无物理文件时的兜底
const totalBytes = tgzTotal
for (const [v, info] of Object.entries(versionsObj)) {
let size = tgzSizes.get(v)
if (typeof size !== 'number') {
// 兼容 {pkgname}-{version}.tgz 命名:用元数据 dist.tarball 的文件名精确匹配
const tb = info && info.dist && info.dist.tarball
const bn = tb ? String(tb).split('/').pop() : ''
if (bn.endsWith('.tgz')) {
const hit = tgzSizes.get(bn.slice(0, -4))
if (typeof hit === 'number') size = hit
}
}
if (typeof size !== 'number') {
size = (info && info.dist && typeof info.dist.unpackedSize === 'number') ? info.dist.unpackedSize : 0
}
versionsDetail.push({
version: parseVersion(v),
size: fmtMb(size),
date: timeObj[v] ? timeObj[v].slice(0, 10) : null,
})
}
// 按 semver 数值排序(旧 → 新)
versionsDetail.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
// 最近更新时间:取 time 字段最大
const lastUpdated = Object.keys(timeObj).length
? Object.entries(timeObj).sort((a, b) => b[1].localeCompare(a[1]))[0][1].slice(0, 10)
: null
packages.push({
name: pkgNameFromDir(pkgDir, storageRoot),
versions: versionsDetail.length,
size: fmtMb(totalBytes),
lastUpdated,
visits: 0, // 真实环境无访问统计,界面不依赖真实值
distTags,
versionsDetail,
})
}
return packages
}
// —— demo 模式数据(无真实目录时的演示) ——
const DEMO_SEED = [
['lodash', 25, 4, 12456], ['@antv/g2', 32, 12, 7654], ['axios', 18, 1.2, 9832],
['react', 14, 10, 6543], ['vue', 12, 9, 5218], ['express', 16, 0.8, 4376],
['tensorflow', 24, 90, 2103], ['typescript', 20, 22, 3421], ['webpack', 19, 18, 2876],
['echarts', 15, 16, 1987], ['dayjs', 8, 0.6, 1789], ['uuid', 11, 0.4, 1654],
['vite', 9, 14, 1432], ['prisma', 7, 28, 1120], ['pg', 6, 5, 986],
['three', 10, 30, 876], ['sharp', 5, 24, 654], ['chalk', 6, 0.3, 743],
['semver', 4, 0.3, 532], ['zod', 5, 0.5, 421],
['prettier', 3, 1, 388], ['rollup', 8, 6, 356], ['eslint', 13, 8, 342],
['moment', 20, 2, 312], ['mocha', 7, 1.4, 301], ['cheerio', 9, 3, 287],
['@vue/compiler-sfc', 11, 5, 265], ['ioredis', 10, 4, 254], ['graphql', 12, 7, 243],
['socket.io', 6, 2, 231], ['xlsx', 14, 20, 218], ['d3', 9, 12, 205],
['puppeteer', 4, 15, 187], ['sharp', 5, 24, 176],
]
function pad(n) { return String(n).padStart(2, '0') }
function dateStr(offset) {
const d = new Date(Date.now() - offset * 86400000)
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
}
function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
function genVersions(count) {
const list = []
for (let i = 0; i < count; i++) {
list.push({
version: `1.${rnd(0, 9)}.${rnd(0, 20)}`,
size: Number((rnd(5, 400) / 10).toFixed(1)),
date: dateStr((count - i) * rnd(3, 9)),
})
}
list.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))
return list
}
export function demoPackages() {
const seen = new Set()
return DEMO_SEED.filter(([n]) => !seen.has(n) && seen.add(n)).map(([name, verCount, sizeBase, visits]) => {
const detail = genVersions(verCount)
return {
name,
versions: detail.length,
size: Number((detail.reduce((s, v) => s + v.size, 0)).toFixed(1)),
lastUpdated: detail.length ? detail[detail.length - 1].date : null,
visits,
distTags: { latest: detail.length ? detail[detail.length - 1].version : '1.0.0' },
versionsDetail: detail,
}
})
}
export function demoSummary() {
return {
demo: true,
todayDownloads: 1234,
todayTrend: '+18%',
note: '演示模式:未配置 Verdaccio 缓存目录,展示内置演示数据。',
}
}