first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
# @suaveplan/storage-cdn - Types-only foundation for CDN-flavoured @suaveplan/storage adapters: CdnAdapter abstract base extending AbstractStorage with purge/signUrl/edgeUrl, CdnCapabilities matrix, Zod-validated SignUrlOptions, STORAGE_CDN error registry, type-guard helpers, and a module-augmentation slot on @suaveplan/storage's capabilities map. Provider packages (storage-cdn-rackspace, storage-cdn-bunnycdn, …) implement against this contract.
Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`, `signUrl`, and `edgeUrl`), the `CdnCapabilities` boolean matrix, Zod-validated `SignUrlOptions`, the `STORAGE_CDN` error registry, type-guard helpers, and the module augmentation that adds an optional `cdn?: CdnCapabilities` slot to `@suaveplan/storage`'s capabilities map. Provider packages (`storage-cdn-rackspace`, `storage-cdn-bunnycdn`, …) implement against this contract.
<!-- begin:auto-docs -->
## Documentation tree
- [cdn-adapter](./src/cdn-adapter/cdn-adapter.md) — Declares `CdnAdapter`, the abstract base class every CDN-flavoured storage adapter in the family extends. Layers three…
- [cdn-adapter](./src/cdn-adapter/cdn-adapter.md) — Declares `CdnAdapter`, the abstract base class every CDN-flavoured storage adapter in the family extends. Layers three…
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN` error namespace shared by every CDN provider package in the family — `storage-cdn-…
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN` error namespace shared by every CDN provider package in the family — `storage-cdn-…
- [guards](./src/guards/guards.md) — Ships two type-guards for `CdnAdapter`: the non-throwing `isCdnAdapter` predicate and the throwing `assertCdnAdapter` a…
- [guards](./src/guards/guards.md) — Ships two type-guards for `CdnAdapter`: the non-throwing `isCdnAdapter` predicate and the throwing `assertCdnAdapter` a…
- [sign-url](./src/sign-url/sign-url.md) — Owns the boundary validation for `CdnAdapter.signUrl` callers. Wraps the Zod `SignUrlOptionsSchema` parse and converts…
- [sign-url](./src/sign-url/sign-url.md) — Owns the boundary validation for `CdnAdapter.signUrl` callers. Wraps the Zod `SignUrlOptionsSchema` parse and converts…
- [storage-augment](./src/storage-augment.md) — Owns the TypeScript module augmentation that adds an optional `cdn?: CdnCapabilities` slot to `@suaveplan/storage`'s `S…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/storage-cdn
```
## Quick start
```ts
import { CdnAdapter, parseSignUrlOptions } from "@suaveplan/storage-cdn";
import { AbstractStorage } from "@suaveplan/storage/abstract";
class MyCdn extends CdnAdapter {
// ... AbstractStorage methods ...
public override async purge(key: string): Promise<void> {
await this.client.invalidate(key);
}
public override async signUrl(key: string, options: SignUrlOptions): Promise<string> {
const opts = parseSignUrlOptions(options);
return await this.client.presign(key, opts);
}
public override edgeUrl(key: string): string {
return `https://edge.example.com/${key}`;
}
}
```
Consumers that just need to test whether a `StorageLike` supports CDN features use the guards:
```ts
import { isCdnAdapter } from "@suaveplan/storage-cdn";
if (isCdnAdapter(storage)) {
await storage.purge("a/b.bin");
const signed = await storage.signUrl("a/b.bin", { ttlSeconds: 60 });
}
```
## Family role
```text
@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
```
A consumer that only needs read access to a CDN-fronted static site uses `storage-http`. A consumer that needs purge and signed URLs uses a `storage-cdn-*` provider. The two abstractions stay separable so neither pulls weight the other does not need.
## Modules
- [error-codes](./src/error-codes.md) — `STORAGE_CDN_CODES` registry + `StorageCdnError`.
- [cdn-adapter](./src/cdn-adapter/cdn-adapter.md) — abstract `CdnAdapter` base + `defaultPurgeNotSupported` / `defaultSignUrlNotSupported` / `defaultPrefixPurgeNotSupported` helpers.
- [sign-url](./src/sign-url/sign-url.md) — `parseSignUrlOptions` boundary validator.
- [guards](./src/guards/guards.md) — `isCdnAdapter` / `assertCdnAdapter`.
- [storage-augment](./src/storage-augment.md) — module augmentation for `capabilities.cdn`.
- [types/cdn-adapter.types](./src/types/cdn-adapter.types.md) — `CdnAdapterOptions` base interface.
- [types/cdn-capabilities.types](./src/types/cdn-capabilities.types.md) — `CdnCapabilities` + `DEFAULT_CDN_CAPABILITIES`.
- [types/sign-url.types](./src/types/sign-url.types.md) — `SignUrlOptions` + `SignUrlMethod`.
## Features
- **Universal purity** — no `node:*` or DOM-only imports. Types and Zod schemas only — no network, no I/O.
- **Abstract `CdnAdapter` base** — three abstract methods (`purge`, `signUrl`, `edgeUrl`) every provider implements.
- **Tightened capabilities** — `CdnAdapter.capabilities` requires a nested `cdn: CdnCapabilities` matrix; base `AbstractStorage` keeps it optional.
- **Canonical error registry** — 8 stable codes (`PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`) shared by every provider.
- **Boundary validation** — `parseSignUrlOptions` maps Zod issues to the canonical codes so every provider's diagnostic surface is identical.
- **Type guards** — `isCdnAdapter` / `assertCdnAdapter` for safe narrowing from `StorageLike`.
- **100 / 100 / 100 / 100 Istanbul coverage**.
## License
MIT — see `LICENSE` at the repo root.

View File

@@ -0,0 +1,99 @@
# cdn-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares `CdnAdapter`, the abstract base class every CDN-flavoured storage adapter in the family extends. Layers three CDN-specific abstract methods (`purge`, `signUrl`, `edgeUrl`) on top of `AbstractStorage` and tightens `capabilities` to require a nested `cdn: CdnCapabilities` matrix. Also ships the canonical "not supported" reference implementations (`defaultPurgeNotSupported`, `defaultSignUrlNotSupported`, `defaultPrefixPurgeNotSupported`) so every provider's "this feature is off" surface looks identical to callers and to operator dashboards.
The class sits between `AbstractStorage` (the generic key-value contract) and concrete provider packages (`storage-cdn-rackspace`, `storage-cdn-bunnycdn`, …). Consumers that need provider-agnostic CDN behaviour code against `CdnAdapter` directly; consumers that need a specific provider's quirks (Rackspace TempURL key rotation, BunnyCDN Pull Zone hostname) reach for the provider package.
## Features
- Three abstract CDN methods every subclass MUST implement: `purge`, `signUrl`, `edgeUrl`.
- `edgeUrl` is synchronous (pure URL composition) — every provider supports it because it is just URL math.
- `purge` and `signUrl` are async to accommodate providers whose signing or invalidation requires an upstream call (R2 STS, Rackspace identity token refresh).
- Helper functions raise `STORAGE_CDN.PURGE_NOT_SUPPORTED`, `STORAGE_CDN.SIGN_URL_NOT_SUPPORTED`, `STORAGE_CDN.PREFIX_PURGE_NOT_SUPPORTED` so every provider's "not supported" surface is uniform.
- Optional `provider` attribute on the thrown errors so operator dashboards can group failures by upstream.
## Usage: Basic
```ts
import { CdnAdapter, defaultPurgeNotSupported } from "@suaveplan/storage-cdn";
class ReadOnlyCdn extends CdnAdapter {
// ... AbstractStorage methods ...
public purge(): Promise<void> {
defaultPurgeNotSupported("read-only");
}
public signUrl(): Promise<string> {
return Promise.resolve("https://edge.example.com/x");
}
public edgeUrl(key: string): string {
return `https://edge.example.com/${key}`;
}
}
```
## Usage: Advanced
```ts
import { CdnAdapter, defaultPurgeNotSupported, defaultPrefixPurgeNotSupported } from "@suaveplan/storage-cdn";
class MyProviderCdn extends CdnAdapter {
public override readonly capabilities = {
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: true,
cdnPrefixPurge: false,
cdnSignedUrls: true,
cdnOriginUploads: true,
},
} as const;
public override async purge(key: string, signal?: AbortSignal): Promise<void> {
await this.client.invalidate(key, signal);
}
public override purgePrefix(): Promise<void> {
return defaultPrefixPurgeNotSupported("my-provider");
}
// ... rest of impl ...
}
```
## API Reference
### `abstract class CdnAdapter extends AbstractStorage`
Adds three abstract methods plus a tightened `capabilities` shape.
- `abstract purge(key: string, signal?: AbortSignal): Promise<void>` — invalidate a single key.
- `abstract signUrl(key, options: SignUrlOptions, signal?): Promise<string>` — short-lived signed URL.
- `abstract edgeUrl(key: string): string` — pure synchronous URL composition.
- `capabilities: StorageCapabilities & { cdn: CdnCapabilities }` — tighter than the base; subclasses MUST populate the nested matrix.
### `defaultPurgeNotSupported(provider?: string): never`
Throws `StorageCdnError(STORAGE_CDN.PURGE_NOT_SUPPORTED)`. `provider` attached when supplied.
### `defaultSignUrlNotSupported(provider?: string): never`
Throws `StorageCdnError(STORAGE_CDN.SIGN_URL_NOT_SUPPORTED)`.
### `defaultPrefixPurgeNotSupported(provider?: string): never`
Throws `StorageCdnError(STORAGE_CDN.PREFIX_PURGE_NOT_SUPPORTED)`.
## Implementation Notes
- `edgeUrl` is intentionally synchronous because URL composition never fails — forcing it async would push every consumer through an unnecessary `await`. `signUrl` and `purge` are async because real providers do make upstream calls (STS, identity, purge API).
- The `capabilities` type intersects `StorageCapabilities` with a required `{ cdn: CdnCapabilities }`. The module augmentation in `storage-augment.ts` makes `cdn` optional on the base type — required on `CdnAdapter` but absent on plain `AbstractStorage` adapters.
- "Not supported" helpers are thrown synchronously even though the abstract methods are async. Callers see a rejected promise either way; the helper is simpler when it just `throws`.
- Multi-key prefix purge is intentionally a separate flag (`cdnPrefixPurge`) and a separate "not supported" helper because many providers (Rackspace) support single-key purge but not prefix purge. Forcing them to be one flag would either mislead callers (claiming prefix when only per-key works) or force providers to declare neither.

View File

@@ -0,0 +1,75 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the canonical `STORAGE_CDN` error namespace shared by every CDN provider package in the family — `storage-cdn-rackspace`, `storage-cdn-bunnycdn`, and any future entrants. Codes are produced via `@suaveplan/error.defineCodes` so the resulting string values are guaranteed-unique within the workspace (`"STORAGE_CDN.PURGE_FAILED"`, `"STORAGE_CDN.SIGN_URL_NOT_SUPPORTED"`, …). The companion `StorageCdnError` class extends `SuaveplanError` and adds three diagnostic discriminants — `key`, `provider`, and `purgeId` — so consumers can correlate failures with the offending operation without parsing message text.
## Features
- 8 stable error codes covering every CDN-specific failure mode.
- `StorageCdnError` subclass with `key`, `provider`, and `purgeId` carry fields.
- Cause-chain preservation via `SuaveplanError`'s `cause` option.
- Type-safe `StorageCdnErrorCode` discriminant inferred from the `defineCodes` tuple.
## Usage: Basic
```ts
import { STORAGE_CDN_CODES, StorageCdnError } from "@suaveplan/storage-cdn";
throw new StorageCdnError(
STORAGE_CDN_CODES.PURGE_FAILED,
"Edge purge timed out",
{ key: "a/b.bin", provider: "rackspace", purgeId: "req-1234" },
);
```
## Usage: Advanced
```ts
import { STORAGE_CDN_CODES, StorageCdnError } from "@suaveplan/storage-cdn";
try {
await storage.purge("a/b.bin");
} catch (err) {
if (err instanceof StorageCdnError && err.code === STORAGE_CDN_CODES.PURGE_FAILED) {
operatorLog.error(
{ key: err.key, provider: err.provider, purgeId: err.purgeId },
"CDN purge failed — alerting on-call",
);
await pagerDuty.alert({ summary: `Purge failed: ${err.message}` });
}
throw err;
}
```
## API Reference
### `STORAGE_CDN_CODES`
Frozen record produced by `defineCodes("STORAGE_CDN", [...] as const)`. The string values are namespaced — `STORAGE_CDN_CODES.PURGE_FAILED === "STORAGE_CDN.PURGE_FAILED"`.
Codes: `PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`.
### `StorageCdnErrorCode`
Union of the namespaced code string values.
### `class StorageCdnError extends SuaveplanError<StorageCdnErrorCode>`
- `constructor(code, message, options?)` — `options` accepts `cause`, `details`, `key`, `provider`, `purgeId`.
- `key: string | undefined` — sanitised key on which the failure occurred.
- `provider: string | undefined` — provider identifier (`"rackspace"`, `"bunnycdn"`, …).
- `purgeId: string | undefined` — provider-issued purge request id when available.
- `name = "StorageCdnError"`.
## Implementation Notes
- `STORAGE_CDN.SIGN_URL_NOT_SUPPORTED` and `STORAGE_CDN.PURGE_NOT_SUPPORTED` are produced by the shipped `defaultSignUrlNotSupported()` / `defaultPurgeNotSupported()` helpers so every provider's "not supported" surface is identical.
- `STORAGE_CDN.PREFIX_PURGE_NOT_SUPPORTED` exists separately from `PURGE_NOT_SUPPORTED` because providers commonly support per-key purge but not prefix purge (Rackspace is the canonical example).
- `STORAGE_CDN.EDGE_ORIGIN_MISMATCH` is the configuration guard raised when a provider adapter detects that its `originBaseUrl` and `edgeBaseUrl` reference different containers / zones — a common foot-gun that produces mysterious 404s at runtime.
- `STORAGE_CDN.CREDENTIAL_EXPIRED` is provider-agnostic — Rackspace tokens, BunnyCDN purge keys, signed-URL signing keys all eventually expire and share the same code so monitoring rules generalise across providers.
- The `key`, `provider`, and `purgeId` carry-fields are all optional so unit-test fixtures can construct minimal errors without inventing fake provider identifiers.

View File

@@ -0,0 +1,89 @@
# guards
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ships two type-guards for `CdnAdapter`: the non-throwing `isCdnAdapter`
predicate and the throwing `assertCdnAdapter` assertion. Consumers that
accept a structural `StorageLike` and need to opt into CDN features at
the call site use these to narrow safely. The throwing variant raises a
canonical `StorageCdnError` so the rejection has the same diagnostic
surface as every other CDN error.
Without these helpers each consumer would re-implement
`value instanceof CdnAdapter` checks and the assertion variant would
either silently degrade or throw a plain `Error` — both bad outcomes for
operator dashboards.
## Features
- Two functions, both tiny — `isCdnAdapter` and `assertCdnAdapter`.
- Predicate form returns `value is CdnAdapter` so TypeScript narrows
cleanly inside the `if` branch.
- Assertion form uses TypeScript `asserts` syntax so the caller's flow
is narrowed after the call without needing an explicit `if`.
- Both rely on `instanceof CdnAdapter` — no structural duck typing, so
third-party look-alikes don't sneak through.
## Usage: Basic
```ts
import { isCdnAdapter } from "@suaveplan/storage-cdn";
if (isCdnAdapter(storage)) {
await storage.purge("a/b.bin");
}
```
## Usage: Advanced
```ts
import { assertCdnAdapter, STORAGE_CDN_CODES, StorageCdnError } from "@suaveplan/storage-cdn";
function purgeAfterWrite(storage: unknown, key: string): Promise<void> {
assertCdnAdapter(storage); // throws if not a CdnAdapter
return storage.purge(key);
}
try {
await purgeAfterWrite(new MemoryStorage(), "k");
} catch (err) {
if (err instanceof StorageCdnError && err.code === STORAGE_CDN_CODES.INVALID_OPTIONS) {
// caller passed the wrong storage type — surface a clear message.
}
}
```
## API Reference
### `isCdnAdapter(value: unknown): value is CdnAdapter`
Returns `true` when `value instanceof CdnAdapter`. Inside the `if`
branch TypeScript narrows `value` to `CdnAdapter`. Safe to call with
`null`, `undefined`, primitives, or arbitrary objects — all yield
`false`.
### `assertCdnAdapter(value: unknown): asserts value is CdnAdapter`
Throws `StorageCdnError(STORAGE_CDN.INVALID_OPTIONS)` when `value` is
not a `CdnAdapter`. After the call, TypeScript treats `value` as
`CdnAdapter`.
## Implementation Notes
- Both functions rely strictly on `instanceof`. Structural duck typing
would let any object with `purge`, `signUrl`, `edgeUrl` methods pass —
acceptable for many type guards, but the CdnAdapter contract includes
the full `AbstractStorage` surface and the capabilities matrix, so
instanceof is the right floor.
- The assertion uses the canonical `STORAGE_CDN.INVALID_OPTIONS` code
rather than inventing a new "not a CdnAdapter" code — the configuration
mistake is the caller passing the wrong type, which is exactly what
that code is for.
- The functions intentionally don't call `Object.getPrototypeOf` or
similar; the `instanceof` check is sufficient and avoids dragging in
prototype-chain edge cases (cross-realm objects, etc.) which provider
packages would have to defend against anyway.

View File

@@ -0,0 +1,91 @@
# sign-url
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the boundary validation for `CdnAdapter.signUrl` callers. Wraps the Zod
`SignUrlOptionsSchema` parse and converts schema failures into the canonical
`StorageCdnError` codes — `STORAGE_CDN.INVALID_TTL` when `ttlSeconds <= 0`
or non-integer, `STORAGE_CDN.INVALID_OPTIONS` for any other schema failure.
Provider adapters invoke `parseSignUrlOptions` at the top of their `signUrl`
implementations so every provider returns the same diagnostic surface on
invalid input.
Without this module, every provider would either replicate the Zod parse
boilerplate or invent its own error mapping — neither outcome is good for
operators trying to write provider-agnostic alerting rules.
## Features
- One exported function (`parseSignUrlOptions`) — keeps the public surface
trivial.
- Distinct error codes for `ttlSeconds` failures vs. everything else, so
monitoring rules can alert separately on "TTL out of range" vs. "caller
is misconfigured".
- Returns a clean `SignUrlOptions` value with optional fields strictly
omitted (not `undefined`) to satisfy `exactOptionalPropertyTypes`.
## Usage: Basic
```ts
import { parseSignUrlOptions } from "@suaveplan/storage-cdn";
const parsed = parseSignUrlOptions({ ttlSeconds: 60, method: "GET" });
```
## Usage: Advanced
```ts
import {
CdnAdapter,
parseSignUrlOptions,
STORAGE_CDN_CODES,
StorageCdnError,
} from "@suaveplan/storage-cdn";
class MyCdn extends CdnAdapter {
public override async signUrl(key: string, options: SignUrlOptions): Promise<string> {
const opts = parseSignUrlOptions(options); // throws on bad input
const url = await this.client.presign(key, opts.method ?? "GET", opts.ttlSeconds);
return url;
}
}
try {
await myCdn.signUrl("a/b.bin", { ttlSeconds: 0 });
} catch (err) {
if (err instanceof StorageCdnError && err.code === STORAGE_CDN_CODES.INVALID_TTL) {
// distinct from INVALID_OPTIONS — alert pipeline can fire a specific rule.
}
}
```
## API Reference
### `parseSignUrlOptions(options: SignUrlOptions): SignUrlOptions`
Validates `options` against `SignUrlOptionsSchema`. On success, returns a
fresh `SignUrlOptions` value with optional fields strictly omitted (not
set to `undefined`). On failure, throws:
- `StorageCdnError(STORAGE_CDN.INVALID_TTL)` when the `ttlSeconds` issue
is present.
- `StorageCdnError(STORAGE_CDN.INVALID_OPTIONS)` otherwise (e.g. method
outside whitelist, empty `responseContentType`).
## Implementation Notes
- The Zod parse runs once per call — the schema is module-level so there
is no per-call construction overhead.
- The `ttlSeconds` detection scans the issues array for any issue whose
path begins with `ttlSeconds`. This catches both "out of range" and
"not an integer" — both surface as `INVALID_TTL` because they are
the same caller mistake (passing a bad TTL).
- Optional-field reconstruction explicitly skips fields the caller
omitted; this preserves the `exactOptionalPropertyTypes` invariant
downstream (some providers' API clients hard-fail on `method: undefined`).
- The function is intentionally narrow — it does not sign anything. Real
signing is provider-specific and lives in the provider package.

View File

@@ -0,0 +1,88 @@
# storage-augment
## Purpose
Owns the TypeScript module augmentation that adds an optional
`cdn?: CdnCapabilities` slot to `@suaveplan/storage`'s `StorageCapabilities`
interface. Importing this module is a side-effect — there is no runtime
export — but it teaches every downstream consumer's TypeScript program
about the new optional field so `storage.capabilities.cdn` typechecks
everywhere.
Without this file, provider packages would each have to redeclare the
augmentation, and every consumer reading `capabilities.cdn` would either
fail to compile or rely on a structural cast.
## Features
- One module augmentation, declared once.
- Additive — base storage adapters that do not opt in continue to
typecheck unchanged.
- No runtime overhead — `declare module` is erased after type-checking.
- Side-effect import pulled from the package root barrel so consumers
who `import "@suaveplan/storage-cdn"` automatically receive the
augmentation.
## Usage: Basic
```ts
import "@suaveplan/storage-cdn"; // pulls in the augmentation
import type { StorageCapabilities } from "@suaveplan/storage/types";
const cap: StorageCapabilities = {
partialReads: false,
partialWrites: false,
nativeStreams: false,
atomicWrites: false,
contentAddressable: false,
};
// cap.cdn is now legal (optional).
```
## Usage: Advanced
```ts
import "@suaveplan/storage-cdn";
import type { StorageCapabilities } from "@suaveplan/storage/types";
import type { CdnCapabilities } from "@suaveplan/storage-cdn";
function describeStorage(cap: StorageCapabilities): string {
if (cap.cdn !== undefined) {
const cdn: CdnCapabilities = cap.cdn;
return `CDN: purge=${cdn.cdnPurge}, signed=${cdn.cdnSignedUrls}`;
}
return "no CDN features";
}
```
## API Reference
### Module augmentation
```ts
declare module "@suaveplan/storage/types" {
interface StorageCapabilities {
readonly cdn?: CdnCapabilities;
}
}
```
No runtime exports — the file's `export {}` is required only to make
TypeScript treat the file as a module rather than a script.
## Implementation Notes
- The augmentation makes `cdn` optional, NOT required, on the base type.
Subclasses of `CdnAdapter` tighten the type to require it via the
intersection `StorageCapabilities & { cdn: CdnCapabilities }` on
`CdnAdapter.capabilities`. Plain `AbstractStorage` subclasses (e.g.
`MemoryStorage`, `NodeFsStorage`, `HttpStorage`) leave it `undefined`.
- The file is imported for side-effects at the root barrel
(`src/index.ts`). Subpath imports of `./cdn-adapter` or `./guards`
alone may not trigger the augmentation, but every package that depends
on `@suaveplan/storage-cdn` declares the dep and imports through the
root barrel at least once during bootstrap, so the augmentation is
loaded before any read of `capabilities.cdn` happens at runtime.
- Pure TypeScript erasure — there is zero JavaScript output that
touches `StorageCapabilities`. Production bundles don't pay any
byte cost.

View File

@@ -0,0 +1,83 @@
# cdn-adapter.types
## Purpose
Declares `CdnAdapterOptions`, the base interface that provider-specific
adapter option bags extend. The interface fixes the two-URL split every
real CDN exhibits — a private `originBaseUrl` for writes and a public
`edgeBaseUrl` for reads — and a `credentials` slot each provider
narrows to its own concrete shape.
Without this base type, each provider would invent its own option-bag
shape from scratch, and code that wants to write provider-agnostic
helpers (e.g. validating that the two URLs reference the same container)
would have to reach for `any`.
## Features
- Two distinct URL fields so the origin / edge split is enforced at the
type level.
- `credentials: unknown` slot — provider packages narrow this to their
own concrete shape via interface inheritance.
- Both URLs are plain strings (not `URL` objects) so option bags
serialise cleanly to JSON for config files.
## Usage: Basic
```ts
import type { CdnAdapterOptions } from "@suaveplan/storage-cdn";
const base: CdnAdapterOptions = {
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acct/container/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
};
```
## Usage: Advanced
```ts
import type { CdnAdapterOptions } from "@suaveplan/storage-cdn";
interface RackspaceCredentials {
username: string;
apiKey: string;
}
interface RackspaceCdnOptions extends CdnAdapterOptions {
credentials: RackspaceCredentials;
region: "LON" | "IAD" | "DFW" | "ORD" | "SYD" | "HKG";
container: string;
tempUrlKey?: string;
}
```
## API Reference
### `interface CdnAdapterOptions`
- `originBaseUrl: string` — required; the (often private) write
endpoint. Must end with `/`.
- `edgeBaseUrl: string` — required; the public read URL the CDN serves
from. Must end with `/`.
- `credentials?: unknown` — provider-specific bundle; each provider
package narrows this.
## Implementation Notes
- The two URLs are deliberately separate string fields, not a single
`baseUrl`. Every real CDN exhibits this split (Rackspace `storage101`
vs. `r88.cf6.rackcdn`; BunnyCDN `storage.bunnycdn.com` vs.
`<zone>.b-cdn.net`). Conflating them silently produces broken signed
URLs (the signature is over the origin path but the caller hands the
edge URL to a browser).
- The trailing-slash invariant is documented but not enforced at this
type level — provider packages enforce it via their Zod option
schemas because Zod can validate string content but TypeScript types
cannot.
- `credentials: unknown` is the safest default. Tightening it to a
union would either be premature (we don't know every provider's
shape) or force every provider to pollute the union; leaving it
`unknown` makes the field future-proof.
- The interface intentionally has no optional fields. Provider
packages add their own optional knobs (region defaults, retry tuning,
etc.) on the extended interface.

View File

@@ -0,0 +1,93 @@
# cdn-capabilities.types
## Purpose
Declares `CdnCapabilities` — the boolean-flag matrix every concrete
`CdnAdapter` subclass populates so consumers can test feature
availability without trying-and-catching. Also exports
`DEFAULT_CDN_CAPABILITIES`, the all-`false` defaults that any subclass
can use as a starting point or that test fixtures use when they need a
"no features" stub.
The four flags map directly to the four observable CDN behaviours
worth distinguishing across providers: single-key purge, prefix purge,
signed URLs, and origin uploads. Anything more granular (specific
cache-control directives, log delivery, edge logic) is provider-specific
and lives in the provider package.
## Features
- Four canonical boolean flags — `cdnPurge`, `cdnPrefixPurge`,
`cdnSignedUrls`, `cdnOriginUploads`.
- `DEFAULT_CDN_CAPABILITIES` constant with every flag `false` so
subclasses can spread it and override only the flags they support.
- Frozen at compile time via `readonly` so consumers can rely on the
matrix not mutating during a session.
## Usage: Basic
```ts
import type { CdnCapabilities } from "@suaveplan/storage-cdn";
const matrix: CdnCapabilities = {
cdnPurge: true,
cdnPrefixPurge: false,
cdnSignedUrls: true,
cdnOriginUploads: true,
};
```
## Usage: Advanced
```ts
import {
type CdnCapabilities,
DEFAULT_CDN_CAPABILITIES,
} from "@suaveplan/storage-cdn";
class MyCdn extends CdnAdapter {
public override readonly capabilities = {
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
cdn: {
...DEFAULT_CDN_CAPABILITIES,
cdnPurge: true,
cdnSignedUrls: true,
} satisfies CdnCapabilities,
} as const;
}
```
## API Reference
### `interface CdnCapabilities`
All `readonly` boolean fields:
- `cdnPurge` — single-key cache invalidation.
- `cdnPrefixPurge` — prefix / wildcard purge in one API call.
- `cdnSignedUrls` — short-lived signed URLs (GET / PUT / HEAD).
- `cdnOriginUploads` — origin-side uploads via a separate write endpoint.
### `DEFAULT_CDN_CAPABILITIES: CdnCapabilities`
Every flag `false`. Use as a starting point when authoring a provider
adapter or as a no-op fixture in tests.
## Implementation Notes
- All flags are `readonly` because the capabilities matrix represents
what the underlying provider can do — it cannot change between calls
in a single session. If a provider's feature set changes (e.g. a
customer upgrades their plan), the consumer constructs a new adapter
instance.
- The four flags are deliberately conservative — every real provider
fits into them. Adding a fifth flag means defining what it means
across Rackspace, BunnyCDN, R2, Cloudflare, Fastly, CloudFront, etc.,
which is a major design change, not a minor extension.
- `DEFAULT_CDN_CAPABILITIES` is exported as `CdnCapabilities` (not
`Readonly<CdnCapabilities>`) so spreading it preserves the readonly
shape of the field types.

View File

@@ -0,0 +1,86 @@
# sign-url.types
## Purpose
Declares the option shape callers pass to `CdnAdapter.signUrl` —
`SignUrlOptions` — and the supporting `SignUrlMethod` union. Provider
adapters validate the option bag via `SignUrlOptionsSchema` and the
`parseSignUrlOptions` helper, then translate the parsed values into
their own provider-specific signing parameters.
The shape is intentionally small: a TTL, a method, and two
response-rewrite fields (the documented S3/R2/Azure pattern). Anything
more provider-specific belongs in the provider package's extended
option bag.
## Features
- `SignUrlMethod` whitelist — `"GET" | "PUT" | "HEAD"`. Other HTTP
methods are not meaningful for signed-URL semantics across major
providers.
- `ttlSeconds` is the only required field — provider defaults fill
everything else.
- Response-rewrite fields are optional — most callers don't set them
and providers default to the stored content type.
## Usage: Basic
```ts
import type { SignUrlOptions } from "@suaveplan/storage-cdn";
const options: SignUrlOptions = { ttlSeconds: 60 };
```
## Usage: Advanced
```ts
import type { SignUrlMethod, SignUrlOptions } from "@suaveplan/storage-cdn";
function buildReadOptions(ttl: number, method: SignUrlMethod = "GET"): SignUrlOptions {
return {
ttlSeconds: ttl,
method,
responseContentType: "application/octet-stream",
responseContentDisposition: 'attachment; filename="download.bin"',
};
}
const opts = buildReadOptions(300);
```
## API Reference
### `type SignUrlMethod = "GET" | "PUT" | "HEAD"`
Method whitelist for signed URLs. Providers that don't support PUT
(BunnyCDN Pull Zone Token Auth, Rackspace TempURL GET-only mode) raise
`STORAGE_CDN.SIGN_URL_NOT_SUPPORTED` or a provider-specific error at
the signing site.
### `interface SignUrlOptions`
- `ttlSeconds: number` — required; validity window in whole seconds
(> 0).
- `method?: SignUrlMethod` — defaults to `"GET"` at the provider site.
- `responseContentType?: string` — optional response-content-type
override.
- `responseContentDisposition?: string` — optional
response-content-disposition override.
## Implementation Notes
- The TTL is in seconds, not milliseconds — every real provider's signing
format uses seconds (S3 `X-Amz-Expires`, Swift TempURL `temp_url_expires`,
BunnyCDN `expires`). Forcing milliseconds would only inflict
per-provider unit conversion.
- The method whitelist is closed (`GET | PUT | HEAD`). Adding methods
requires defining their semantics across providers — out of scope for
v0 and a major change for v1.
- The response-rewrite fields are deliberately named after the AWS
S3 query parameters (`response-content-type`,
`response-content-disposition`) because that is the de-facto
cross-provider naming and the spec consumers expect.
- The interface intentionally has no provider-specific fields.
Provider packages with extra signing knobs (e.g. Rackspace
`temp-url-prefix` mode) expose those via their own helper
functions, not by extending this option bag.