feat(upstream): Add upstream proxy/cache subsystem and integrate per-protocol upstreams
This commit is contained in:
@@ -3,6 +3,7 @@ import { BaseRegistry } from '../core/classes.baseregistry.js';
|
||||
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
||||
import { 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 type {
|
||||
IRubyGemsMetadata,
|
||||
IRubyGemsVersionMetadata,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
ICompactIndexInfoEntry,
|
||||
} from './interfaces.rubygems.js';
|
||||
import * as helpers from './helpers.rubygems.js';
|
||||
import { RubygemsUpstream } from './classes.rubygemsupstream.js';
|
||||
|
||||
/**
|
||||
* RubyGems registry implementation
|
||||
@@ -23,12 +25,14 @@ export class RubyGemsRegistry extends BaseRegistry {
|
||||
private basePath: string = '/rubygems';
|
||||
private registryUrl: string;
|
||||
private logger: Smartlog;
|
||||
private upstream: RubygemsUpstream | null = null;
|
||||
|
||||
constructor(
|
||||
storage: RegistryStorage,
|
||||
authManager: AuthManager,
|
||||
basePath: string = '/rubygems',
|
||||
registryUrl: string = 'http://localhost:5000/rubygems'
|
||||
registryUrl: string = 'http://localhost:5000/rubygems',
|
||||
upstreamConfig?: IProtocolUpstreamConfig
|
||||
) {
|
||||
super();
|
||||
this.storage = storage;
|
||||
@@ -48,6 +52,20 @@ export class RubyGemsRegistry extends BaseRegistry {
|
||||
}
|
||||
});
|
||||
this.logger.enableConsole();
|
||||
|
||||
// Initialize upstream if configured
|
||||
if (upstreamConfig?.enabled) {
|
||||
this.upstream = new RubygemsUpstream(upstreamConfig, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources (timers, connections, etc.)
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.upstream) {
|
||||
this.upstream.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
@@ -215,7 +233,17 @@ export class RubyGemsRegistry extends BaseRegistry {
|
||||
* Handle /info/{gem} endpoint (Compact Index)
|
||||
*/
|
||||
private async handleInfoFile(gemName: string): Promise<IResponse> {
|
||||
const content = await this.storage.getRubyGemsInfo(gemName);
|
||||
let content = await this.storage.getRubyGemsInfo(gemName);
|
||||
|
||||
// Try upstream if not found locally
|
||||
if (!content && this.upstream) {
|
||||
const upstreamInfo = await this.upstream.fetchInfo(gemName);
|
||||
if (upstreamInfo) {
|
||||
// Cache locally
|
||||
await this.storage.putRubyGemsInfo(gemName, upstreamInfo);
|
||||
content = upstreamInfo;
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
@@ -245,12 +273,21 @@ export class RubyGemsRegistry extends BaseRegistry {
|
||||
return this.errorResponse(400, 'Invalid gem filename');
|
||||
}
|
||||
|
||||
const gemData = await this.storage.getRubyGemsGem(
|
||||
let gemData = await this.storage.getRubyGemsGem(
|
||||
parsed.name,
|
||||
parsed.version,
|
||||
parsed.platform
|
||||
);
|
||||
|
||||
// Try upstream if not found locally
|
||||
if (!gemData && this.upstream) {
|
||||
gemData = await this.upstream.fetchGem(parsed.name, parsed.version);
|
||||
if (gemData) {
|
||||
// Cache locally
|
||||
await this.storage.putRubyGemsGem(parsed.name, parsed.version, gemData, parsed.platform);
|
||||
}
|
||||
}
|
||||
|
||||
if (!gemData) {
|
||||
return this.errorResponse(404, 'Gem not found');
|
||||
}
|
||||
|
||||
230
ts/rubygems/classes.rubygemsupstream.ts
Normal file
230
ts/rubygems/classes.rubygemsupstream.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { BaseUpstream } from '../upstream/classes.baseupstream.js';
|
||||
import type {
|
||||
IProtocolUpstreamConfig,
|
||||
IUpstreamFetchContext,
|
||||
IUpstreamRegistryConfig,
|
||||
} from '../upstream/interfaces.upstream.js';
|
||||
|
||||
/**
|
||||
* RubyGems-specific upstream implementation.
|
||||
*
|
||||
* Handles:
|
||||
* - Compact Index format (/versions, /info/{gem}, /names)
|
||||
* - Gem file (.gem) downloading
|
||||
* - Gem spec fetching
|
||||
* - HTTP Range requests for incremental updates
|
||||
*/
|
||||
export class RubygemsUpstream extends BaseUpstream {
|
||||
protected readonly protocolName = 'rubygems';
|
||||
|
||||
constructor(
|
||||
config: IProtocolUpstreamConfig,
|
||||
logger?: plugins.smartlog.Smartlog,
|
||||
) {
|
||||
super(config, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the /versions file (master list of all gems).
|
||||
*/
|
||||
public async fetchVersions(etag?: string): Promise<{ data: string; etag?: string } | null> {
|
||||
const headers: Record<string, string> = {
|
||||
'accept': 'text/plain',
|
||||
};
|
||||
|
||||
if (etag) {
|
||||
headers['if-none-match'] = etag;
|
||||
}
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'rubygems',
|
||||
resource: '*',
|
||||
resourceType: 'versions',
|
||||
path: '/versions',
|
||||
method: 'GET',
|
||||
headers,
|
||||
query: {},
|
||||
};
|
||||
|
||||
const result = await this.fetch(context);
|
||||
|
||||
if (!result || !result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data: string;
|
||||
if (Buffer.isBuffer(result.body)) {
|
||||
data = result.body.toString('utf8');
|
||||
} else if (typeof result.body === 'string') {
|
||||
data = result.body;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
etag: result.headers['etag'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch gem info file (/info/{gemname}).
|
||||
*/
|
||||
public async fetchInfo(gemName: string): Promise<string | null> {
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'rubygems',
|
||||
resource: gemName,
|
||||
resourceType: 'info',
|
||||
path: `/info/${gemName}`,
|
||||
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');
|
||||
}
|
||||
|
||||
return typeof result.body === 'string' ? result.body : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the /names file (list of all gem names).
|
||||
*/
|
||||
public async fetchNames(): Promise<string | null> {
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'rubygems',
|
||||
resource: '*',
|
||||
resourceType: 'names',
|
||||
path: '/names',
|
||||
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');
|
||||
}
|
||||
|
||||
return typeof result.body === 'string' ? result.body : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a gem file.
|
||||
*/
|
||||
public async fetchGem(gemName: string, version: string): Promise<Buffer | null> {
|
||||
const path = `/gems/${gemName}-${version}.gem`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'rubygems',
|
||||
resource: gemName,
|
||||
resourceType: 'gem',
|
||||
path,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch gem spec (quick spec).
|
||||
*/
|
||||
public async fetchQuickSpec(gemName: string, version: string): Promise<Buffer | null> {
|
||||
const path = `/quick/Marshal.4.8/${gemName}-${version}.gemspec.rz`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'rubygems',
|
||||
resource: gemName,
|
||||
resourceType: 'spec',
|
||||
path,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch gem versions JSON from API.
|
||||
*/
|
||||
public async fetchVersionsJson(gemName: string): Promise<any[] | null> {
|
||||
const path = `/api/v1/versions/${gemName}.json`;
|
||||
|
||||
const context: IUpstreamFetchContext = {
|
||||
protocol: 'rubygems',
|
||||
resource: gemName,
|
||||
resourceType: 'versions-json',
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
},
|
||||
query: {},
|
||||
};
|
||||
|
||||
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 Array.isArray(result.body) ? result.body : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override URL building for RubyGems-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}`;
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@
|
||||
|
||||
export * from './interfaces.rubygems.js';
|
||||
export * from './classes.rubygemsregistry.js';
|
||||
export { RubygemsUpstream } from './classes.rubygemsupstream.js';
|
||||
export * as rubygemsHelpers from './helpers.rubygems.js';
|
||||
|
||||
Reference in New Issue
Block a user