76 lines
7.2 KiB
Markdown
76 lines
7.2 KiB
Markdown
# Add @suaveplan/storage-cdn — CDN adapter interface over AbstractStorage
|
|
|
|
**Package**: `@suaveplan/storage-cdn`
|
|
**Status**: PROPOSED
|
|
**Type**: new-package (genesis-original)
|
|
|
|
- **Design**: ./design.md
|
|
- **Spec delta**: ./specs/storage-cdn/spec.md
|
|
|
|
## Why
|
|
|
|
`@suaveplan/storage-http` (separate proposal) covers the generic "URL prefix + key" read surface. Real CDN providers (Rackspace Cloud Files + Rackspace CDN, BunnyCDN Storage + Pull Zones, Cloudflare R2 + custom domain, Fastly + origin, etc.) add **three capabilities the generic adapter cannot express**:
|
|
|
|
1. **Cache purge / invalidation** — purge a key or prefix from edge POPs after origin updates.
|
|
2. **Signed / temporary URLs** — produce a short-lived URL the caller can hand to a browser without exposing credentials.
|
|
3. **Origin vs edge split** — writes go to a private origin endpoint; reads go through a public edge URL. The adapter needs both.
|
|
|
|
`storage-cdn` is the **types-only foundation** that defines a common contract (`CdnAdapter extends AbstractStorage` + `CdnCapabilities` + `purge()` / `signUrl()` / `edgeUrl()`) so consumers can write provider-agnostic code and provider packages (`storage-cdn-rackspace`, `storage-cdn-bunnycdn`, future entrants) implement against the same surface. The package mirrors the role `@suaveplan/db` plays for database adapters, or `@suaveplan/network` plays for transports: a small core that establishes the contract, with concrete adapters in sibling packages.
|
|
|
|
## What changes
|
|
|
|
- ADDED: `CdnAdapter` abstract class extending `AbstractStorage` from `@suaveplan/storage`. Adds three abstract methods: `purge(key: string, signal?: AbortSignal): Promise<void>`, `signUrl(key: string, options: SignUrlOptions, signal?: AbortSignal): Promise<string>`, `edgeUrl(key: string): string`.
|
|
- ADDED: `CdnCapabilities` interface extending `StorageCapabilities` with `cdnPurge: boolean`, `cdnSignedUrls: boolean`, `cdnPrefixPurge: boolean`, `cdnOriginUploads: boolean`. Defaults all `false`.
|
|
- ADDED: `SignUrlOptions` interface — `{ ttlSeconds: number; method?: "GET" | "PUT" | "HEAD"; responseContentType?: string; responseContentDisposition?: string }`. Zod schema for boundary validation.
|
|
- ADDED: `CdnAdapterOptions` base interface (provider packages extend it) — `originBaseUrl: string` (write endpoint, must end with `/`), `edgeBaseUrl: string` (public read endpoint, must end with `/`), `credentials` (provider-specific subtype slot), `fetch?: FetchLike`.
|
|
- ADDED: `STORAGE_CDN_CODES` registry via `defineCodes("STORAGE_CDN", [...])` — 8 codes: `PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`.
|
|
- ADDED: `StorageCdnError extends SuaveplanError<StorageCdnErrorCode>` with `key`, `provider`, and `purgeId` discriminants.
|
|
- ADDED: `defaultPurgeNotSupported()` and `defaultSignUrlNotSupported()` reference implementations — concrete `CdnAdapter` subclasses that lack a capability MUST throw via these helpers so the error surface is uniform.
|
|
- ADDED: `assertCdnAdapter(value)` type-guard for runtime narrowing — useful when accepting a `StorageLike` and needing to opt into CDN features.
|
|
- ADDED: Module-augmentation slot — `declare module "@suaveplan/storage"` augments `StorageCapabilities` with `cdn?: CdnCapabilities` (optional nested capability object). Provider packages set `capabilities.cdn = { … }`.
|
|
|
|
## Out of scope
|
|
|
|
- **Concrete provider implementations.** `storage-cdn-rackspace` and `storage-cdn-bunnycdn` are separate change proposals. This package is **types-only foundation** plus the error registry and helper functions — no provider-specific code.
|
|
- **Listing.** Inherited from `AbstractStorage`; provider packages decide whether their origin API supports it. The CDN abstraction itself adds no listing contract.
|
|
- **Cache-control header policy.** `Cache-Control` / `Expires` belong to the consumer's content strategy and are emitted by the origin server config, not the adapter. The adapter MAY pass-through headers via `set` options but does not own the policy.
|
|
- **Origin shielding / multi-origin failover.** A failover/shielding layer is a separate concern composed via `storage-mux` (future) or application logic.
|
|
- **Edge-side rendering integration** (image transforms, video JIT, etc.). Out of scope; provider-specific and orthogonal to storage semantics.
|
|
|
|
## Source mapping
|
|
|
|
Genesis-original. No upstream in `../suaveplan/`. The package contract derives from:
|
|
|
|
- `openspec/specs/storage/spec.md` — `AbstractStorage` base.
|
|
- Industry conventions: AWS S3 `PutObject` + CloudFront `CreateInvalidation`; Rackspace Cloud Files + CDN purge; BunnyCDN Storage + Pull Zone purge; Cloudflare R2 + cache purge API. The three abstract methods (`purge`, `signUrl`, `edgeUrl`) are the intersection of every major CDN.
|
|
|
|
### Requirements with no source line
|
|
|
|
| Requirement | Justification |
|
|
|---|---|
|
|
| Extend `AbstractStorage` | Every storage adapter in genesis extends `AbstractStorage` for `StorageProviderRegistry` compatibility. |
|
|
| `originBaseUrl` and `edgeBaseUrl` distinct fields | The two URLs are genuinely different on every real CDN (Rackspace `storage101.<dc>.clouddrive.com` vs `<container>.cdn.clouddrive.com`; BunnyCDN `storage.bunnycdn.com/<zone>` vs `<zone>.b-cdn.net`). Conflating them produces broken signed URLs and wasted edge cache. |
|
|
| `signUrl` returns `Promise<string>` not `string` | Some providers compute signed URLs via an API call (Rackspace temp URLs are HMAC-local, but R2 presigned URLs require an STS call). Returning a Promise keeps both shapes uniform. |
|
|
| `purge` is a single-key method | Multi-key / prefix purge is a separate capability flag (`cdnPrefixPurge`). Many providers rate-limit prefix purge heavily; forcing single-key as the minimum contract keeps the cheap path cheap. |
|
|
| Error code `EDGE_ORIGIN_MISMATCH` | A common foot-gun is configuring `originBaseUrl` and `edgeBaseUrl` against different containers/zones. The adapter validates at construction time and surfaces a clear code rather than mysterious 404s at runtime. |
|
|
|
|
## Verification approach
|
|
|
|
- `src/cdn-adapter/cdn-adapter.test.ts` — abstract class shape, capability defaults, default-not-supported helpers throw correctly.
|
|
- `src/cdn-adapter/cdn-adapter.spec-conformance.test.ts` — type-level conformance with `AbstractStorage`, `assertCdnAdapter` discriminates correctly.
|
|
- `src/sign-url/sign-url.test.ts` — `SignUrlOptionsSchema` validates `ttlSeconds > 0`, method whitelist, response-* fields optional.
|
|
- `src/error-codes/error-codes.test.ts` — every `STORAGE_CDN_*` code reachable from a real call site.
|
|
|
|
## Impact
|
|
|
|
### Affected dependents
|
|
|
|
Run `bunx turbo ls --filter=...@suaveplan/storage-cdn` to enumerate. The package is new — no dependents currently. The two follow-up changes (`add-storage-cdn-rackspace`, `add-storage-cdn-bunnycdn`) declare a hard dependency on this package.
|
|
|
|
- No dependents yet (verified by `bunx turbo ls --filter=...@suaveplan/storage-cdn` returning the package itself).
|
|
- Forward-declared consumers: `@suaveplan/storage-cdn-rackspace`, `@suaveplan/storage-cdn-bunnycdn`.
|
|
|
|
### Affected upstreams
|
|
|
|
- `@suaveplan/storage` — `StorageCapabilities` module-augmented with optional `cdn?: CdnCapabilities`. Additive; no breaking change. [x] no action needed.
|