diff --git a/eslint.config.js b/eslint.config.js index cc8b98f4..61129f5d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -57,6 +57,13 @@ export default [ beforeAll: 'readonly', afterAll: 'readonly', crypto: 'readonly', + Blob: 'readonly', + File: 'readonly', + FormData: 'readonly', + URLSearchParams: 'readonly', + URL: 'readonly', + BodyInit: 'readonly', + ReadableStream: 'readonly', localStorage: 'readonly', sessionStorage: 'readonly', ReactNode: 'readonly', diff --git a/packages/blob-client/package.json b/packages/blob-client/package.json new file mode 100644 index 00000000..6b8d4135 --- /dev/null +++ b/packages/blob-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bytelyst/blob-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe blob storage client — SAS URL upload/download via platform-service", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + } +} diff --git a/packages/blob-client/src/index.test.ts b/packages/blob-client/src/index.test.ts new file mode 100644 index 00000000..8ddad34d --- /dev/null +++ b/packages/blob-client/src/index.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createBlobClient, BlobApiError, BlobUploadError } from './index.js'; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + headers: new Headers(), + } as unknown as Response; +} + +function blobClient(overrides?: Partial[0]>) { + return createBlobClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'test-token', + ...overrides, + }); +} + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('createBlobClient', () => { + describe('getSasUrl', () => { + it('requests a SAS URL from platform-service', async () => { + const sasResponse = { + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', + container: 'attachments', + blobName: 'testapp/user1/photo.jpg', + permissions: 'r', + expiresInMinutes: 60, + expiresAt: '2026-03-02T00:00:00.000Z', + }; + mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse)); + + const client = blobClient(); + const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg'); + + expect(result).toEqual(sasResponse); + expect(mockFetch).toHaveBeenCalledOnce(); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/blob/sas'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + container: 'attachments', + blobName: 'testapp/user1/photo.jpg', + permissions: 'r', + expiresInMinutes: 60, + }); + }); + + it('sends auth and product headers', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); + + const client = blobClient(); + await client.getSasUrl('attachments', 'blob.jpg'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(headers['x-product-id']).toBe('testapp'); + expect(headers['x-request-id']).toBeDefined(); + }); + + it('omits auth header when no token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); + + const client = blobClient({ getAccessToken: () => null }); + await client.getSasUrl('attachments', 'blob.jpg'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('throws BlobApiError on non-ok response', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401)); + + const client = blobClient(); + await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError); + }); + }); + + describe('upload', () => { + it('requests SAS then uploads directly to Azure', async () => { + const sasResponse = { + sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc', + container: 'attachments', + blobName: 'testapp/user1/photo.jpg', + permissions: 'w', + expiresInMinutes: 30, + expiresAt: '2026-03-02T00:00:00.000Z', + }; + mockFetch + .mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request + .mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload + + const client = blobClient(); + const result = await client.upload('attachments', 'file-data', { + contentType: 'image/jpeg', + blobName: 'testapp/user1/photo.jpg', + }); + + expect(result.container).toBe('attachments'); + expect(result.blobName).toBe('testapp/user1/photo.jpg'); + expect(result.sasUrl).toBe( + 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg' + ); + + // Verify Azure upload call + const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]; + expect(uploadUrl).toBe(sasResponse.sasUrl); + expect(uploadInit.method).toBe('PUT'); + expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob'); + expect(uploadInit.headers['Content-Type']).toBe('image/jpeg'); + expect(uploadInit.body).toBe('file-data'); + }); + + it('auto-generates blobName when not provided', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x', + container: 'attachments', + blobName: 'testapp/123-abc', + permissions: 'w', + expiresInMinutes: 30, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ ok: true, status: 201 } as Response); + + const client = blobClient(); + const result = await client.upload('attachments', 'data', { + contentType: 'text/plain', + }); + + // Verify the SAS request included a generated blobName starting with productId + const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(sasBody.blobName).toMatch(/^testapp\//); + expect(result.container).toBe('attachments'); + }); + + it('throws BlobUploadError when Azure returns non-ok', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', + container: 'attachments', + blobName: 'blob', + permissions: 'w', + expiresInMinutes: 30, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ ok: false, status: 403 } as Response); + + const client = blobClient(); + await expect( + client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' }) + ).rejects.toThrow(BlobUploadError); + }); + }); + + describe('download', () => { + it('requests read SAS then fetches the blob', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', + container: 'attachments', + blobName: 'blob', + permissions: 'r', + expiresInMinutes: 15, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ + ok: true, + status: 200, + blob: () => Promise.resolve(new Blob()), + } as unknown as Response); + + const client = blobClient(); + const res = await client.download('attachments', 'blob'); + + expect(res.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Second call should be to the SAS URL + expect(mockFetch.mock.calls[1][0]).toBe( + 'https://storage.blob.core.windows.net/attachments/blob?sig=read' + ); + }); + + it('throws BlobApiError on download failure', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', + container: 'attachments', + blobName: 'blob', + permissions: 'r', + expiresInMinutes: 15, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ ok: false, status: 404 } as Response); + + const client = blobClient(); + await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError); + }); + }); + + describe('list', () => { + it('lists blobs with prefix and limit', async () => { + const listResponse = { + blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }], + count: 1, + container: 'attachments', + prefix: 'testapp/user1/', + }; + mockFetch.mockResolvedValueOnce(jsonResponse(listResponse)); + + const client = blobClient(); + const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 }); + + expect(result).toEqual(listResponse); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('container=attachments'); + expect(url).toContain('prefix=testapp%2Fuser1%2F'); + expect(url).toContain('limit=10'); + }); + + it('works without options', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null }) + ); + + const client = blobClient(); + await client.list('audio'); + + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('container=audio'); + expect(url).not.toContain('prefix='); + expect(url).not.toContain('limit='); + }); + }); + + describe('info', () => { + it('fetches blob metadata', async () => { + const infoResponse = { + name: 'testapp/user1/photo.jpg', + container: 'attachments', + contentType: 'image/jpeg', + size: 2048, + lastModified: '2026-03-01T00:00:00.000Z', + url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg', + metadata: {}, + }; + mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse)); + + const client = blobClient(); + const result = await client.info('attachments', 'testapp/user1/photo.jpg'); + + expect(result).toEqual(infoResponse); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg'); + }); + }); +}); diff --git a/packages/blob-client/src/index.ts b/packages/blob-client/src/index.ts new file mode 100644 index 00000000..f0bf9a99 --- /dev/null +++ b/packages/blob-client/src/index.ts @@ -0,0 +1,289 @@ +/** + * 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 }; +} diff --git a/packages/blob-client/tsconfig.json b/packages/blob-client/tsconfig.json new file mode 100644 index 00000000..318c075a --- /dev/null +++ b/packages/blob-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}