feat(core,storage,oci,registry-config): add streaming response support and configurable registry URLs across protocols
This commit is contained in:
@@ -34,8 +34,8 @@ import type {
|
||||
* ```
|
||||
*/
|
||||
export class RegistryStorage implements IStorageBackend {
|
||||
private smartBucket: plugins.smartbucket.SmartBucket;
|
||||
private bucket: plugins.smartbucket.Bucket;
|
||||
private smartBucket!: plugins.smartbucket.SmartBucket;
|
||||
private bucket!: plugins.smartbucket.Bucket;
|
||||
private bucketName: string;
|
||||
private hooks?: IStorageHooks;
|
||||
|
||||
@@ -1266,4 +1266,135 @@ export class RegistryStorage implements IStorageBackend {
|
||||
private getRubyGemsMetadataPath(gemName: string): string {
|
||||
return `rubygems/metadata/${gemName}/metadata.json`;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// STREAMING METHODS (Web Streams API)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get an object as a ReadableStream. Returns null if not found.
|
||||
*/
|
||||
public async getObjectStream(key: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
try {
|
||||
const stat = await this.bucket.fastStat({ path: key });
|
||||
const size = stat.ContentLength ?? 0;
|
||||
const stream = await this.bucket.fastGetStream({ path: key }, 'webstream');
|
||||
|
||||
// Call afterGet hook (non-blocking)
|
||||
if (this.hooks?.afterGet) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
this.hooks.afterGet({
|
||||
operation: 'get',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
timestamp: new Date(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return { stream: stream as ReadableStream<Uint8Array>, size };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an object from a ReadableStream.
|
||||
*/
|
||||
public async putObjectStream(key: string, stream: ReadableStream<Uint8Array>): Promise<void> {
|
||||
if (this.hooks?.beforePut) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
const hookContext: IStorageHookContext = {
|
||||
operation: 'put',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const result = await this.hooks.beforePut(hookContext);
|
||||
if (!result.allowed) {
|
||||
throw new Error(result.reason || 'Storage operation denied by hook');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert WebStream to Node Readable at the S3 SDK boundary
|
||||
// AWS SDK v3 PutObjectCommand requires a Node.js Readable (not WebStream)
|
||||
const { Readable } = await import('stream');
|
||||
const nodeStream = Readable.fromWeb(stream as any);
|
||||
await this.bucket.fastPutStream({
|
||||
path: key,
|
||||
readableStream: nodeStream,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
if (this.hooks?.afterPut) {
|
||||
const context = this.currentContext;
|
||||
if (context) {
|
||||
this.hooks.afterPut({
|
||||
operation: 'put',
|
||||
key,
|
||||
protocol: context.protocol,
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
timestamp: new Date(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object size without reading data (S3 HEAD request).
|
||||
*/
|
||||
public async getObjectSize(key: string): Promise<number | null> {
|
||||
try {
|
||||
const stat = await this.bucket.fastStat({ path: key });
|
||||
return stat.ContentLength ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Protocol-specific streaming wrappers ----
|
||||
|
||||
public async getOciBlobStream(digest: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getOciBlobPath(digest));
|
||||
}
|
||||
|
||||
public async putOciBlobStream(digest: string, stream: ReadableStream<Uint8Array>): Promise<void> {
|
||||
return this.putObjectStream(this.getOciBlobPath(digest), stream);
|
||||
}
|
||||
|
||||
public async getOciBlobSize(digest: string): Promise<number | null> {
|
||||
return this.getObjectSize(this.getOciBlobPath(digest));
|
||||
}
|
||||
|
||||
public async getNpmTarballStream(packageName: string, version: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getNpmTarballPath(packageName, version));
|
||||
}
|
||||
|
||||
public async getMavenArtifactStream(groupId: string, artifactId: string, version: string, filename: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getMavenArtifactPath(groupId, artifactId, version, filename));
|
||||
}
|
||||
|
||||
public async getCargoCrateStream(crateName: string, version: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getCargoCratePath(crateName, version));
|
||||
}
|
||||
|
||||
public async getComposerPackageZipStream(vendorPackage: string, reference: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getComposerZipPath(vendorPackage, reference));
|
||||
}
|
||||
|
||||
public async getPypiPackageFileStream(packageName: string, filename: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getPypiPackageFilePath(packageName, filename));
|
||||
}
|
||||
|
||||
public async getRubyGemsGemStream(gemName: string, version: string, platform?: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null> {
|
||||
return this.getObjectStream(this.getRubyGemsGemPath(gemName, version, platform));
|
||||
}
|
||||
}
|
||||
|
||||
63
ts/core/helpers.stream.ts
Normal file
63
ts/core/helpers.stream.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Convert Buffer, Uint8Array, string, or JSON object to a ReadableStream<Uint8Array>.
|
||||
*/
|
||||
export function toReadableStream(data: Buffer | Uint8Array | string | object): ReadableStream<Uint8Array> {
|
||||
const buf = Buffer.isBuffer(data)
|
||||
? data
|
||||
: data instanceof Uint8Array
|
||||
? Buffer.from(data)
|
||||
: typeof data === 'string'
|
||||
? Buffer.from(data, 'utf-8')
|
||||
: Buffer.from(JSON.stringify(data), 'utf-8');
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(buf));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a ReadableStream into a Buffer.
|
||||
*/
|
||||
export async function streamToBuffer(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a ReadableStream into a parsed JSON object.
|
||||
*/
|
||||
export async function streamToJson<T = any>(stream: ReadableStream<Uint8Array>): Promise<T> {
|
||||
const buf = await streamToBuffer(stream);
|
||||
return JSON.parse(buf.toString('utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TransformStream that incrementally hashes data passing through.
|
||||
* Data flows through unchanged; the digest is available after the stream completes.
|
||||
*/
|
||||
export function createHashTransform(algorithm: string = 'sha256'): {
|
||||
transform: TransformStream<Uint8Array, Uint8Array>;
|
||||
getDigest: () => string;
|
||||
} {
|
||||
const hash = crypto.createHash(algorithm);
|
||||
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
hash.update(chunk);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
return {
|
||||
transform,
|
||||
getDigest: () => hash.digest('hex'),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export { DefaultAuthProvider } from './classes.defaultauthprovider.js';
|
||||
// Storage interfaces and hooks
|
||||
export * from './interfaces.storage.js';
|
||||
|
||||
// Stream helpers
|
||||
export { toReadableStream, streamToBuffer, streamToJson, createHashTransform } from './helpers.stream.js';
|
||||
|
||||
// Classes
|
||||
export { BaseRegistry } from './classes.baseregistry.js';
|
||||
export { RegistryStorage } from './classes.registrystorage.js';
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface IAuthConfig {
|
||||
export interface IProtocolConfig {
|
||||
enabled: boolean;
|
||||
basePath: string;
|
||||
registryUrl?: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@@ -160,6 +161,21 @@ export interface IStorageBackend {
|
||||
* Get object metadata
|
||||
*/
|
||||
getMetadata(key: string): Promise<Record<string, string> | null>;
|
||||
|
||||
/**
|
||||
* Get an object as a ReadableStream. Returns null if not found.
|
||||
*/
|
||||
getObjectStream?(key: string): Promise<{ stream: ReadableStream<Uint8Array>; size: number } | null>;
|
||||
|
||||
/**
|
||||
* Store an object from a ReadableStream.
|
||||
*/
|
||||
putObjectStream?(key: string, stream: ReadableStream<Uint8Array>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get object size without reading data (S3 HEAD request).
|
||||
*/
|
||||
getObjectSize?(key: string): Promise<number | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,10 +231,13 @@ export interface IRequestContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Base response structure
|
||||
* Base response structure.
|
||||
* `body` is always a `ReadableStream<Uint8Array>` at the public API boundary.
|
||||
* Internal handlers may return Buffer/string/object — the SmartRegistry orchestrator
|
||||
* auto-wraps them via `toReadableStream()` before returning to the caller.
|
||||
*/
|
||||
export interface IResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body?: any;
|
||||
body?: ReadableStream<Uint8Array> | any;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user