Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/upload-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@

Client-side presigned URL upload utilities for Constructive.

No Node.js-only dependencies — all APIs use Web standards (fetch, Web Crypto).

## Usage

```typescript
import { uploadFile, hashFile } from '@constructive-io/upload-client';
import { uploadFile, hashFile, hashContent, putToPresignedUrl, fetchFromUrl } from '@constructive-io/upload-client';

// Full orchestrated upload
const result = await uploadFile({
Expand All @@ -27,8 +29,11 @@ const result = await uploadFile({
onProgress: (pct) => console.log(`${pct}%`),
});

// Or use atomic functions individually
// Atomic functions for custom flows
const hash = await hashFile(myFile);
const contentHash = await hashContent('file contents');
await putToPresignedUrl(presignedUrl, content, 'image/png');
const response = await fetchFromUrl(downloadUrl);
```

## API
Expand All @@ -39,8 +44,20 @@ Orchestrates the full presigned URL upload flow: hash → requestUploadUrl → P

### `hashFile(file)`

Computes SHA-256 hash using the Web Crypto API.
Computes SHA-256 hash of a File/Blob using the Web Crypto API.

### `hashFileChunked(file, chunkSize?, onProgress?)`

Computes SHA-256 hash in chunks for large files.

### `hashContent(content)`

Computes SHA-256 hex digest of a plain string using the Web Crypto API.

### `putToPresignedUrl(url, body, contentType, signal?)`

PUT content to a presigned S3 URL. Throws `UploadError` on failure.

### `fetchFromUrl(url, signal?)`

Fetch content from a presigned GET or CDN URL. Throws `UploadError` on failure.
3 changes: 3 additions & 0 deletions packages/upload-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@constructive-io/fetch": "^1.0.0"
},
"devDependencies": {
"makage": "^0.3.0"
},
Expand Down
39 changes: 39 additions & 0 deletions packages/upload-client/src/hash-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* String content hashing using Web Crypto API.
*
* Complements `hashFile` (which accepts File/Blob) with a convenience
* function for hashing plain strings. Uses the same Web Crypto API —
* no Node.js-only dependencies.
*
* @example
* ```typescript
* import { hashContent } from '@constructive-io/upload-client';
*
* const hash = await hashContent('hello world');
* // "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
* ```
*/

import { UploadError } from './types';

/**
* Compute the SHA-256 hex digest of a string using Web Crypto API.
*
* @param content - The string content to hash
* @returns 64-character lowercase hex string
*/
export async function hashContent(content: string): Promise<string> {
try {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const bytes = new Uint8Array(hashBuffer);
const hex = new Array<string>(bytes.length);
for (let i = 0; i < bytes.length; i++) {
hex[i] = bytes[i].toString(16).padStart(2, '0');
}
return hex.join('');
} catch (err) {
throw new UploadError('HASH_FAILED', 'Failed to compute SHA-256 hash', err);
}
}
18 changes: 14 additions & 4 deletions packages/upload-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
* Client-side presigned URL upload utilities for Constructive.
*
* Provides atomic functions for the presigned URL upload pipeline:
* - `hashFile` — SHA-256 hash via Web Crypto API
* - `hashFile` — SHA-256 hash via Web Crypto API (File/Blob)
* - `hashFileChunked` — chunked SHA-256 for large files
* - `hashContent` — SHA-256 hash for plain strings (Web Crypto)
* - `putToPresignedUrl` — PUT bytes to a presigned S3 URL
* - `fetchFromUrl` — GET from a presigned or CDN URL
* - `uploadFile` — full upload orchestrator (hash → request → PUT)
*
* Framework-agnostic, works in any browser or Node.js 18+ environment.
* Uses @constructive-io/fetch for isomorphic HTTP (handles *.localhost DNS in Node.js).
*
* @example
* ```typescript
* import { uploadFile, hashFile } from '@constructive-io/upload-client';
* import { uploadFile, hashFile, hashContent, putToPresignedUrl } from '@constructive-io/upload-client';
*
* // Full orchestrated upload
* const result = await uploadFile({
Expand All @@ -21,13 +25,19 @@
* execute: myGraphQLExecutor,
* });
*
* // Or use atomic functions individually
* // Atomic functions for custom flows
* const hash = await hashFile(myFile);
* const contentHash = await hashContent('file contents');
* await putToPresignedUrl(presignedUrl, content, 'image/png');
* ```
*/

// Atomic functions
// Hashing
export { hashFile, hashFileChunked } from './hash';
export { hashContent } from './hash-content';

// Presigned URL helpers
export { putToPresignedUrl, fetchFromUrl } from './put';

// Orchestrator
export { uploadFile } from './upload';
Expand Down
112 changes: 112 additions & 0 deletions packages/upload-client/src/put.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Presigned URL PUT/GET helpers.
*
* Thin wrappers around fetch for uploading to and downloading from
* presigned S3 URLs. These are the client-side counterparts to the
* server-side presigned URL generation in @constructive-io/s3-utils.
*
* @example
* ```typescript
* import { putToPresignedUrl, fetchFromUrl } from '@constructive-io/upload-client';
*
* // Upload bytes to a presigned PUT URL
* await putToPresignedUrl(uploadUrl, fileContent, 'image/png');
*
* // Download from a presigned GET or CDN URL
* const response = await fetchFromUrl(downloadUrl);
* const text = await response.text();
* ```
*/

import { createFetch } from '@constructive-io/fetch';
import { UploadError } from './types';

const fetch = createFetch();

/**
* PUT content to a presigned S3 URL.
*
* Accepts any fetch-compatible body (string, ArrayBuffer, Blob, etc.)
* — works in both browser and Node.js environments.
*
* @param url - Presigned PUT URL
* @param body - Content to upload
* @param contentType - MIME type (must match the presigned URL's content-type constraint)
* @param signal - Optional AbortSignal for cancellation
* @returns The fetch Response (already verified as ok)
* @throws {UploadError} If the PUT fails or is aborted
*/
export async function putToPresignedUrl(
url: string,
body: BodyInit | string,
contentType: string,
signal?: AbortSignal,
): Promise<Response> {
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: body as BodyInit,
signal,
});

if (!response.ok) {
const text = await response.text().catch(() => '');
throw new UploadError(
'PUT_UPLOAD_FAILED',
`S3 PUT failed with status ${response.status}: ${text}`,
);
}

return response;
} catch (err) {
if (err instanceof UploadError) throw err;
if (signal?.aborted) {
throw new UploadError('ABORTED', 'Upload was cancelled');
}
throw new UploadError(
'PUT_UPLOAD_FAILED',
`S3 PUT failed: ${err instanceof Error ? err.message : String(err)}`,
err,
);
}
}

/**
* Fetch content from a presigned GET or CDN URL.
*
* Thin wrapper that throws a structured UploadError on failure.
*
* @param url - Presigned GET URL or public CDN URL
* @param signal - Optional AbortSignal for cancellation
* @returns The fetch Response (already verified as ok)
* @throws {UploadError} If the fetch fails or is aborted
*/
export async function fetchFromUrl(
url: string,
signal?: AbortSignal,
): Promise<Response> {
try {
const response = await fetch(url, { signal });

if (!response.ok) {
const text = await response.text().catch(() => '');
throw new UploadError(
'PUT_UPLOAD_FAILED',
`Fetch failed with status ${response.status}: ${text}`,
);
}

return response;
} catch (err) {
if (err instanceof UploadError) throw err;
if (signal?.aborted) {
throw new UploadError('ABORTED', 'Fetch was cancelled');
}
throw new UploadError(
'PUT_UPLOAD_FAILED',
`Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
err,
);
}
}
53 changes: 5 additions & 48 deletions packages/upload-client/src/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { hashFile } from './hash';
import { putToPresignedUrl } from './put';
import { buildRequestUploadUrlQuery, DEFAULT_BUCKET_QUERY_FIELD } from './queries';
import { UploadError } from './types';
import type {
Expand Down Expand Up @@ -88,13 +89,7 @@ export async function uploadFile(options: UploadFileOptions): Promise<UploadResu
);
}

await putToS3(
requestPayload.uploadUrl,
file,
file.type || 'application/octet-stream',
onProgress,
signal,
);
await putToS3(requestPayload.uploadUrl, file, file.type || 'application/octet-stream', onProgress, signal);

return {
fileId: requestPayload.fileId,
Expand Down Expand Up @@ -154,7 +149,7 @@ async function requestUploadUrl(
* PUT file bytes to the presigned S3 URL.
*
* Uses XMLHttpRequest when available (for progress tracking),
* falls back to fetch otherwise.
* falls back to putToPresignedUrl (fetch) otherwise.
*/
async function putToS3(
url: string,
Expand All @@ -168,46 +163,8 @@ async function putToS3(
return putWithXHR(url, file, contentType, onProgress, signal);
}

// Fallback to fetch
return putWithFetch(url, file, contentType, signal);
}

/**
* PUT using fetch API.
*/
async function putWithFetch(
url: string,
file: UploadFileOptions['file'],
contentType: string,
signal?: AbortSignal,
): Promise<void> {
try {
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body,
signal,
});

if (!response.ok) {
const text = await response.text().catch(() => '');
throw new UploadError(
'PUT_UPLOAD_FAILED',
`S3 PUT failed with status ${response.status}: ${text}`,
);
}
} catch (err) {
if (err instanceof UploadError) throw err;
if (signal?.aborted) {
throw new UploadError('ABORTED', 'Upload was cancelled');
}
throw new UploadError(
'PUT_UPLOAD_FAILED',
`S3 PUT failed: ${err instanceof Error ? err.message : String(err)}`,
err,
);
}
const body = await file.arrayBuffer();
await putToPresignedUrl(url, body, contentType, signal);
}

/**
Expand Down
Loading
Loading