feat(upstream): Add upstream proxy/cache subsystem and integrate per-protocol upstreams
This commit is contained in:
@@ -7,6 +7,7 @@ import { BaseRegistry } from '../core/classes.baseregistry.js';
|
||||
import type { RegistryStorage } from '../core/classes.registrystorage.js';
|
||||
import type { AuthManager } from '../core/classes.authmanager.js';
|
||||
import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
|
||||
import type { IProtocolUpstreamConfig } from '../upstream/interfaces.upstream.js';
|
||||
import { toBuffer } from '../core/helpers.buffer.js';
|
||||
import type { IMavenCoordinate, IMavenMetadata, IChecksums } from './interfaces.maven.js';
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
extractGAVFromPom,
|
||||
gavToPath,
|
||||
} from './helpers.maven.js';
|
||||
import { MavenUpstream } from './classes.mavenupstream.js';
|
||||
|
||||
/**
|
||||
* Maven Registry class
|
||||
@@ -31,18 +33,34 @@ export class MavenRegistry extends BaseRegistry {
|
||||
private authManager: AuthManager;
|
||||
private basePath: string = '/maven';
|
||||
private registryUrl: string;
|
||||
private upstream: MavenUpstream | null = null;
|
||||
|
||||
constructor(
|
||||
storage: RegistryStorage,
|
||||
authManager: AuthManager,
|
||||
basePath: string,
|
||||
registryUrl: string
|
||||
registryUrl: string,
|
||||
upstreamConfig?: IProtocolUpstreamConfig
|
||||
) {
|
||||
super();
|
||||
this.storage = storage;
|
||||
this.authManager = authManager;
|
||||
this.basePath = basePath;
|
||||
this.registryUrl = registryUrl;
|
||||
|
||||
// Initialize upstream if configured
|
||||
if (upstreamConfig?.enabled) {
|
||||
this.upstream = new MavenUpstream(upstreamConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources (timers, connections, etc.)
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.upstream) {
|
||||
this.upstream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
@@ -234,7 +252,23 @@ export class MavenRegistry extends BaseRegistry {
|
||||
version: string,
|
||||
filename: string
|
||||
): Promise<IResponse> {
|
||||
const data = await this.storage.getMavenArtifact(groupId, artifactId, version, filename);
|
||||
let data = await this.storage.getMavenArtifact(groupId, artifactId, version, filename);
|
||||
|
||||
// Try upstream if not found locally
|
||||
if (!data && this.upstream) {
|
||||
// Parse the filename to extract extension and classifier
|
||||
const { extension, classifier } = this.parseFilename(filename, artifactId, version);
|
||||
if (extension) {
|
||||
data = await this.upstream.fetchArtifact(groupId, artifactId, version, extension, classifier);
|
||||
if (data) {
|
||||
// Cache the artifact locally
|
||||
await this.storage.putMavenArtifact(groupId, artifactId, version, filename, data);
|
||||
// Generate and store checksums
|
||||
const checksums = await calculateChecksums(data);
|
||||
await this.storeChecksums(groupId, artifactId, version, filename, checksums);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
@@ -462,7 +496,17 @@ export class MavenRegistry extends BaseRegistry {
|
||||
// ========================================================================
|
||||
|
||||
private async getMetadata(groupId: string, artifactId: string): Promise<IResponse> {
|
||||
const metadataBuffer = await this.storage.getMavenMetadata(groupId, artifactId);
|
||||
let metadataBuffer = await this.storage.getMavenMetadata(groupId, artifactId);
|
||||
|
||||
// Try upstream if not found locally
|
||||
if (!metadataBuffer && this.upstream) {
|
||||
const upstreamMetadata = await this.upstream.fetchMetadata(groupId, artifactId);
|
||||
if (upstreamMetadata) {
|
||||
metadataBuffer = Buffer.from(upstreamMetadata, 'utf-8');
|
||||
// Cache the metadata locally
|
||||
await this.storage.putMavenMetadata(groupId, artifactId, metadataBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataBuffer) {
|
||||
// Generate empty metadata if none exists
|
||||
@@ -578,4 +622,41 @@ export class MavenRegistry extends BaseRegistry {
|
||||
|
||||
return contentTypes[extension] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Maven filename to extract extension and classifier.
|
||||
* Filename format: {artifactId}-{version}[-{classifier}].{extension}
|
||||
*/
|
||||
private parseFilename(
|
||||
filename: string,
|
||||
artifactId: string,
|
||||
version: string
|
||||
): { extension: string; classifier?: string } {
|
||||
const prefix = `${artifactId}-${version}`;
|
||||
|
||||
if (!filename.startsWith(prefix)) {
|
||||
// Fallback: just get the extension
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
return { extension: lastDot > 0 ? filename.slice(lastDot + 1) : '' };
|
||||
}
|
||||
|
||||
const remainder = filename.slice(prefix.length);
|
||||
// remainder is either ".extension" or "-classifier.extension"
|
||||
|
||||
if (remainder.startsWith('.')) {
|
||||
return { extension: remainder.slice(1) };
|
||||
}
|
||||
|
||||
if (remainder.startsWith('-')) {
|
||||
const lastDot = remainder.lastIndexOf('.');
|
||||
if (lastDot > 1) {
|
||||
return {
|
||||
classifier: remainder.slice(1, lastDot),
|
||||
extension: remainder.slice(lastDot + 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { extension: '' };
|
||||
}
|
||||
}
|
||||
|
||||
220
ts/maven/classes.mavenupstream.ts
Normal file
220
ts/maven/classes.mavenupstream.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { BaseUpstream } from '../upstream/classes.baseupstream.js';
|
||||
import type {
|
||||
IProtocolUpstreamConfig,
|
||||
IUpstreamFetchContext,
|
||||
IUpstreamRegistryConfig,
|
||||
} from '../upstream/interfaces.upstream.js';
|
||||
import type { IMavenCoordinate } from './interfaces.maven.js';
|
||||
|
||||
/**
|
||||
* Maven-specific upstream implementation.
|
||||
*
|
||||
* Handles:
|
||||
* - Artifact fetching (JAR, POM, WAR, etc.)
|
||||
* - Metadata fetching (maven-metadata.xml)
|
||||
* - Checksum files (.md5, .sha1, .sha256, .sha512)
|
||||
* - SNAPSHOT version handling
|
||||
* - Content-addressable caching for release artifacts
|
||||
*/
|
||||
export class MavenUpstream extends BaseUpstream {
|
||||
protected readonly protocolName = 'maven';
|
||||
|
||||
constructor(
|
||||
config: IProtocolUpstreamConfig,
|
||||
logger?: plugins.smartlog.Smartlog,
|
||||
) {
|
||||
super(config, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an artifact from upstream registries.
|
||||
*/
|
||||
public async fetchArtifact(
|
||||
groupId: string,
|
||||
artifactId: string,
|
||||
version: string,
|
||||
extension: string,
|
||||
classifier?: string,
|
||||
): Promise<Buffer | null> {
|
||||
const path = this.buildArtifactPath(groupId, artifactId, version, extension, classifier);
|
||||
const resource = `${groupId}:${artifactId}`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'maven',
|
||||
resource,
|
||||
resourceType: 'artifact',
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await this.fetch(context);
|
||||
|
||||
if (!result || !result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch maven-metadata.xml from upstream.
|
||||
*/
|
||||
public async fetchMetadata(groupId: string, artifactId: string, version?: string): Promise<string | null> {
|
||||
const groupPath = groupId.replace(/\./g, '/');
|
||||
let path: string;
|
||||
|
||||
if (version) {
|
||||
// Version-level metadata (for SNAPSHOTs)
|
||||
path = `/${groupPath}/${artifactId}/${version}/maven-metadata.xml`;
|
||||
} else {
|
||||
// Artifact-level metadata (lists all versions)
|
||||
path = `/${groupPath}/${artifactId}/maven-metadata.xml`;
|
||||
}
|
||||
|
||||
const resource = `${groupId}:${artifactId}`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'maven',
|
||||
resource,
|
||||
resourceType: 'metadata',
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'application/xml, text/xml',
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await this.fetch(context);
|
||||
|
||||
if (!result || !result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(result.body)) {
|
||||
return result.body.toString('utf8');
|
||||
}
|
||||
|
||||
return typeof result.body === 'string' ? result.body : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a checksum file from upstream.
|
||||
*/
|
||||
public async fetchChecksum(
|
||||
groupId: string,
|
||||
artifactId: string,
|
||||
version: string,
|
||||
extension: string,
|
||||
checksumType: 'md5' | 'sha1' | 'sha256' | 'sha512',
|
||||
classifier?: string,
|
||||
): Promise<string | null> {
|
||||
const basePath = this.buildArtifactPath(groupId, artifactId, version, extension, classifier);
|
||||
const path = `${basePath}.${checksumType}`;
|
||||
const resource = `${groupId}:${artifactId}`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'maven',
|
||||
resource,
|
||||
resourceType: 'checksum',
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'text/plain',
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await this.fetch(context);
|
||||
|
||||
if (!result || !result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(result.body)) {
|
||||
return result.body.toString('utf8').trim();
|
||||
}
|
||||
|
||||
return typeof result.body === 'string' ? result.body.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an artifact exists in upstream (HEAD request).
|
||||
*/
|
||||
public async headArtifact(
|
||||
groupId: string,
|
||||
artifactId: string,
|
||||
version: string,
|
||||
extension: string,
|
||||
classifier?: string,
|
||||
): Promise<{ exists: boolean; size?: number; lastModified?: string } | null> {
|
||||
const path = this.buildArtifactPath(groupId, artifactId, version, extension, classifier);
|
||||
const resource = `${groupId}:${artifactId}`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'maven',
|
||||
resource,
|
||||
resourceType: 'artifact',
|
||||
path,
|
||||
method: 'HEAD',
|
||||
headers: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await this.fetch(context);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
size: result.headers['content-length'] ? parseInt(result.headers['content-length'], 10) : undefined,
|
||||
lastModified: result.headers['last-modified'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path for a Maven artifact.
|
||||
*/
|
||||
private buildArtifactPath(
|
||||
groupId: string,
|
||||
artifactId: string,
|
||||
version: string,
|
||||
extension: string,
|
||||
classifier?: string,
|
||||
): string {
|
||||
const groupPath = groupId.replace(/\./g, '/');
|
||||
let filename = `${artifactId}-${version}`;
|
||||
if (classifier) {
|
||||
filename += `-${classifier}`;
|
||||
}
|
||||
filename += `.${extension}`;
|
||||
|
||||
return `/${groupPath}/${artifactId}/${version}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override URL building for Maven-specific handling.
|
||||
*/
|
||||
protected buildUpstreamUrl(
|
||||
upstream: IUpstreamRegistryConfig,
|
||||
context: IUpstreamFetchContext,
|
||||
): string {
|
||||
let baseUrl = upstream.url;
|
||||
|
||||
// Remove trailing slash
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
|
||||
return `${baseUrl}${context.path}`;
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
*/
|
||||
|
||||
export { MavenRegistry } from './classes.mavenregistry.js';
|
||||
export { MavenUpstream } from './classes.mavenupstream.js';
|
||||
export * from './interfaces.maven.js';
|
||||
export * from './helpers.maven.js';
|
||||
|
||||
Reference in New Issue
Block a user