Skip to content

Content-addressable CDN with storage abstraction for Cloudflare Workers, Node.js, and beyond.

License

Notifications You must be signed in to change notification settings

edgefoundryinc/Hammr-CDN

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hammr Logo

@hammr/cdn

Content-addressable CDN with storage abstraction for Cloudflare Workers, Node.js, and beyond.

npm version License TypeScript

Features

  • Content-Addressable - Automatic SHA256 hashing via @hammr/normalizer
  • Idempotent Uploads - Same content = same hash, stored once
  • Storage Abstraction - R2, S3, Memory, FileSystem (bring your own!)
  • Auto Content-Type Detection - 40+ file types recognized automatically
  • Immutable Artifacts - Hash-based URLs with aggressive caching
  • HTTP Request Handler - Drop-in handler for Cloudflare Workers
  • Production-Ready - CORS, ETag, Cache-Control, error handling
  • Zero Config - Works out of the box with sensible defaults
  • 100% Test Coverage - Fully tested and type-safe

Installation

npm install @hammr/cdn

Quick Start

Cloudflare Workers (R2 Storage)

import { CDN, R2Storage } from '@hammr/cdn';

export default {
  async fetch(request: Request, env: Env) {
    const cdn = new CDN({
      storage: new R2Storage(env.ARTIFACTS),
      baseUrl: 'https://cdn.example.com'
    });
    
    return cdn.handleRequest(request);
  }
}

Programmatic Usage

import { CDN, MemoryStorage } from '@hammr/cdn';

const cdn = new CDN({
  storage: new MemoryStorage(),
  baseUrl: 'https://cdn.example.com'
});

// Upload artifact
const imageBytes = await fetch('https://example.com/logo.png').then(r => r.arrayBuffer());
const result = await cdn.put(imageBytes, {
  filename: 'logo.png'
});

console.log(result.url);
// → https://cdn.example.com/a/5e884898da28047151d0e56f8dc629...png

// Retrieve artifact
const artifact = await cdn.get(result.hash);
console.log(artifact.metadata.contentType); // → image/png

// Delete artifact
await cdn.delete(result.hash);

How It Works

  1. Upload → Content is hashed with SHA256 (content-addressable)
  2. Store → Artifact stored with hash as key (idempotent)
  3. Serve → Immutable URL with aggressive caching
  4. Deduplicate → Same content = same hash = stored once
// Upload the same file twice
const upload1 = await cdn.put(bytes, { filename: 'logo.png' });
const upload2 = await cdn.put(bytes, { filename: 'logo.png' });

console.log(upload1.hash === upload2.hash); // → true
console.log(upload1.created); // → true (new upload)
console.log(upload2.created); // → false (already existed)

API Reference

CDN

Constructor

new CDN(options: CDNOptions)

Options:

  • storage: StorageAdapter - Storage backend (R2, Memory, etc.)
  • baseUrl: string - Base URL for generating artifact URLs
  • cacheMaxAge?: number - Cache-Control max-age in seconds (default: 31536000 = 1 year)
  • defaultContentType?: string - Fallback content type (default: application/octet-stream)
  • cors?: boolean - Enable CORS headers (default: true)

Methods

put(content, metadata?): Promise<UploadResult>

Upload an artifact and get content-addressable URL.

const result = await cdn.put(imageBytes, {
  filename: 'logo.png',        // Optional: filename (used for content-type detection)
  contentType: 'image/png',    // Optional: override content-type
  customMetadata: {            // Optional: custom key-value metadata
    author: 'John Doe',
    version: '1.0'
  }
});

console.log(result);
// {
//   hash: '5e884898da28047151d0e56f8dc629...',
//   url: 'https://cdn.example.com/a/5e88489...png',
//   created: true,
//   metadata: {
//     contentType: 'image/png',
//     filename: 'logo.png',
//     size: 12345,
//     uploadedAt: 1609459200000,
//     customMetadata: { author: 'John Doe', version: '1.0' }
//   }
// }
get(hash): Promise<StoredArtifact | null>

Retrieve an artifact by hash.

const artifact = await cdn.get('5e884898da28047151d0e56f8dc629...');

if (artifact) {
  console.log(artifact.hash);              // SHA256 hash
  console.log(artifact.body);              // ArrayBuffer | ReadableStream
  console.log(artifact.metadata);          // Metadata object
}
delete(hash): Promise<boolean>

Delete an artifact by hash.

const deleted = await cdn.delete('5e884898da28047151d0e56f8dc629...');
console.log(deleted); // true if deleted, false if not found
exists(hash): Promise<boolean>

Check if an artifact exists.

const exists = await cdn.exists('5e884898da28047151d0e56f8dc629...');
list(options?): Promise<string[]>

List all artifact hashes (if supported by storage adapter).

const hashes = await cdn.list({ limit: 100 });
handleRequest(request): Promise<Response>

Handle HTTP requests (for Cloudflare Workers, Express, etc.).

Supported Routes:

  • PUT /artifact?filename=logo.png - Upload artifact
  • GET /a/:hash or GET /a/:hash.ext - Retrieve artifact
  • DELETE /a/:hash - Delete artifact
  • OPTIONS * - CORS preflight
// Cloudflare Workers
export default {
  async fetch(request: Request, env: Env) {
    const cdn = new CDN({
      storage: new R2Storage(env.ARTIFACTS),
      baseUrl: 'https://cdn.example.com'
    });
    return cdn.handleRequest(request);
  }
}

Storage Adapters

R2Storage (Cloudflare R2)

import { CDN, R2Storage } from '@hammr/cdn';

const cdn = new CDN({
  storage: new R2Storage(env.ARTIFACTS), // R2 binding from Cloudflare Workers
  baseUrl: 'https://cdn.example.com'
});

Requirements:

  • Cloudflare Workers environment
  • R2 bucket binding in wrangler.toml:
[[r2_buckets]]
binding = "ARTIFACTS"
bucket_name = "my-cdn-artifacts"

MemoryStorage (Development/Testing)

import { CDN, MemoryStorage } from '@hammr/cdn';

const cdn = new CDN({
  storage: new MemoryStorage(),
  baseUrl: 'https://cdn.example.com'
});

Note: Data is lost on restart. Use for testing only.

Custom Storage Adapter

Implement the StorageAdapter interface:

import type { StorageAdapter, StoredArtifact, ArtifactMetadata } from '@hammr/cdn';

class S3Storage implements StorageAdapter {
  async put(hash: string, content: ArrayBuffer | Uint8Array, metadata?: ArtifactMetadata): Promise<void> {
    // Upload to S3
  }

  async get(hash: string): Promise<StoredArtifact | null> {
    // Retrieve from S3
  }

  async delete(hash: string): Promise<boolean> {
    // Delete from S3
  }

  async exists(hash: string): Promise<boolean> {
    // Check if exists in S3
  }

  async list?(options?: { limit?: number; cursor?: string }): Promise<string[]> {
    // List all hashes (optional)
  }
}

const cdn = new CDN({
  storage: new S3Storage(),
  baseUrl: 'https://cdn.example.com'
});

Content-Type Detection

Automatic detection for 40+ file types:

Extension Content-Type
.png, .jpg, .gif, .webp image/*
.pdf, .json, .xml, .txt application/* or text/*
.js, .mjs, .ts, .wasm application/javascript, etc.
.mp3, .mp4, .webm, .ogg audio/* or video/*
.woff, .woff2, .ttf, .otf font/*

Override detection:

await cdn.put(bytes, {
  filename: 'data.txt',
  contentType: 'application/json' // Override detected type
});

HTTP API

Upload Artifact

Request:

PUT /artifact?filename=logo.png HTTP/1.1
Content-Type: image/png

[binary data]

Response:

{
  "hash": "5e884898da28047151d0e56f8dc6296...",
  "url": "https://cdn.example.com/a/5e884898...png",
  "created": true,
  "metadata": {
    "contentType": "image/png",
    "filename": "logo.png",
    "size": 12345,
    "uploadedAt": 1609459200000
  }
}

Retrieve Artifact

Request:

GET /a/5e884898da28047151d0e56f8dc6296...png HTTP/1.1

Response:

HTTP/1.1 200 OK
Content-Type: image/png
Cache-Control: public, max-age=31536000, immutable
ETag: "5e884898da28047151d0e56f8dc6296..."
Access-Control-Allow-Origin: *

[binary data]

Delete Artifact

Request:

DELETE /a/5e884898da28047151d0e56f8dc6296... HTTP/1.1

Response:

{
  "deleted": true,
  "hash": "5e884898da28047151d0e56f8dc6296..."
}

Examples

Upload from Form Data

// Client-side
const formData = new FormData();
formData.append('file', fileInput.files[0]);

const response = await fetch('https://cdn.example.com/artifact?filename=logo.png', {
  method: 'PUT',
  body: await fileInput.files[0].arrayBuffer()
});

const result = await response.json();
console.log(result.url); // Use this URL in <img> tags, etc.

Bulk Upload

const files = ['logo.png', 'icon.svg', 'banner.jpg'];

const results = await Promise.all(
  files.map(async (filename) => {
    const bytes = await fs.readFile(filename);
    return cdn.put(bytes, { filename });
  })
);

console.log(results.map(r => r.url));

Custom Metadata

const result = await cdn.put(imageBytes, {
  filename: 'product.jpg',
  customMetadata: {
    productId: 'prod_123',
    uploadedBy: 'user_456',
    version: '2.0'
  }
});

// Retrieve metadata later
const artifact = await cdn.get(result.hash);
console.log(artifact.metadata.customMetadata.productId); // → prod_123

Verify Upload Integrity

import { sha256 } from '@hammr/normalizer';

// Client computes hash before upload
const clientHash = await sha256(Array.from(new Uint8Array(fileBytes))
  .map(b => String.fromCharCode(b)).join(''));

// Upload
const result = await cdn.put(fileBytes, { filename: 'file.dat' });

// Verify server returned same hash
if (result.hash === clientHash) {
  console.log('✅ Upload verified - content matches hash');
} else {
  console.error('❌ Upload corrupted - hashes do not match');
}

Configuration

Cache Strategy

const cdn = new CDN({
  storage: new R2Storage(env.ARTIFACTS),
  baseUrl: 'https://cdn.example.com',
  cacheMaxAge: 31536000, // 1 year (default)
});

Cache-Control header:

Cache-Control: public, max-age=31536000, immutable

Why immutable? Content-addressable URLs never change. The hash IS the content. Safe to cache forever.

Disable CORS

const cdn = new CDN({
  storage: new R2Storage(env.ARTIFACTS),
  baseUrl: 'https://cdn.example.com',
  cors: false, // Disable CORS headers
});

Custom Base URL with Path

const cdn = new CDN({
  storage: new R2Storage(env.ARTIFACTS),
  baseUrl: 'https://example.com/cdn', // Trailing slash removed automatically
});

const result = await cdn.put(bytes, { filename: 'logo.png' });
console.log(result.url);
// → https://example.com/cdn/a/5e884898...png

Production Deployment

Cloudflare Workers

1. Install dependencies:

npm install @hammr/cdn

2. Configure wrangler.toml:

name = "cdn-worker"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[[r2_buckets]]
binding = "ARTIFACTS"
bucket_name = "my-cdn-artifacts"

3. Create worker:

// src/index.ts
import { CDN, R2Storage } from '@hammr/cdn';

interface Env {
  ARTIFACTS: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cdn = new CDN({
      storage: new R2Storage(env.ARTIFACTS),
      baseUrl: 'https://cdn.example.com'
    });

    return cdn.handleRequest(request);
  }
};

4. Deploy:

npx wrangler deploy

Custom Domain

In Cloudflare dashboard:

  1. Workers & Pages → your worker → Settings → Domains & Routes
  2. Add custom domain: cdn.example.com
  3. Update baseUrl in code to match

Performance

Benchmarks (Cloudflare Workers + R2)

  • Upload (PUT): ~50ms (includes SHA256 hashing + R2 write)
  • Retrieve (GET): ~10-30ms (R2 read, first request)
  • Retrieve (cached): ~1-5ms (edge cache hit)
  • Delete: ~20ms (R2 delete)

Optimization Tips

  1. Use R2 for production - Fast, cheap, globally distributed
  2. Enable Cloudflare Cache - Artifacts cached at edge automatically
  3. Use HTTP/2 - Multiplexing for bulk uploads
  4. Compress before upload - Use gzip/brotli for compressible files

Troubleshooting

"Cannot find module '@hammr/normalizer'"

The normalizer package is a required peer dependency:

npm install @hammr/normalizer

R2 binding not found

Ensure wrangler.toml has R2 binding:

[[r2_buckets]]
binding = "ARTIFACTS"
bucket_name = "my-bucket"

And your worker receives it in env:

interface Env {
  ARTIFACTS: R2Bucket;
}

Content-Type not detected

Specify explicitly:

await cdn.put(bytes, {
  contentType: 'application/custom'
});

CORS errors

Ensure cors: true (default):

const cdn = new CDN({
  storage: new R2Storage(env.ARTIFACTS),
  baseUrl: 'https://cdn.example.com',
  cors: true, // Enable CORS (default)
});

Related Packages

License

Apache 2.0

Contributing

Issues and PRs welcome! This package is part of the Hammr ecosystem.


Built with ❤️ by Edge Foundry, Inc.

About

Content-addressable CDN with storage abstraction for Cloudflare Workers, Node.js, and beyond.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published