290 lines
8.2 KiB
TypeScript
290 lines
8.2 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
}
|
|
|
|
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 `<productId>/<userId>/<timestamp>-<random>`. */
|
|
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<SasUrlResponse>;
|
|
|
|
/** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */
|
|
upload(
|
|
container: string,
|
|
data: Blob | ArrayBuffer | Uint8Array | string,
|
|
options: UploadOptions
|
|
): Promise<UploadResult>;
|
|
|
|
/** Download a blob via SAS URL. Returns the Response for streaming. */
|
|
download(container: string, blobName: string): Promise<Response>;
|
|
|
|
/** List blobs in a container. */
|
|
list(
|
|
container: string,
|
|
options?: { prefix?: string; limit?: number }
|
|
): Promise<ListBlobsResponse>;
|
|
|
|
/** Get blob metadata/info. */
|
|
info(container: string, blobName: string): Promise<BlobInfo>;
|
|
}
|
|
|
|
// ── 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<string, string> {
|
|
const headers: Record<string, string> = {
|
|
'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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
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<string, string>).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<SasUrlResponse> {
|
|
return apiRequest<SasUrlResponse>('POST', '/blob/sas', {
|
|
container,
|
|
blobName,
|
|
permissions,
|
|
expiresInMinutes,
|
|
});
|
|
}
|
|
|
|
async function upload(
|
|
container: string,
|
|
data: Blob | ArrayBuffer | Uint8Array | string,
|
|
options: UploadOptions
|
|
): Promise<UploadResult> {
|
|
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<Response> {
|
|
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<ListBlobsResponse> {
|
|
const params = new URLSearchParams({ container });
|
|
if (options?.prefix) params.set('prefix', options.prefix);
|
|
if (options?.limit) params.set('limit', String(options.limit));
|
|
|
|
return apiRequest<ListBlobsResponse>('GET', `/blob/list?${params.toString()}`);
|
|
}
|
|
|
|
async function info(container: string, blobName: string): Promise<BlobInfo> {
|
|
return apiRequest<BlobInfo>(
|
|
'GET',
|
|
`/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}`
|
|
);
|
|
}
|
|
|
|
return { getSasUrl, upload, download, list, info };
|
|
}
|