/** * 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 { const parts = new Map(); 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(); 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 { 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, 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 { 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 { const container = await this.containerClient(); const blob = container.getBlobClient(key); const response = await blob.downloadToBuffer(); return response; } async delete(key: string): Promise { const container = await this.containerClient(); const blob = container.getBlobClient(key); await blob.deleteIfExists(); } async exists(key: string): Promise { const container = await this.containerClient(); const blob = container.getBlobClient(key); return blob.exists(); } async list(prefix?: string): Promise { 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 { 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()}`; } }