4.5 KiB
4.5 KiB
Design — @suaveplan/storage-cdn
Non-normative companion to proposal.md and specs/storage-cdn/spec.md.
1. Role in the family
@suaveplan/storage AbstractStorage core (key/value, in-memory)
└─ @suaveplan/storage-http Generic prefix+key reader (no purge/signing)
└─ @suaveplan/storage-cdn CdnAdapter contract (THIS PACKAGE)
├─ @suaveplan/storage-cdn-rackspace Cloud Files origin + Rackspace CDN
└─ @suaveplan/storage-cdn-bunnycdn BunnyCDN Storage origin + Pull Zone
storage-http and storage-cdn are siblings, not layers. A consumer that only needs read access to a CDN-fronted static site uses storage-http. A consumer that needs purge + signed URLs uses a storage-cdn-* provider. The two abstractions stay separable so neither pulls weight the other does not need.
2. The contract — three methods
abstract class CdnAdapter extends AbstractStorage {
abstract purge(key: string, signal?: AbortSignal): Promise<void>;
abstract signUrl(key: string, options: SignUrlOptions, signal?: AbortSignal): Promise<string>;
abstract edgeUrl(key: string): string; // pure, synchronous; just URL composition
readonly capabilities: StorageCapabilities & { cdn: CdnCapabilities };
}
purge— single-key edge invalidation. ThrowsSTORAGE_CDN.PURGE_NOT_SUPPORTEDifcapabilities.cdn.cdnPurge === false.signUrl— produces a short-lived public URL. ThrowsSTORAGE_CDN.SIGN_URL_NOT_SUPPORTEDifcapabilities.cdn.cdnSignedUrls === false. Themethodoption distinguishes GET (signed read), HEAD (signed exists), PUT (signed upload — only meaningful when the provider supports it).edgeUrl— pure composition:new URL(encodedKey, edgeBaseUrl).toString(). Synchronous. Every provider supports this — it is just URL math.
purge taking a prefix is a separate capability (cdnPrefixPurge). Rationale in proposal §"Out of scope".
3. Capability matrix examples
| Provider | cdnPurge | cdnPrefixPurge | cdnSignedUrls | cdnOriginUploads |
|---|---|---|---|---|
| Rackspace Cloud Files + CDN | ✅ | ⚠️ via re-list+per-key | ✅ HMAC-SHA1 TempURL | ✅ Swift PUT |
| BunnyCDN Storage + Pull Zone | ✅ | ✅ (/purge?url=…/*) |
✅ HMAC-SHA256 Token Auth | ✅ HTTP PUT |
| Cloudflare R2 + custom domain | ✅ via Cache API | ✅ tag-based | ✅ presigned (S3-compat) | ✅ S3-compat |
The matrix is a guide for provider package authors, not a normative requirement of this package.
4. File layout
src/
index.ts # explicit named re-exports
error-codes.ts # defineCodes("STORAGE_CDN", [...]) + StorageCdnError
types/
cdn-adapter.types.ts # CdnAdapter abstract class, CdnAdapterOptions
cdn-capabilities.types.ts # CdnCapabilities interface + DEFAULT_CDN_CAPABILITIES
sign-url.types.ts # SignUrlOptions interface
sign-url.schemas.ts # SignUrlOptionsSchema (Zod)
cdn-adapter/
cdn-adapter.ts # base class with default-not-supported helpers
cdn-adapter.md
cdn-adapter.test.ts
cdn-adapter.spec-conformance.test.ts
sign-url/
sign-url.ts # schema parse + boundary helpers
sign-url.md
sign-url.test.ts
guards/
guards.ts # assertCdnAdapter, isCdnAdapter
guards.md
guards.test.ts
5. Tier purity
Universal — no node:*, no DOM. Pure types + small helper functions.
6. Trade-offs
- One abstract base vs. interface mixed into AbstractStorage. Chose abstract base. Reason: provider packages can use
defaultPurgeNotSupported()/defaultSignUrlNotSupported()helpers from a real super-class without each re-implementing them. The cost —CdnAdapterbecomes a second extension point alongsideAbstractStorage— is small because providers always know they are CDN providers when authoring. signUrlPromise vs. synchronous. Some providers (Rackspace TempURL, BunnyCDN Token Auth) are HMAC-local and could be sync. Others (R2 presigned URLs via STS, signed cookies via auth API) are async. Forcing Promise everywhere keeps callers uniform; sync providers just resolve immediately.edgeUrlsynchronous. Pure URL composition is always available and never throws — there is no reason to make it async. Forces provider authors to ensureedgeBaseUrlis validated at construction time, which is the right invariant.