@hammr/cdn 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,604 @@
1
+ # @hammr/cdn
2
+
3
+ > Content-addressable CDN with storage abstraction for Cloudflare Workers, Node.js, and beyond.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@hammr/cdn.svg)](https://www.npmjs.com/package/@hammr/cdn)
6
+ [![License](https://img.shields.io/npm/l/@hammr/cdn.svg)](https://github.com/hammr/cdn/blob/main/LICENSE)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.3+-blue.svg)](https://www.typescriptlang.org/)
8
+
9
+ ## Features
10
+
11
+ - **๐Ÿ” Content-Addressable** - Automatic SHA256 hashing via [@hammr/normalizer](https://www.npmjs.com/package/@hammr/normalizer)
12
+ - **โ™ป๏ธ Idempotent Uploads** - Same content = same hash, stored once
13
+ - **๐Ÿ”Œ Storage Abstraction** - R2, S3, Memory, FileSystem (bring your own!)
14
+ - **๐ŸŽฏ Auto Content-Type Detection** - 40+ file types recognized automatically
15
+ - **โšก Immutable Artifacts** - Hash-based URLs with aggressive caching
16
+ - **๐ŸŒ HTTP Request Handler** - Drop-in handler for Cloudflare Workers
17
+ - **๐Ÿ”’ Production-Ready** - CORS, ETag, Cache-Control, error handling
18
+ - **๐Ÿ“ฆ Zero Config** - Works out of the box with sensible defaults
19
+ - **๐Ÿงช 100% Test Coverage** - Fully tested and type-safe
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @sygnl/cdn
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ### Cloudflare Workers (R2 Storage)
30
+
31
+ ```typescript
32
+ import { CDN, R2Storage } from '@sygnl/cdn';
33
+
34
+ export default {
35
+ async fetch(request: Request, env: Env) {
36
+ const cdn = new CDN({
37
+ storage: new R2Storage(env.ARTIFACTS),
38
+ baseUrl: 'https://cdn.example.com'
39
+ });
40
+
41
+ return cdn.handleRequest(request);
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Programmatic Usage
47
+
48
+ ```typescript
49
+ import { CDN, MemoryStorage } from '@sygnl/cdn';
50
+
51
+ const cdn = new CDN({
52
+ storage: new MemoryStorage(),
53
+ baseUrl: 'https://cdn.example.com'
54
+ });
55
+
56
+ // Upload artifact
57
+ const imageBytes = await fetch('https://example.com/logo.png').then(r => r.arrayBuffer());
58
+ const result = await cdn.put(imageBytes, {
59
+ filename: 'logo.png'
60
+ });
61
+
62
+ console.log(result.url);
63
+ // โ†’ https://cdn.example.com/a/5e884898da28047151d0e56f8dc629...png
64
+
65
+ // Retrieve artifact
66
+ const artifact = await cdn.get(result.hash);
67
+ console.log(artifact.metadata.contentType); // โ†’ image/png
68
+
69
+ // Delete artifact
70
+ await cdn.delete(result.hash);
71
+ ```
72
+
73
+ ## How It Works
74
+
75
+ 1. **Upload** โ†’ Content is hashed with SHA256 (content-addressable)
76
+ 2. **Store** โ†’ Artifact stored with hash as key (idempotent)
77
+ 3. **Serve** โ†’ Immutable URL with aggressive caching
78
+ 4. **Deduplicate** โ†’ Same content = same hash = stored once
79
+
80
+ ```typescript
81
+ // Upload the same file twice
82
+ const upload1 = await cdn.put(bytes, { filename: 'logo.png' });
83
+ const upload2 = await cdn.put(bytes, { filename: 'logo.png' });
84
+
85
+ console.log(upload1.hash === upload2.hash); // โ†’ true
86
+ console.log(upload1.created); // โ†’ true (new upload)
87
+ console.log(upload2.created); // โ†’ false (already existed)
88
+ ```
89
+
90
+ ## API Reference
91
+
92
+ ### `CDN`
93
+
94
+ #### Constructor
95
+
96
+ ```typescript
97
+ new CDN(options: CDNOptions)
98
+ ```
99
+
100
+ **Options:**
101
+ - `storage: StorageAdapter` - Storage backend (R2, Memory, etc.)
102
+ - `baseUrl: string` - Base URL for generating artifact URLs
103
+ - `cacheMaxAge?: number` - Cache-Control max-age in seconds (default: 31536000 = 1 year)
104
+ - `defaultContentType?: string` - Fallback content type (default: `application/octet-stream`)
105
+ - `cors?: boolean` - Enable CORS headers (default: `true`)
106
+
107
+ #### Methods
108
+
109
+ ##### `put(content, metadata?): Promise<UploadResult>`
110
+
111
+ Upload an artifact and get content-addressable URL.
112
+
113
+ ```typescript
114
+ const result = await cdn.put(imageBytes, {
115
+ filename: 'logo.png', // Optional: filename (used for content-type detection)
116
+ contentType: 'image/png', // Optional: override content-type
117
+ customMetadata: { // Optional: custom key-value metadata
118
+ author: 'John Doe',
119
+ version: '1.0'
120
+ }
121
+ });
122
+
123
+ console.log(result);
124
+ // {
125
+ // hash: '5e884898da28047151d0e56f8dc629...',
126
+ // url: 'https://cdn.example.com/a/5e88489...png',
127
+ // created: true,
128
+ // metadata: {
129
+ // contentType: 'image/png',
130
+ // filename: 'logo.png',
131
+ // size: 12345,
132
+ // uploadedAt: 1609459200000,
133
+ // customMetadata: { author: 'John Doe', version: '1.0' }
134
+ // }
135
+ // }
136
+ ```
137
+
138
+ ##### `get(hash): Promise<StoredArtifact | null>`
139
+
140
+ Retrieve an artifact by hash.
141
+
142
+ ```typescript
143
+ const artifact = await cdn.get('5e884898da28047151d0e56f8dc629...');
144
+
145
+ if (artifact) {
146
+ console.log(artifact.hash); // SHA256 hash
147
+ console.log(artifact.body); // ArrayBuffer | ReadableStream
148
+ console.log(artifact.metadata); // Metadata object
149
+ }
150
+ ```
151
+
152
+ ##### `delete(hash): Promise<boolean>`
153
+
154
+ Delete an artifact by hash.
155
+
156
+ ```typescript
157
+ const deleted = await cdn.delete('5e884898da28047151d0e56f8dc629...');
158
+ console.log(deleted); // true if deleted, false if not found
159
+ ```
160
+
161
+ ##### `exists(hash): Promise<boolean>`
162
+
163
+ Check if an artifact exists.
164
+
165
+ ```typescript
166
+ const exists = await cdn.exists('5e884898da28047151d0e56f8dc629...');
167
+ ```
168
+
169
+ ##### `list(options?): Promise<string[]>`
170
+
171
+ List all artifact hashes (if supported by storage adapter).
172
+
173
+ ```typescript
174
+ const hashes = await cdn.list({ limit: 100 });
175
+ ```
176
+
177
+ ##### `handleRequest(request): Promise<Response>`
178
+
179
+ Handle HTTP requests (for Cloudflare Workers, Express, etc.).
180
+
181
+ **Supported Routes:**
182
+ - `PUT /artifact?filename=logo.png` - Upload artifact
183
+ - `GET /a/:hash` or `GET /a/:hash.ext` - Retrieve artifact
184
+ - `DELETE /a/:hash` - Delete artifact
185
+ - `OPTIONS *` - CORS preflight
186
+
187
+ ```typescript
188
+ // Cloudflare Workers
189
+ export default {
190
+ async fetch(request: Request, env: Env) {
191
+ const cdn = new CDN({
192
+ storage: new R2Storage(env.ARTIFACTS),
193
+ baseUrl: 'https://cdn.example.com'
194
+ });
195
+ return cdn.handleRequest(request);
196
+ }
197
+ }
198
+ ```
199
+
200
+ ## Storage Adapters
201
+
202
+ ### R2Storage (Cloudflare R2)
203
+
204
+ ```typescript
205
+ import { CDN, R2Storage } from '@sygnl/cdn';
206
+
207
+ const cdn = new CDN({
208
+ storage: new R2Storage(env.ARTIFACTS), // R2 binding from Cloudflare Workers
209
+ baseUrl: 'https://cdn.example.com'
210
+ });
211
+ ```
212
+
213
+ **Requirements:**
214
+ - Cloudflare Workers environment
215
+ - R2 bucket binding in `wrangler.toml`:
216
+
217
+ ```toml
218
+ [[r2_buckets]]
219
+ binding = "ARTIFACTS"
220
+ bucket_name = "my-cdn-artifacts"
221
+ ```
222
+
223
+ ### MemoryStorage (Development/Testing)
224
+
225
+ ```typescript
226
+ import { CDN, MemoryStorage } from '@sygnl/cdn';
227
+
228
+ const cdn = new CDN({
229
+ storage: new MemoryStorage(),
230
+ baseUrl: 'https://cdn.example.com'
231
+ });
232
+ ```
233
+
234
+ **Note:** Data is lost on restart. Use for testing only.
235
+
236
+ ### Custom Storage Adapter
237
+
238
+ Implement the `StorageAdapter` interface:
239
+
240
+ ```typescript
241
+ import type { StorageAdapter, StoredArtifact, ArtifactMetadata } from '@hammr/cdn';
242
+
243
+ class S3Storage implements StorageAdapter {
244
+ async put(hash: string, content: ArrayBuffer | Uint8Array, metadata?: ArtifactMetadata): Promise<void> {
245
+ // Upload to S3
246
+ }
247
+
248
+ async get(hash: string): Promise<StoredArtifact | null> {
249
+ // Retrieve from S3
250
+ }
251
+
252
+ async delete(hash: string): Promise<boolean> {
253
+ // Delete from S3
254
+ }
255
+
256
+ async exists(hash: string): Promise<boolean> {
257
+ // Check if exists in S3
258
+ }
259
+
260
+ async list?(options?: { limit?: number; cursor?: string }): Promise<string[]> {
261
+ // List all hashes (optional)
262
+ }
263
+ }
264
+
265
+ const cdn = new CDN({
266
+ storage: new S3Storage(),
267
+ baseUrl: 'https://cdn.example.com'
268
+ });
269
+ ```
270
+
271
+ ## Content-Type Detection
272
+
273
+ Automatic detection for 40+ file types:
274
+
275
+ | Extension | Content-Type |
276
+ |-----------|--------------|
277
+ | `.png`, `.jpg`, `.gif`, `.webp` | `image/*` |
278
+ | `.pdf`, `.json`, `.xml`, `.txt` | `application/*` or `text/*` |
279
+ | `.js`, `.mjs`, `.ts`, `.wasm` | `application/javascript`, etc. |
280
+ | `.mp3`, `.mp4`, `.webm`, `.ogg` | `audio/*` or `video/*` |
281
+ | `.woff`, `.woff2`, `.ttf`, `.otf` | `font/*` |
282
+
283
+ **Override detection:**
284
+
285
+ ```typescript
286
+ await cdn.put(bytes, {
287
+ filename: 'data.txt',
288
+ contentType: 'application/json' // Override detected type
289
+ });
290
+ ```
291
+
292
+ ## HTTP API
293
+
294
+ ### Upload Artifact
295
+
296
+ **Request:**
297
+ ```http
298
+ PUT /artifact?filename=logo.png HTTP/1.1
299
+ Content-Type: image/png
300
+
301
+ [binary data]
302
+ ```
303
+
304
+ **Response:**
305
+ ```json
306
+ {
307
+ "hash": "5e884898da28047151d0e56f8dc6296...",
308
+ "url": "https://cdn.example.com/a/5e884898...png",
309
+ "created": true,
310
+ "metadata": {
311
+ "contentType": "image/png",
312
+ "filename": "logo.png",
313
+ "size": 12345,
314
+ "uploadedAt": 1609459200000
315
+ }
316
+ }
317
+ ```
318
+
319
+ ### Retrieve Artifact
320
+
321
+ **Request:**
322
+ ```http
323
+ GET /a/5e884898da28047151d0e56f8dc6296...png HTTP/1.1
324
+ ```
325
+
326
+ **Response:**
327
+ ```http
328
+ HTTP/1.1 200 OK
329
+ Content-Type: image/png
330
+ Cache-Control: public, max-age=31536000, immutable
331
+ ETag: "5e884898da28047151d0e56f8dc6296..."
332
+ Access-Control-Allow-Origin: *
333
+
334
+ [binary data]
335
+ ```
336
+
337
+ ### Delete Artifact
338
+
339
+ **Request:**
340
+ ```http
341
+ DELETE /a/5e884898da28047151d0e56f8dc6296... HTTP/1.1
342
+ ```
343
+
344
+ **Response:**
345
+ ```json
346
+ {
347
+ "deleted": true,
348
+ "hash": "5e884898da28047151d0e56f8dc6296..."
349
+ }
350
+ ```
351
+
352
+ ## Examples
353
+
354
+ ### Upload from Form Data
355
+
356
+ ```typescript
357
+ // Client-side
358
+ const formData = new FormData();
359
+ formData.append('file', fileInput.files[0]);
360
+
361
+ const response = await fetch('https://cdn.example.com/artifact?filename=logo.png', {
362
+ method: 'PUT',
363
+ body: await fileInput.files[0].arrayBuffer()
364
+ });
365
+
366
+ const result = await response.json();
367
+ console.log(result.url); // Use this URL in <img> tags, etc.
368
+ ```
369
+
370
+ ### Bulk Upload
371
+
372
+ ```typescript
373
+ const files = ['logo.png', 'icon.svg', 'banner.jpg'];
374
+
375
+ const results = await Promise.all(
376
+ files.map(async (filename) => {
377
+ const bytes = await fs.readFile(filename);
378
+ return cdn.put(bytes, { filename });
379
+ })
380
+ );
381
+
382
+ console.log(results.map(r => r.url));
383
+ ```
384
+
385
+ ### Custom Metadata
386
+
387
+ ```typescript
388
+ const result = await cdn.put(imageBytes, {
389
+ filename: 'product.jpg',
390
+ customMetadata: {
391
+ productId: 'prod_123',
392
+ uploadedBy: 'user_456',
393
+ version: '2.0'
394
+ }
395
+ });
396
+
397
+ // Retrieve metadata later
398
+ const artifact = await cdn.get(result.hash);
399
+ console.log(artifact.metadata.customMetadata.productId); // โ†’ prod_123
400
+ ```
401
+
402
+ ### Verify Upload Integrity
403
+
404
+ ```typescript
405
+ import { sha256 } from '@sygnl/normalizer';
406
+
407
+ // Client computes hash before upload
408
+ const clientHash = await sha256(Array.from(new Uint8Array(fileBytes))
409
+ .map(b => String.fromCharCode(b)).join(''));
410
+
411
+ // Upload
412
+ const result = await cdn.put(fileBytes, { filename: 'file.dat' });
413
+
414
+ // Verify server returned same hash
415
+ if (result.hash === clientHash) {
416
+ console.log('โœ… Upload verified - content matches hash');
417
+ } else {
418
+ console.error('โŒ Upload corrupted - hashes do not match');
419
+ }
420
+ ```
421
+
422
+ ## Configuration
423
+
424
+ ### Cache Strategy
425
+
426
+ ```typescript
427
+ const cdn = new CDN({
428
+ storage: new R2Storage(env.ARTIFACTS),
429
+ baseUrl: 'https://cdn.example.com',
430
+ cacheMaxAge: 31536000, // 1 year (default)
431
+ });
432
+ ```
433
+
434
+ **Cache-Control header:**
435
+ ```
436
+ Cache-Control: public, max-age=31536000, immutable
437
+ ```
438
+
439
+ **Why immutable?**
440
+ Content-addressable URLs never change. The hash IS the content. Safe to cache forever.
441
+
442
+ ### Disable CORS
443
+
444
+ ```typescript
445
+ const cdn = new CDN({
446
+ storage: new R2Storage(env.ARTIFACTS),
447
+ baseUrl: 'https://cdn.example.com',
448
+ cors: false, // Disable CORS headers
449
+ });
450
+ ```
451
+
452
+ ### Custom Base URL with Path
453
+
454
+ ```typescript
455
+ const cdn = new CDN({
456
+ storage: new R2Storage(env.ARTIFACTS),
457
+ baseUrl: 'https://example.com/cdn', // Trailing slash removed automatically
458
+ });
459
+
460
+ const result = await cdn.put(bytes, { filename: 'logo.png' });
461
+ console.log(result.url);
462
+ // โ†’ https://example.com/cdn/a/5e884898...png
463
+ ```
464
+
465
+ ## Production Deployment
466
+
467
+ ### Cloudflare Workers
468
+
469
+ **1. Install dependencies:**
470
+
471
+ ```bash
472
+ npm install @hammr/cdn
473
+ ```
474
+
475
+ **2. Configure `wrangler.toml`:**
476
+
477
+ ```toml
478
+ name = "cdn-worker"
479
+ main = "src/index.ts"
480
+ compatibility_date = "2024-01-01"
481
+
482
+ [[r2_buckets]]
483
+ binding = "ARTIFACTS"
484
+ bucket_name = "my-cdn-artifacts"
485
+ ```
486
+
487
+ **3. Create worker:**
488
+
489
+ ```typescript
490
+ // src/index.ts
491
+ import { CDN, R2Storage } from '@hammr/cdn';
492
+
493
+ interface Env {
494
+ ARTIFACTS: R2Bucket;
495
+ }
496
+
497
+ export default {
498
+ async fetch(request: Request, env: Env): Promise<Response> {
499
+ const cdn = new CDN({
500
+ storage: new R2Storage(env.ARTIFACTS),
501
+ baseUrl: 'https://cdn.example.com'
502
+ });
503
+
504
+ return cdn.handleRequest(request);
505
+ }
506
+ };
507
+ ```
508
+
509
+ **4. Deploy:**
510
+
511
+ ```bash
512
+ npx wrangler deploy
513
+ ```
514
+
515
+ ### Custom Domain
516
+
517
+ In Cloudflare dashboard:
518
+ 1. Workers & Pages โ†’ your worker โ†’ Settings โ†’ Domains & Routes
519
+ 2. Add custom domain: `cdn.example.com`
520
+ 3. Update `baseUrl` in code to match
521
+
522
+ ## Performance
523
+
524
+ ### Benchmarks (Cloudflare Workers + R2)
525
+
526
+ - **Upload (PUT):** ~50ms (includes SHA256 hashing + R2 write)
527
+ - **Retrieve (GET):** ~10-30ms (R2 read, first request)
528
+ - **Retrieve (cached):** ~1-5ms (edge cache hit)
529
+ - **Delete:** ~20ms (R2 delete)
530
+
531
+ ### Optimization Tips
532
+
533
+ 1. **Use R2 for production** - Fast, cheap, globally distributed
534
+ 2. **Enable Cloudflare Cache** - Artifacts cached at edge automatically
535
+ 3. **Use HTTP/2** - Multiplexing for bulk uploads
536
+ 4. **Compress before upload** - Use gzip/brotli for compressible files
537
+
538
+ ## Troubleshooting
539
+
540
+ ### "Cannot find module '@hammr/normalizer'"
541
+
542
+ The normalizer package is a required peer dependency:
543
+
544
+ ```bash
545
+ npm install @hammr/normalizer
546
+ ```
547
+
548
+ ### R2 binding not found
549
+
550
+ Ensure `wrangler.toml` has R2 binding:
551
+
552
+ ```toml
553
+ [[r2_buckets]]
554
+ binding = "ARTIFACTS"
555
+ bucket_name = "my-bucket"
556
+ ```
557
+
558
+ And your worker receives it in `env`:
559
+
560
+ ```typescript
561
+ interface Env {
562
+ ARTIFACTS: R2Bucket;
563
+ }
564
+ ```
565
+
566
+ ### Content-Type not detected
567
+
568
+ Specify explicitly:
569
+
570
+ ```typescript
571
+ await cdn.put(bytes, {
572
+ contentType: 'application/custom'
573
+ });
574
+ ```
575
+
576
+ ### CORS errors
577
+
578
+ Ensure `cors: true` (default):
579
+
580
+ ```typescript
581
+ const cdn = new CDN({
582
+ storage: new R2Storage(env.ARTIFACTS),
583
+ baseUrl: 'https://cdn.example.com',
584
+ cors: true, // Enable CORS (default)
585
+ });
586
+ ```
587
+
588
+ ## Related Packages
589
+
590
+ - [@sygnl/normalizer](https://www.npmjs.com/package/@sygnl/normalizer) - PII normalization & SHA256 hashing (used internally)
591
+ - [@sygnl/identity-manager](https://www.npmjs.com/package/@sygnl/identity-manager) - Session & identity tracking
592
+ - [@sygnl/health-check](https://www.npmjs.com/package/@sygnl/health-check) - Production observability
593
+
594
+ ## License
595
+
596
+ UNLICENSED
597
+
598
+ ## Contributing
599
+
600
+ Issues and PRs welcome! This package is part of the [Sygnl](https://sygnl.io) ecosystem.
601
+
602
+ ---
603
+
604
+ **Built with โค๏ธ by Edge Foundry, Inc.**