/** * Browser/React Native-safe blob storage client. * * Wraps the platform-service blob endpoints to provide: * - SAS URL generation for direct upload/download * - Direct blob upload via SAS URL (PUT with raw body) * - Direct blob download via SAS URL * - Blob listing and metadata * * Requires a fetch-compatible environment (browser, React Native, Node 18+). * * @example * ```ts * import { createBlobClient } from '@bytelyst/blob-client'; * * const blob = createBlobClient({ * baseUrl: 'http://localhost:4003/api', * productId: 'nomgap', * getAccessToken: () => authClient.getAccessToken(), * }); * * // Upload a file * const { url } = await blob.upload('attachments', file, { * contentType: 'image/jpeg', * blobName: 'nomgap/user123/photos/meal.jpg', * }); * * // Download a file * const data = await blob.download('attachments', 'nomgap/user123/photos/meal.jpg'); * * // List blobs * const { blobs } = await blob.list('attachments', { prefix: 'nomgap/user123/' }); * ``` */ // ── Types ──────────────────────────────────────────────────── export interface BlobClientConfig { /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ baseUrl: string; /** Product identifier sent as x-product-id header. */ productId: string; /** Function that returns the current access token, or null. */ getAccessToken: () => string | null; /** Request timeout in milliseconds for API calls. Default: 15000. */ timeoutMs?: number; /** Upload timeout in milliseconds for direct blob uploads. Default: 120000. */ uploadTimeoutMs?: number; } export interface SasUrlResponse { sasUrl: string; container: string; blobName: string; permissions: string; expiresInMinutes: number; expiresAt: string; } export interface BlobInfo { name: string; container: string; contentType?: string; size: number; lastModified?: string; url: string; metadata: Record; } export interface ListBlobsResponse { blobs: BlobInfo[]; count: number; container: string; prefix: string | null; } export interface UploadOptions { /** Content-Type of the blob (e.g. "image/jpeg", "application/pdf"). */ contentType: string; /** Full blob path. If omitted, auto-generated as `//-`. */ blobName?: string; /** SAS token expiry in minutes. Default: 30. */ expiresInMinutes?: number; } export interface UploadResult { sasUrl: string; container: string; blobName: string; } export interface BlobClient { /** Get a SAS URL for direct upload or download. */ getSasUrl( container: string, blobName: string, permissions?: 'r' | 'w' | 'rw' | 'rwc', expiresInMinutes?: number ): Promise; /** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */ upload( container: string, data: Blob | ArrayBuffer | Uint8Array | string, options: UploadOptions ): Promise; /** Download a blob via SAS URL. Returns the Response for streaming. */ download(container: string, blobName: string): Promise; /** List blobs in a container. */ list( container: string, options?: { prefix?: string; limit?: number } ): Promise; /** Get blob metadata/info. */ info(container: string, blobName: string): Promise; } // ── Errors ─────────────────────────────────────────────────── export class BlobApiError extends Error { constructor( public readonly status: number, public readonly body: unknown, message?: string ) { super(message ?? `Blob API error ${status}`); this.name = 'BlobApiError'; } } export class BlobUploadError extends Error { constructor( public readonly status: number, message?: string ) { super(message ?? `Blob upload failed with status ${status}`); this.name = 'BlobUploadError'; } } // ── UUID helper ────────────────────────────────────────────── function uuid(): string { if (typeof globalThis.crypto?.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } // ── Factory ────────────────────────────────────────────────── export function createBlobClient(config: BlobClientConfig): BlobClient { const { baseUrl, productId, getAccessToken, timeoutMs = 15_000, uploadTimeoutMs = 120_000, } = config; function authHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', 'x-product-id': productId, 'x-request-id': uuid(), }; const token = getAccessToken(); if (token) headers['Authorization'] = `Bearer ${token}`; return headers; } async function apiRequest(method: string, path: string, body?: unknown): Promise { const url = `${baseUrl}${path}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await globalThis.fetch(url, { method, headers: authHeaders(), body: body != null ? JSON.stringify(body) : undefined, signal: controller.signal, }); const json = await res.json().catch(() => ({})); if (!res.ok) { throw new BlobApiError( res.status, json, (json as Record).message ?? `HTTP ${res.status}` ); } return json as T; } finally { clearTimeout(timer); } } async function getSasUrl( container: string, blobName: string, permissions: 'r' | 'w' | 'rw' | 'rwc' = 'r', expiresInMinutes = 60 ): Promise { return apiRequest('POST', '/blob/sas', { container, blobName, permissions, expiresInMinutes, }); } async function upload( container: string, data: Blob | ArrayBuffer | Uint8Array | string, options: UploadOptions ): Promise { const blobName = options.blobName ?? `${productId}/${Date.now()}-${uuid().slice(0, 8)}`; const sas = await getSasUrl(container, blobName, 'w', options.expiresInMinutes ?? 30); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), uploadTimeoutMs); try { const res = await globalThis.fetch(sas.sasUrl, { method: 'PUT', headers: { 'x-ms-blob-type': 'BlockBlob', 'Content-Type': options.contentType, }, body: data as BodyInit, signal: controller.signal, }); if (!res.ok) { throw new BlobUploadError(res.status, `Upload to Azure failed: HTTP ${res.status}`); } return { sasUrl: sas.sasUrl.split('?')[0], container, blobName }; } finally { clearTimeout(timer); } } async function download(container: string, blobName: string): Promise { const sas = await getSasUrl(container, blobName, 'r', 15); const res = await globalThis.fetch(sas.sasUrl); if (!res.ok) { throw new BlobApiError(res.status, null, `Download failed: HTTP ${res.status}`); } return res; } async function list( container: string, options?: { prefix?: string; limit?: number } ): Promise { const params = new URLSearchParams({ container }); if (options?.prefix) params.set('prefix', options.prefix); if (options?.limit) params.set('limit', String(options.limit)); return apiRequest('GET', `/blob/list?${params.toString()}`); } async function info(container: string, blobName: string): Promise { return apiRequest( 'GET', `/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}` ); } return { getSasUrl, upload, download, list, info }; }