Files
suaveplan/openspec/archive/2026-05-16-add-storage-cdn/design.md
2026-05-27 23:29:53 +01:00

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. Throws STORAGE_CDN.PURGE_NOT_SUPPORTED if capabilities.cdn.cdnPurge === false.
  • signUrl — produces a short-lived public URL. Throws STORAGE_CDN.SIGN_URL_NOT_SUPPORTED if capabilities.cdn.cdnSignedUrls === false. The method option 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 — CdnAdapter becomes a second extension point alongside AbstractStorage — is small because providers always know they are CDN providers when authoring.
  • signUrl Promise 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.
  • edgeUrl synchronous. Pure URL composition is always available and never throws — there is no reason to make it async. Forces provider authors to ensure edgeBaseUrl is validated at construction time, which is the right invariant.