Files
cnpmcore/app/core/service/ProxyCacheService.ts
MK (fengmk2) 89f1250927 refactor: use all in one egg v4 (#855)
required https://github.com/eggjs/egg/pull/5654

---------

Signed-off-by: MK (fengmk2) <fengmk2@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-28 22:17:01 +08:00

437 lines
14 KiB
TypeScript

import {
Context,
HttpClient,
HttpClientRequestOptions,
HttpClientResponse,
AccessLevel, Inject, SingletonProto,
BackgroundTaskHelper,
} from 'egg';
import { ForbiddenError } from 'egg/errors';
import { valid as semverValid } from 'semver';
import { AbstractService } from '../../common/AbstractService.ts';
import type { TaskService } from './TaskService.ts';
import type { CacheService } from './CacheService.ts';
import type { RegistryManagerService } from './RegistryManagerService.ts';
import type { NPMRegistry } from '../../common/adapter/NPMRegistry.ts';
import type { NFSAdapter } from '../../common/adapter/NFSAdapter.ts';
import { ProxyCache } from '../entity/ProxyCache.ts';
import {
type CreateUpdateProxyCacheTask,
type UpdateProxyCacheTaskOptions,
Task,
} from '../entity/Task.ts';
import type { ProxyCacheRepository } from '../../repository/ProxyCacheRepository.ts';
import { TaskState, TaskType } from '../../common/enum/Task.ts';
import { calculateIntegrity } from '../../common/PackageUtil.ts';
import {
ABBREVIATED_META_TYPE,
PROXY_CACHE_DIR_NAME,
} from '../../common/constants.ts';
import { DIST_NAMES, isPkgManifest } from '../entity/Package.ts';
import type {
AbbreviatedPackageJSONType,
AbbreviatedPackageManifestType,
PackageJSONType,
PackageManifestType,
} from '../../repository/PackageRepository.ts';
function isoNow() {
return new Date().toISOString();
}
type GetSourceManifestAndCacheReturnType<T> = T extends
| DIST_NAMES.ABBREVIATED
| DIST_NAMES.MANIFEST
? AbbreviatedPackageJSONType | PackageJSONType
: T extends DIST_NAMES.FULL_MANIFESTS | DIST_NAMES.ABBREVIATED_MANIFESTS
? AbbreviatedPackageManifestType | PackageManifestType
: never;
@SingletonProto({
accessLevel: AccessLevel.PUBLIC,
})
export class ProxyCacheService extends AbstractService {
@Inject()
private readonly httpClient: HttpClient;
@Inject()
private readonly npmRegistry: NPMRegistry;
@Inject()
private readonly nfsAdapter: NFSAdapter;
@Inject()
private readonly proxyCacheRepository: ProxyCacheRepository;
@Inject()
private readonly registryManagerService: RegistryManagerService;
@Inject()
private readonly taskService: TaskService;
@Inject()
private readonly cacheService: CacheService;
@Inject()
private readonly backgroundTaskHelper: BackgroundTaskHelper;
async getPackageVersionTarResponse(
fullname: string,
ctx: Context
): Promise<HttpClientResponse> {
if (this.config.cnpmcore.syncPackageBlockList.includes(fullname)) {
throw new ForbiddenError(
`stop proxy by block list: ${JSON.stringify(this.config.cnpmcore.syncPackageBlockList)}`
);
}
return await this.getProxyResponse(ctx);
}
async getPackageManifest(
fullname: string,
fileType: DIST_NAMES.FULL_MANIFESTS | DIST_NAMES.ABBREVIATED_MANIFESTS
): Promise<AbbreviatedPackageManifestType | PackageManifestType> {
const isFullManifests = fileType === DIST_NAMES.FULL_MANIFESTS;
const proxyCache = await this.proxyCacheRepository.findProxyCache(
fullname,
fileType
);
const cachedStoreKey = proxyCache?.filePath;
if (cachedStoreKey) {
try {
const nfsBytes = await this.nfsAdapter.getBytes(cachedStoreKey);
if (!nfsBytes)
throw new Error('not found proxy cache, try again later.');
const nfsBuffer = Buffer.from(nfsBytes);
const { shasum: etag } = await calculateIntegrity(nfsBytes);
await this.cacheService.savePackageEtagAndManifests(
fullname,
isFullManifests,
etag,
nfsBuffer
);
const nfsString = nfsBuffer.toString();
const nfsPkgManifest = JSON.parse(nfsString);
return nfsPkgManifest as
| AbbreviatedPackageManifestType
| PackageManifestType;
} catch (error) {
/* c8 ignore next 6 */
if (
error.message.includes('not found proxy cache') ||
error.message.includes('Unexpected token : in JSON at')
) {
await this.nfsAdapter.remove(cachedStoreKey);
await this.proxyCacheRepository.removeProxyCache(fullname, fileType);
}
throw error;
}
}
const manifest = await this.getRewrittenManifest<typeof fileType>(
fullname,
fileType
);
this.backgroundTaskHelper.run(async () => {
await this.storeRewrittenManifest(manifest, fullname, fileType);
const cachedFiles = ProxyCache.create({ fullname, fileType });
await this.proxyCacheRepository.saveProxyCache(cachedFiles);
});
return manifest;
}
// used by GET /:fullname/:versionOrTag
async getPackageVersionManifest(
fullname: string,
fileType: DIST_NAMES.ABBREVIATED | DIST_NAMES.MANIFEST,
versionOrTag: string
): Promise<AbbreviatedPackageJSONType | PackageJSONType> {
let version: string;
if (semverValid(versionOrTag)) {
version = versionOrTag;
} else {
const pkgManifest = await this.getPackageManifest(
fullname,
DIST_NAMES.ABBREVIATED_MANIFESTS
);
const distTags = pkgManifest['dist-tags'] || {};
version = distTags[versionOrTag] ?? versionOrTag;
}
const proxyCache = await this.proxyCacheRepository.findProxyCache(
fullname,
fileType,
version
);
const cachedStoreKey = proxyCache?.filePath;
if (cachedStoreKey) {
try {
const nfsBytes = await this.nfsAdapter.getBytes(cachedStoreKey);
if (!nfsBytes)
throw new Error('not found proxy cache, try again later.');
const nfsString = Buffer.from(nfsBytes).toString();
return JSON.parse(nfsString) as
| PackageJSONType
| AbbreviatedPackageJSONType;
} catch (error) {
/* c8 ignore next 6 */
if (
error.message.includes('not found proxy cache') ||
error.message.includes('Unexpected token : in JSON at')
) {
await this.nfsAdapter.remove(cachedStoreKey);
await this.proxyCacheRepository.removeProxyCache(fullname, fileType);
}
throw error;
}
}
const manifest = await this.getRewrittenManifest(
fullname,
fileType,
versionOrTag
);
this.backgroundTaskHelper.run(async () => {
await this.storeRewrittenManifest(manifest, fullname, fileType);
const cachedFiles = ProxyCache.create({ fullname, fileType, version });
await this.proxyCacheRepository.saveProxyCache(cachedFiles);
});
return manifest;
}
async removeProxyCache(
fullname: string,
fileType: DIST_NAMES,
version?: string
) {
const storeKey = isPkgManifest(fileType)
? `/${PROXY_CACHE_DIR_NAME}/${fullname}/${fileType}`
: `/${PROXY_CACHE_DIR_NAME}/${fullname}/${version}/${fileType}`;
await this.nfsAdapter.remove(storeKey);
await this.proxyCacheRepository.removeProxyCache(
fullname,
fileType,
version
);
}
replaceTarballUrl<T extends DIST_NAMES>(
manifest: GetSourceManifestAndCacheReturnType<T>,
fileType: T
) {
const { sourceRegistry, registry } = this.config.cnpmcore;
if (isPkgManifest(fileType)) {
// pkg manifest
const versionMap = (
manifest as AbbreviatedPackageManifestType | PackageManifestType
)?.versions;
for (const key in versionMap) {
const versionItem = versionMap[key];
if (versionItem?.dist?.tarball) {
versionItem.dist.tarball = versionItem.dist.tarball.replace(
sourceRegistry,
registry
);
}
}
} else {
// pkg version manifest
const distItem = (
manifest as AbbreviatedPackageJSONType | PackageJSONType
).dist;
if (distItem?.tarball) {
distItem.tarball = distItem.tarball.replace(sourceRegistry, registry);
}
}
return manifest;
}
async createTask(
targetName: string,
options: UpdateProxyCacheTaskOptions
): Promise<CreateUpdateProxyCacheTask> {
return (await this.taskService.createTask(
Task.createUpdateProxyCache(targetName, options),
false
)) as CreateUpdateProxyCacheTask;
}
async findExecuteTask() {
return await this.taskService.findExecuteTask(TaskType.UpdateProxyCache);
}
async executeTask(task: Task) {
const logs: string[] = [];
const fullname = (task as CreateUpdateProxyCacheTask).data.fullname;
const { fileType, version } = (task as CreateUpdateProxyCacheTask).data;
let cachedManifest;
logs.push(
`[${isoNow()}] 🚧🚧🚧🚧🚧 Start update "${fullname}-${fileType}" 🚧🚧🚧🚧🚧`
);
try {
const cachedFiles = await this.proxyCacheRepository.findProxyCache(
fullname,
fileType
);
if (!cachedFiles)
throw new Error('task params error, can not found record in repo.');
cachedManifest = await this.getRewrittenManifest<typeof fileType>(
fullname,
fileType
);
await this.storeRewrittenManifest(cachedManifest, fullname, fileType);
ProxyCache.update(cachedFiles);
await this.proxyCacheRepository.saveProxyCache(cachedFiles);
} catch (error) {
task.error = error;
logs.push(`[${isoNow()}] ❌ ${task.error}`);
logs.push(
`[${isoNow()}] ❌❌❌❌❌ ${fullname}-${fileType} ${version ?? ''} ❌❌❌❌❌`
);
await this.taskService.finishTask(task, TaskState.Fail, logs.join('\n'));
this.logger.info(
'[ProxyCacheService.executeTask:fail] taskId: %s, targetName: %s, %s',
task.taskId,
task.targetName,
task.error
);
return;
}
logs.push(`[${isoNow()}] 🟢 Update Success.`);
const isFullManifests = fileType === DIST_NAMES.FULL_MANIFESTS;
const cachedKey = await this.cacheService.getPackageEtag(
fullname,
isFullManifests
);
if (cachedKey) {
const cacheBytes = Buffer.from(JSON.stringify(cachedManifest));
const { shasum: etag } = await calculateIntegrity(cacheBytes);
await this.cacheService.savePackageEtagAndManifests(
fullname,
isFullManifests,
etag,
cacheBytes
);
logs.push(`[${isoNow()}] 🟢 Update Cache Success.`);
}
await this.taskService.finishTask(task, TaskState.Success, logs.join('\n'));
}
// only used by schedule task
private async getRewrittenManifest<T extends DIST_NAMES>(
fullname: string,
fileType: T,
versionOrTag?: string
): Promise<GetSourceManifestAndCacheReturnType<T>> {
let responseResult;
const USER_AGENT = 'npm_service.cnpmjs.org/cnpmcore';
switch (fileType) {
case DIST_NAMES.FULL_MANIFESTS: {
const url = `/${encodeURIComponent(fullname)}?t=${Date.now()}&cache=0`;
responseResult = await this.getProxyResponse(
{
url,
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
},
{ dataType: 'json' }
);
break;
}
case DIST_NAMES.ABBREVIATED_MANIFESTS: {
const url = `/${encodeURIComponent(fullname)}?t=${Date.now()}&cache=0`;
responseResult = await this.getProxyResponse(
{
url,
headers: {
accept: ABBREVIATED_META_TYPE,
'user-agent': USER_AGENT,
},
},
{ dataType: 'json' }
);
break;
}
case DIST_NAMES.MANIFEST: {
const url = `/${encodeURIComponent(fullname)}/${encodeURIComponent(versionOrTag ?? '')}`;
responseResult = await this.getProxyResponse(
{
url,
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
},
{ dataType: 'json' }
);
break;
}
case DIST_NAMES.ABBREVIATED: {
const url = `/${encodeURIComponent(fullname)}/${encodeURIComponent(versionOrTag ?? '')}`;
responseResult = await this.getProxyResponse(
{
url,
headers: {
accept: ABBREVIATED_META_TYPE,
'user-agent': USER_AGENT,
},
},
{ dataType: 'json' }
);
break;
}
default: {
break;
}
}
// replace tarball url
const manifest = this.replaceTarballUrl(responseResult?.data, fileType);
return manifest;
}
private async storeRewrittenManifest(
// oxlint-disable-next-line typescript-eslint/no-explicit-any
manifest: any,
fullname: string,
fileType: DIST_NAMES
) {
let storeKey: string;
if (isPkgManifest(fileType)) {
storeKey = `/${PROXY_CACHE_DIR_NAME}/${fullname}/${fileType}`;
} else {
const version = manifest.version;
storeKey = `/${PROXY_CACHE_DIR_NAME}/${fullname}/${version}/${fileType}`;
}
const nfsBytes = Buffer.from(JSON.stringify(manifest));
await this.nfsAdapter.uploadBytes(storeKey, nfsBytes);
}
async getProxyResponse(
ctx: Partial<Context>,
options?: HttpClientRequestOptions
): Promise<HttpClientResponse> {
const registry = this.npmRegistry.registry;
const remoteAuthToken =
await this.registryManagerService.getAuthTokenByRegistryHost(registry);
const authorization =
this.npmRegistry.genAuthorizationHeader(remoteAuthToken);
const url = `${this.npmRegistry.registry}${ctx.url}`;
const res = (await this.httpClient.request(url, {
timing: true,
followRedirect: true,
// once redirection is also count as a retry
retry: 7,
dataType: 'stream',
timeout: 10_000,
compressed: true,
...options,
headers: {
accept: ctx.headers?.accept,
'user-agent': ctx.headers?.['user-agent'],
authorization,
'x-forwarded-for': ctx?.ip,
via: `1.1, ${this.config.cnpmcore.registry}`,
},
})) as HttpClientResponse;
this.logger.info(
'[ProxyCacheService:getProxyStreamResponse] %s, status: %s',
url,
res.status
);
return res;
}
}