learning_ai_common_plat/packages/storage/src/providers/azure-blob.ts

208 lines
6.5 KiB
TypeScript

/**
* Azure Blob Storage provider.
*
* Wraps @azure/storage-blob behind the cloud-agnostic StorageProvider interface.
*/
import type {
BlobMeta,
SignedUrlOptions,
StorageBucket,
StorageProvider,
UploadOptions,
} from '../types.js';
export interface AzureBlobProviderConfig {
connectionString?: string;
accountName?: string;
accountKey?: string;
blobEndpoint?: string;
publicBlobEndpoint?: string;
}
function parseConnectionString(connectionString: string): Partial<AzureBlobProviderConfig> {
const parts = new Map<string, string>();
for (const segment of connectionString.split(';')) {
const [key, ...rest] = segment.split('=');
if (!key || rest.length === 0) continue;
parts.set(key, rest.join('='));
}
return {
accountName: parts.get('AccountName'),
accountKey: parts.get('AccountKey'),
blobEndpoint: parts.get('BlobEndpoint'),
};
}
export class AzureBlobStorageProvider implements StorageProvider {
private client: unknown = null;
private config: AzureBlobProviderConfig;
private buckets = new Map<string, AzureBlobBucket>();
constructor(config?: AzureBlobProviderConfig) {
const envConfig = config ?? {
connectionString: process.env.AZURE_BLOB_CONNECTION_STRING,
accountName: process.env.AZURE_BLOB_ACCOUNT_NAME,
accountKey: process.env.AZURE_BLOB_ACCOUNT_KEY,
publicBlobEndpoint: process.env.AZURE_BLOB_PUBLIC_ENDPOINT,
};
const parsed = envConfig.connectionString
? parseConnectionString(envConfig.connectionString)
: undefined;
this.config = {
...parsed,
...envConfig,
accountName: envConfig.accountName ?? parsed?.accountName,
accountKey: envConfig.accountKey ?? parsed?.accountKey,
blobEndpoint: envConfig.blobEndpoint ?? parsed?.blobEndpoint,
};
}
private async getClient() {
if (!this.client) {
const { BlobServiceClient } = await import('@azure/storage-blob');
if (this.config.connectionString) {
this.client = BlobServiceClient.fromConnectionString(this.config.connectionString);
} else if (this.config.accountName && this.config.accountKey) {
const { StorageSharedKeyCredential } = await import('@azure/storage-blob');
const cred = new StorageSharedKeyCredential(
this.config.accountName,
this.config.accountKey
);
const endpoint =
this.config.blobEndpoint ?? `https://${this.config.accountName}.blob.core.windows.net`;
this.client = new BlobServiceClient(endpoint, cred);
} else {
throw new Error(
'AzureBlobStorageProvider requires AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY'
);
}
}
return this.client as import('@azure/storage-blob').BlobServiceClient;
}
getBucket(name: string): StorageBucket {
let bucket = this.buckets.get(name);
if (!bucket) {
bucket = new AzureBlobBucket(name, () => this.getClient(), this.config);
this.buckets.set(name, bucket);
}
return bucket;
}
async isHealthy(): Promise<boolean> {
try {
const client = await this.getClient();
// List one container to verify connectivity
const iter = client.listContainers();
await iter.next();
return true;
} catch {
return false;
}
}
}
class AzureBlobBucket implements StorageBucket {
constructor(
private containerName: string,
private getClient: () => Promise<import('@azure/storage-blob').BlobServiceClient>,
private config: AzureBlobProviderConfig
) {}
private async containerClient() {
const client = await this.getClient();
const container = client.getContainerClient(this.containerName);
await container.createIfNotExists();
return container;
}
async upload(
key: string,
data: Buffer | Uint8Array | string,
options?: UploadOptions
): Promise<BlobMeta> {
const container = await this.containerClient();
const blockBlob = container.getBlockBlobClient(key);
const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data);
await blockBlob.upload(buf, buf.length, {
blobHTTPHeaders: { blobContentType: options?.contentType },
metadata: options?.metadata,
});
return {
key,
size: buf.length,
contentType: options?.contentType,
lastModified: new Date(),
metadata: options?.metadata,
};
}
async download(key: string): Promise<Buffer> {
const container = await this.containerClient();
const blob = container.getBlobClient(key);
const response = await blob.downloadToBuffer();
return response;
}
async delete(key: string): Promise<void> {
const container = await this.containerClient();
const blob = container.getBlobClient(key);
await blob.deleteIfExists();
}
async exists(key: string): Promise<boolean> {
const container = await this.containerClient();
const blob = container.getBlobClient(key);
return blob.exists();
}
async list(prefix?: string): Promise<BlobMeta[]> {
const container = await this.containerClient();
const results: BlobMeta[] = [];
for await (const blob of container.listBlobsFlat({ prefix: prefix ?? undefined })) {
results.push({
key: blob.name,
size: blob.properties.contentLength ?? undefined,
contentType: blob.properties.contentType ?? undefined,
lastModified: blob.properties.lastModified,
});
}
return results;
}
async getSignedUrl(key: string, options?: SignedUrlOptions): Promise<string> {
const { generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } =
await import('@azure/storage-blob');
if (!this.config.accountName || !this.config.accountKey) {
throw new Error('Signed URLs require accountName + accountKey');
}
const cred = new StorageSharedKeyCredential(this.config.accountName, this.config.accountKey);
const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 3600) * 1000);
const permissions = BlobSASPermissions.parse(options?.permissions === 'write' ? 'w' : 'r');
const sas = generateBlobSASQueryParameters(
{
containerName: this.containerName,
blobName: key,
permissions,
expiresOn,
},
cred
);
const baseUrl =
this.config.publicBlobEndpoint ??
this.config.blobEndpoint ??
`https://${this.config.accountName}.blob.core.windows.net`;
return `${baseUrl.replace(/\/$/, '')}/${this.containerName}/${key}?${sas.toString()}`;
}
}