Skip to content

Commit f125aa0

Browse files
committed
feat: add @constructive-io/upload-client package (Phase 2B)
Vanilla TypeScript utilities for client-side presigned URL uploads: - hashFile() — SHA-256 via Web Crypto API - hashFileChunked() — chunked SHA-256 for large files with progress - uploadFile() — full orchestrator (hash → requestUploadUrl → PUT → confirmUpload) - GraphQLExecutor type — works with any GraphQL client (urql, Apollo, fetch) - UploadError class with typed error codes - AbortSignal support for cancellation - Progress tracking via XHR upload events - Deduplication support (skip PUT when file already exists) 23 tests passing across 2 test suites.
1 parent 7b4274e commit f125aa0

13 files changed

Lines changed: 1204 additions & 0 deletions

File tree

packages/upload-client/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# @constructive-io/upload-client
2+
3+
Client-side presigned URL upload utilities for Constructive.
4+
5+
## Usage
6+
7+
```typescript
8+
import { uploadFile, hashFile } from '@constructive-io/upload-client';
9+
10+
// Full orchestrated upload
11+
const result = await uploadFile({
12+
file: selectedFile,
13+
bucketKey: 'avatars',
14+
execute: myGraphQLExecutor,
15+
onProgress: (pct) => console.log(`${pct}%`),
16+
});
17+
18+
// Or use atomic functions individually
19+
const hash = await hashFile(myFile);
20+
```
21+
22+
## API
23+
24+
### `uploadFile(options)`
25+
26+
Orchestrates the full presigned URL upload flow: hash → requestUploadUrl → PUT → confirmUpload.
27+
28+
### `hashFile(file)`
29+
30+
Computes SHA-256 hash using the Web Crypto API.
31+
32+
### `hashFileChunked(file, chunkSize?, onProgress?)`
33+
34+
Computes SHA-256 hash in chunks for large files.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { hashFile, hashFileChunked } from '../src/hash';
2+
import { UploadError } from '../src/types';
3+
import type { FileInput } from '../src/types';
4+
5+
/**
6+
* Create a mock FileInput from a string body.
7+
*/
8+
function createMockFile(
9+
body: string,
10+
name = 'test.txt',
11+
type = 'text/plain',
12+
): FileInput {
13+
const encoder = new TextEncoder();
14+
const data = encoder.encode(body);
15+
return {
16+
name,
17+
size: data.byteLength,
18+
type,
19+
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
20+
slice: (start = 0, end = data.byteLength) => {
21+
const sliced = data.slice(start, end);
22+
return new Blob([sliced]);
23+
},
24+
};
25+
}
26+
27+
/**
28+
* Known SHA-256 hash of an empty string.
29+
* echo -n "" | sha256sum → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
30+
*/
31+
const EMPTY_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
32+
33+
/**
34+
* Known SHA-256 of "hello world"
35+
* echo -n "hello world" | sha256sum → b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
36+
*/
37+
const HELLO_WORLD_HASH = 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9';
38+
39+
describe('hashFile', () => {
40+
it('should produce correct SHA-256 for known input', async () => {
41+
const file = createMockFile('hello world');
42+
const hash = await hashFile(file);
43+
expect(hash).toBe(HELLO_WORLD_HASH);
44+
});
45+
46+
it('should produce a 64-char lowercase hex string', async () => {
47+
const file = createMockFile('test content');
48+
const hash = await hashFile(file);
49+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
50+
});
51+
52+
it('should produce deterministic output (same content = same hash)', async () => {
53+
const file1 = createMockFile('identical content');
54+
const file2 = createMockFile('identical content');
55+
const hash1 = await hashFile(file1);
56+
const hash2 = await hashFile(file2);
57+
expect(hash1).toBe(hash2);
58+
});
59+
60+
it('should produce different hashes for different content', async () => {
61+
const file1 = createMockFile('content A');
62+
const file2 = createMockFile('content B');
63+
const hash1 = await hashFile(file1);
64+
const hash2 = await hashFile(file2);
65+
expect(hash1).not.toBe(hash2);
66+
});
67+
68+
it('should handle empty file (zero bytes)', async () => {
69+
const file = createMockFile('');
70+
// size is 0, but the function should still hash it
71+
const hash = await hashFile(file);
72+
expect(hash).toBe(EMPTY_HASH);
73+
});
74+
75+
it('should throw UploadError for null file', async () => {
76+
await expect(hashFile(null as any)).rejects.toThrow(UploadError);
77+
await expect(hashFile(null as any)).rejects.toMatchObject({ code: 'INVALID_FILE' });
78+
});
79+
80+
it('should handle binary-like content (UTF-8 multibyte)', async () => {
81+
const file = createMockFile('emoji: 🎉🚀 and accents: ñ ü ö');
82+
const hash = await hashFile(file);
83+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
84+
});
85+
});
86+
87+
describe('hashFileChunked', () => {
88+
it('should produce the same hash as hashFile for the same content', async () => {
89+
const body = 'hello world';
90+
const file = createMockFile(body);
91+
const hashDirect = await hashFile(file);
92+
const hashChunked = await hashFileChunked(createMockFile(body));
93+
expect(hashChunked).toBe(hashDirect);
94+
});
95+
96+
it('should produce correct hash with very small chunk size', async () => {
97+
// Force many chunks by using 1-byte chunk size
98+
const file = createMockFile('hello world');
99+
const hash = await hashFileChunked(file, 1);
100+
expect(hash).toBe(HELLO_WORLD_HASH);
101+
});
102+
103+
it('should produce correct hash when chunk size exceeds file size', async () => {
104+
const file = createMockFile('hello world');
105+
// Chunk size of 1MB for an 11-byte file → single chunk
106+
const hash = await hashFileChunked(file, 1024 * 1024);
107+
expect(hash).toBe(HELLO_WORLD_HASH);
108+
});
109+
110+
it('should fire progress callbacks', async () => {
111+
const body = 'abcdefghij'; // 10 bytes
112+
const file = createMockFile(body);
113+
const progressValues: number[] = [];
114+
115+
await hashFileChunked(file, 3, (pct) => progressValues.push(pct));
116+
117+
// With 10 bytes and 3-byte chunks: 3, 6, 9, 10 → ~30%, 60%, 90%, 100%
118+
expect(progressValues.length).toBeGreaterThan(0);
119+
expect(progressValues[progressValues.length - 1]).toBe(100);
120+
// Each value should be increasing
121+
for (let i = 1; i < progressValues.length; i++) {
122+
expect(progressValues[i]).toBeGreaterThanOrEqual(progressValues[i - 1]);
123+
}
124+
});
125+
126+
it('should handle empty file', async () => {
127+
const file = createMockFile('');
128+
const hash = await hashFileChunked(file);
129+
expect(hash).toBe(EMPTY_HASH);
130+
});
131+
132+
it('should throw for invalid chunk size', async () => {
133+
const file = createMockFile('test');
134+
await expect(hashFileChunked(file, 0)).rejects.toThrow(UploadError);
135+
await expect(hashFileChunked(file, -1)).rejects.toThrow(UploadError);
136+
});
137+
138+
it('should produce same hash for simulated large file (multiple chunks)', async () => {
139+
// Create a "large" string (1000 chars) and hash with 100-byte chunks
140+
const body = 'x'.repeat(1000);
141+
const file = createMockFile(body);
142+
const hashDirect = await hashFile(file);
143+
const hashChunked = await hashFileChunked(createMockFile(body), 100);
144+
expect(hashChunked).toBe(hashDirect);
145+
});
146+
});

0 commit comments

Comments
 (0)