import * as plugins from '../plugins.js'; import { BaseUpstream } from '../upstream/classes.baseupstream.js'; import type { IProtocolUpstreamConfig, IUpstreamFetchContext, IUpstreamResult, IUpstreamRegistryConfig, } from '../upstream/interfaces.upstream.js'; import type { IPackument, INpmVersion } from './interfaces.npm.js'; /** * NPM-specific upstream implementation. * * Handles: * - Package metadata (packument) fetching * - Tarball proxying * - Scoped package routing (@scope/* patterns) * - NPM-specific URL rewriting */ export class NpmUpstream extends BaseUpstream { protected readonly protocolName = 'npm'; /** Local registry URL for rewriting tarball URLs */ private readonly localRegistryUrl: string; constructor( config: IProtocolUpstreamConfig, localRegistryUrl: string, logger?: plugins.smartlog.Smartlog, ) { super(config, logger); this.localRegistryUrl = localRegistryUrl; } /** * Fetch a packument from upstream registries. */ public async fetchPackument(packageName: string): Promise { const context: IUpstreamFetchContext = { protocol: 'npm', resource: packageName, resourceType: 'packument', path: `/${encodeURIComponent(packageName).replace('%40', '@')}`, method: 'GET', headers: { 'accept': 'application/json', }, query: {}, }; const result = await this.fetch(context); if (!result || !result.success) { return null; } // Parse and process packument let packument: IPackument; if (Buffer.isBuffer(result.body)) { packument = JSON.parse(result.body.toString('utf8')); } else { packument = result.body; } // Rewrite tarball URLs to point to local registry packument = this.rewriteTarballUrls(packument); return packument; } /** * Fetch a specific version from upstream registries. */ public async fetchVersion(packageName: string, version: string): Promise { const context: IUpstreamFetchContext = { protocol: 'npm', resource: packageName, resourceType: 'version', path: `/${encodeURIComponent(packageName).replace('%40', '@')}/${version}`, method: 'GET', headers: { 'accept': 'application/json', }, query: {}, }; const result = await this.fetch(context); if (!result || !result.success) { return null; } let versionData: INpmVersion; if (Buffer.isBuffer(result.body)) { versionData = JSON.parse(result.body.toString('utf8')); } else { versionData = result.body; } // Rewrite tarball URL if (versionData.dist?.tarball) { versionData.dist.tarball = this.rewriteSingleTarballUrl( packageName, versionData.version, versionData.dist.tarball, ); } return versionData; } /** * Fetch a tarball from upstream registries. */ public async fetchTarball(packageName: string, version: string): Promise { // First, try to get the tarball URL from packument const packument = await this.fetchPackument(packageName); let tarballPath: string; if (packument?.versions?.[version]?.dist?.tarball) { // Extract path from original (upstream) tarball URL const tarballUrl = packument.versions[version].dist.tarball; try { const url = new URL(tarballUrl); tarballPath = url.pathname; } catch { // Fallback to standard NPM tarball path tarballPath = this.buildTarballPath(packageName, version); } } else { tarballPath = this.buildTarballPath(packageName, version); } const context: IUpstreamFetchContext = { protocol: 'npm', resource: packageName, resourceType: 'tarball', path: tarballPath, method: 'GET', headers: { 'accept': 'application/octet-stream', }, query: {}, }; const result = await this.fetch(context); if (!result || !result.success) { return null; } return Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body); } /** * Search packages in upstream registries. */ public async search(text: string, size: number = 20, from: number = 0): Promise { const context: IUpstreamFetchContext = { protocol: 'npm', resource: '*', resourceType: 'search', path: '/-/v1/search', method: 'GET', headers: { 'accept': 'application/json', }, query: { text, size: size.toString(), from: from.toString(), }, }; const result = await this.fetch(context); if (!result || !result.success) { return null; } if (Buffer.isBuffer(result.body)) { return JSON.parse(result.body.toString('utf8')); } return result.body; } /** * Build the standard NPM tarball path. */ private buildTarballPath(packageName: string, version: string): string { // NPM uses: /{package}/-/{package-name}-{version}.tgz // For scoped packages: /@scope/name/-/name-version.tgz if (packageName.startsWith('@')) { const [scope, name] = packageName.split('/'); return `/${scope}/${name}/-/${name}-${version}.tgz`; } else { return `/${packageName}/-/${packageName}-${version}.tgz`; } } /** * Rewrite all tarball URLs in a packument to point to local registry. */ private rewriteTarballUrls(packument: IPackument): IPackument { if (!packument.versions) { return packument; } const rewritten = { ...packument }; rewritten.versions = {}; for (const [version, versionData] of Object.entries(packument.versions)) { const newVersionData = { ...versionData }; if (newVersionData.dist?.tarball) { newVersionData.dist = { ...newVersionData.dist, tarball: this.rewriteSingleTarballUrl( packument.name, version, newVersionData.dist.tarball, ), }; } rewritten.versions[version] = newVersionData; } return rewritten; } /** * Rewrite a single tarball URL to point to local registry. */ private rewriteSingleTarballUrl( packageName: string, version: string, _originalUrl: string, ): string { // Generate local tarball URL // Format: {localRegistryUrl}/{package}/-/{package-name}-{version}.tgz const safeName = packageName.replace('@', '').replace('/', '-'); return `${this.localRegistryUrl}/${packageName}/-/${safeName}-${version}.tgz`; } /** * Override URL building for NPM-specific handling. */ protected buildUpstreamUrl( upstream: IUpstreamRegistryConfig, context: IUpstreamFetchContext, ): string { // NPM registries often don't have trailing slashes let baseUrl = upstream.url; if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1); } return `${baseUrl}${context.path}`; } }