first commit
This commit is contained in:
93
docs/persistence/storage/storage-cdn/README.md
Normal file
93
docs/persistence/storage/storage-cdn/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
89
docs/persistence/storage/storage-cdn/src/guards/guards.md
Normal file
89
docs/persistence/storage/storage-cdn/src/guards/guards.md
Normal 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.
|
||||
@@ -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.
|
||||
88
docs/persistence/storage/storage-cdn/src/storage-augment.md
Normal file
88
docs/persistence/storage/storage-cdn/src/storage-augment.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user