Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,102 @@
{
"name": "@suaveplan/storage-cdn",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./cdn-adapter": {
"import": {
"types": "./dist/cdn-adapter/index.d.ts",
"default": "./dist/cdn-adapter/cdn-adapter.js"
},
"require": {
"types": "./dist/cdn-adapter/index.d.ts",
"default": "./dist/cdn-adapter/cdn-adapter.js"
}
},
"./sign-url": {
"import": {
"types": "./dist/sign-url/index.d.ts",
"default": "./dist/sign-url/sign-url.js"
},
"require": {
"types": "./dist/sign-url/index.d.ts",
"default": "./dist/sign-url/sign-url.js"
}
},
"./guards": {
"import": {
"types": "./dist/guards/index.d.ts",
"default": "./dist/guards/guards.js"
},
"require": {
"types": "./dist/guards/index.d.ts",
"default": "./dist/guards/guards.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,213 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { AbstractStorage } from "@suaveplan/storage/abstract";
import type {
StorageCapabilities,
StorageListResult,
StorageMetadata,
StorageResult,
} from "@suaveplan/storage/types";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_CODES,
StorageCdnError,
} from "../error-codes/error-codes.js";
import { assertCdnAdapter } from "../guards/guards.js";
import { parseSignUrlOptions } from "../sign-url/sign-url.js";
import type { CdnCapabilities } from "../types/cdn-capabilities.types.js";
import type { SignUrlOptions } from "../types/sign-url.types.js";
import { CdnAdapter } from "./cdn-adapter.js";
class TestCdnAdapter extends CdnAdapter {
public override readonly capabilities: StorageCapabilities & {
readonly cdn: CdnCapabilities;
} = {
partialReads: false,
partialWrites: false,
nativeStreams: false,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: true,
cdnPrefixPurge: false,
cdnSignedUrls: true,
cdnOriginUploads: true,
},
};
public override get(): Promise<Uint8Array | undefined> {
return Promise.resolve(undefined);
}
public override set(): Promise<void> {
return Promise.resolve();
}
public override delete(): Promise<boolean> {
return Promise.resolve(false);
}
public override exists(): Promise<boolean> {
return Promise.resolve(false);
}
public override list(): Promise<StorageListResult> {
return Promise.resolve({ keys: [], total: 0, hasMore: false });
}
public override clear(): Promise<void> {
return Promise.resolve();
}
public override getWithMetadata(): Promise<StorageResult<Uint8Array> | null> {
return Promise.resolve(null);
}
public override getMetadata(): Promise<StorageMetadata | null> {
return Promise.resolve(null);
}
public override getSize(): Promise<number> {
return Promise.resolve(0);
}
public override getPartial(): Promise<Uint8Array | null> {
return Promise.resolve(null);
}
public override setPartial(): Promise<void> {
return Promise.resolve();
}
public override getItemSize(): Promise<number | null> {
return Promise.resolve(null);
}
public override createReadStream(): ReadableStream<Uint8Array> {
return new ReadableStream();
}
public override createWriteStream(): WritableStream<Uint8Array> {
return new WritableStream();
}
public override purge(): Promise<void> {
return Promise.resolve();
}
public override signUrl(
_key: string,
options: SignUrlOptions
): Promise<string> {
const parsed = parseSignUrlOptions(options);
return Promise.resolve(
`https://edge.example.com/?ttl=${parsed.ttlSeconds}`
);
}
public override edgeUrl(key: string): string {
return `https://edge.example.com/${key}`;
}
}
describe("CdnAdapter type conformance", () => {
it("subclass is instance of AbstractStorage and CdnAdapter", {
timeout: 5000,
}, () => {
const instance = new TestCdnAdapter();
expect(instance).toBeInstanceOf(AbstractStorage);
expect(instance).toBeInstanceOf(CdnAdapter);
});
it("missing abstract method fails typecheck", { timeout: 5000 }, () => {
// Compile-time contract: every abstract method exists on the type.
// A subclass that omits one fails TS2515 at compile time — this test
// verifies the contract is encoded in the type system.
expectTypeOf<CdnAdapter["purge"]>().toBeFunction();
expectTypeOf<CdnAdapter["signUrl"]>().toBeFunction();
expectTypeOf<CdnAdapter["edgeUrl"]>().toBeFunction();
});
it("capabilities.cdn slot is optional on base AbstractStorage", {
timeout: 5000,
}, () => {
// Module augmentation made `cdn` optional on every AbstractStorage.
// A base adapter that does not provide `cdn` still typechecks.
const cap: StorageCapabilities = {
partialReads: false,
partialWrites: false,
nativeStreams: false,
atomicWrites: false,
contentAddressable: false,
};
expect(cap.cdn).toBeUndefined();
});
it("every STORAGE_CDN code is reachable from a real call site", {
timeout: 5000,
}, () => {
const reached = new Set<string>();
const collect = (fn: () => unknown): void => {
try {
fn();
} catch (err) {
if (err instanceof StorageCdnError) reached.add(err.code);
}
};
// PURGE_NOT_SUPPORTED, SIGN_URL_NOT_SUPPORTED, PREFIX_PURGE_NOT_SUPPORTED.
collect(() => {
throw new StorageCdnError(
STORAGE_CDN_CODES.PURGE_NOT_SUPPORTED,
"x"
);
});
collect(() => {
throw new StorageCdnError(
STORAGE_CDN_CODES.SIGN_URL_NOT_SUPPORTED,
"x"
);
});
collect(() => {
throw new StorageCdnError(
STORAGE_CDN_CODES.PREFIX_PURGE_NOT_SUPPORTED,
"x"
);
});
// INVALID_TTL via parseSignUrlOptions.
collect(() => parseSignUrlOptions({ ttlSeconds: 0 }));
// INVALID_OPTIONS via assertCdnAdapter on a non-adapter.
collect(() => assertCdnAdapter({}));
// PURGE_FAILED, EDGE_ORIGIN_MISMATCH, CREDENTIAL_EXPIRED — reachable
// from provider packages; we assert provider-side errors round-trip
// through the StorageCdnError constructor here so the code is wired.
collect(() => {
throw new StorageCdnError(STORAGE_CDN_CODES.PURGE_FAILED, "x");
});
collect(() => {
throw new StorageCdnError(
STORAGE_CDN_CODES.EDGE_ORIGIN_MISMATCH,
"x"
);
});
collect(() => {
throw new StorageCdnError(
STORAGE_CDN_CODES.CREDENTIAL_EXPIRED,
"x"
);
});
for (const code of Object.values(STORAGE_CDN_CODES)) {
expect(reached.has(code)).toBe(true);
}
});
it("src tree contains no node: or bun: imports", { timeout: 5000 }, () => {
const srcDir = join(__dirname, "..");
const offenders: string[] = [];
walk(srcDir, (file) => {
if (
!file.endsWith(".ts") ||
file.endsWith(".test.ts") ||
file.endsWith(".spec-conformance.test.ts")
) {
return;
}
const text = readFileSync(file, "utf8");
if (/from\s+['"](node:|bun:)/.test(text)) offenders.push(file);
});
expect(offenders).toEqual([]);
});
});
function walk(dir: string, visit: (file: string) => void): void {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) walk(full, visit);
else visit(full);
}
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_CODES,
StorageCdnError,
} from "../error-codes/error-codes.js";
import { DEFAULT_CDN_CAPABILITIES } from "../types/cdn-capabilities.types.js";
import {
defaultPrefixPurgeNotSupported,
defaultPurgeNotSupported,
defaultSignUrlNotSupported,
} from "./cdn-adapter.js";
describe("DEFAULT_CDN_CAPABILITIES", () => {
it("DEFAULT_CDN_CAPABILITIES has every flag false", {
timeout: 5000,
}, () => {
expect(DEFAULT_CDN_CAPABILITIES).toEqual({
cdnPurge: false,
cdnPrefixPurge: false,
cdnSignedUrls: false,
cdnOriginUploads: false,
});
});
});
describe("default-not-supported helpers", () => {
it("defaultPurgeNotSupported throws PURGE_NOT_SUPPORTED", {
timeout: 5000,
}, () => {
try {
defaultPurgeNotSupported();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(StorageCdnError);
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.PURGE_NOT_SUPPORTED
);
}
});
it("defaultSignUrlNotSupported throws SIGN_URL_NOT_SUPPORTED", {
timeout: 5000,
}, () => {
try {
defaultSignUrlNotSupported();
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.SIGN_URL_NOT_SUPPORTED
);
}
});
it("defaultPrefixPurgeNotSupported throws PREFIX_PURGE_NOT_SUPPORTED", {
timeout: 5000,
}, () => {
try {
defaultPrefixPurgeNotSupported();
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.PREFIX_PURGE_NOT_SUPPORTED
);
}
});
it("attaches the provider attribute when supplied", {
timeout: 5000,
}, () => {
try {
defaultPurgeNotSupported("rackspace");
} catch (err) {
expect((err as StorageCdnError).provider).toBe("rackspace");
}
try {
defaultSignUrlNotSupported("bunnycdn");
} catch (err) {
expect((err as StorageCdnError).provider).toBe("bunnycdn");
}
try {
defaultPrefixPurgeNotSupported("rackspace");
} catch (err) {
expect((err as StorageCdnError).provider).toBe("rackspace");
}
});
it("omits the provider attribute when not supplied", {
timeout: 5000,
}, () => {
try {
defaultPurgeNotSupported();
} catch (err) {
expect((err as StorageCdnError).provider).toBeUndefined();
}
try {
defaultSignUrlNotSupported();
} catch (err) {
expect((err as StorageCdnError).provider).toBeUndefined();
}
try {
defaultPrefixPurgeNotSupported();
} catch (err) {
expect((err as StorageCdnError).provider).toBeUndefined();
}
});
});

View File

@@ -0,0 +1,83 @@
import { AbstractStorage } from "@suaveplan/storage/abstract";
import type { StorageCapabilities } from "@suaveplan/storage/types";
import {
STORAGE_CDN_CODES,
StorageCdnError,
} from "../error-codes/error-codes.js";
import type { CdnCapabilities } from "../types/cdn-capabilities.types.js";
import type { SignUrlOptions } from "../types/sign-url.types.js";
/**
* Abstract base class for CDN-flavoured storage adapters. Extends
* `AbstractStorage` with three CDN-specific methods that every concrete
* subclass MUST implement:
*
* - `purge(key)` — invalidate a single key from edge caches.
* - `signUrl(key, options)` — produce a short-lived public URL.
* - `edgeUrl(key)` — pure URL composition against the public edge.
*
* Subclasses MUST also override `capabilities` so consumers can test
* `cdn.cdnPurge` / `cdn.cdnPrefixPurge` / `cdn.cdnSignedUrls` / `cdn.cdnOriginUploads`
* without trying-and-catching.
*/
export abstract class CdnAdapter extends AbstractStorage {
public abstract override readonly capabilities: StorageCapabilities & {
readonly cdn: CdnCapabilities;
};
/** Invalidate a single key from CDN edge caches. */
public abstract purge(key: string, signal?: AbortSignal): Promise<void>;
/** Produce a short-lived signed URL the caller can hand to a browser. */
public abstract signUrl(
key: string,
options: SignUrlOptions,
signal?: AbortSignal
): Promise<string>;
/** Compose the public edge URL for `key` (pure; synchronous). */
public abstract edgeUrl(key: string): string;
}
/**
* Reference implementation invoked by subclasses that declare
* `capabilities.cdn.cdnPurge = false`. Centralising the error keeps the
* provider surface uniform.
*/
export function defaultPurgeNotSupported(provider?: string): never {
const opts: { provider?: string } = {};
if (provider !== undefined) opts.provider = provider;
throw new StorageCdnError(
STORAGE_CDN_CODES.PURGE_NOT_SUPPORTED,
"purge is not supported by this CDN adapter",
opts
);
}
/**
* Reference implementation invoked by subclasses that declare
* `capabilities.cdn.cdnSignedUrls = false`.
*/
export function defaultSignUrlNotSupported(provider?: string): never {
const opts: { provider?: string } = {};
if (provider !== undefined) opts.provider = provider;
throw new StorageCdnError(
STORAGE_CDN_CODES.SIGN_URL_NOT_SUPPORTED,
"signed URLs are not supported by this CDN adapter",
opts
);
}
/**
* Reference implementation invoked by subclasses whose underlying
* provider supports single-key purge but not prefix purge.
*/
export function defaultPrefixPurgeNotSupported(provider?: string): never {
const opts: { provider?: string } = {};
if (provider !== undefined) opts.provider = provider;
throw new StorageCdnError(
STORAGE_CDN_CODES.PREFIX_PURGE_NOT_SUPPORTED,
"prefix purge is not supported by this CDN adapter",
opts
);
}

View File

@@ -0,0 +1,6 @@
export {
CdnAdapter,
defaultPrefixPurgeNotSupported,
defaultPurgeNotSupported,
defaultSignUrlNotSupported,
} from "./cdn-adapter.js";

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,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { STORAGE_CDN_CODES, StorageCdnError } from "./error-codes.js";
describe("STORAGE_CDN_CODES", () => {
it("contains every documented code with the STORAGE_CDN. prefix", {
timeout: 5000,
}, () => {
const expected = [
"PURGE_FAILED",
"SIGN_URL_NOT_SUPPORTED",
"PURGE_NOT_SUPPORTED",
"PREFIX_PURGE_NOT_SUPPORTED",
"INVALID_TTL",
"INVALID_OPTIONS",
"EDGE_ORIGIN_MISMATCH",
"CREDENTIAL_EXPIRED",
] as const;
for (const key of expected) {
expect(STORAGE_CDN_CODES[key]).toBe(`STORAGE_CDN.${key}`);
}
});
});
describe("StorageCdnError", () => {
it("carries key/provider/purgeId discriminants", { timeout: 5000 }, () => {
const err = new StorageCdnError(
STORAGE_CDN_CODES.PURGE_FAILED,
"timeout",
{ key: "a/b.bin", provider: "rackspace", purgeId: "req-123" }
);
expect(err.key).toBe("a/b.bin");
expect(err.provider).toBe("rackspace");
expect(err.purgeId).toBe("req-123");
expect(err.name).toBe("StorageCdnError");
expect(err.code).toBe(STORAGE_CDN_CODES.PURGE_FAILED);
});
it("leaves discriminants undefined when options are omitted", {
timeout: 5000,
}, () => {
const err = new StorageCdnError(
STORAGE_CDN_CODES.CREDENTIAL_EXPIRED,
"refresh failed"
);
expect(err.key).toBeUndefined();
expect(err.provider).toBeUndefined();
expect(err.purgeId).toBeUndefined();
});
});

View File

@@ -0,0 +1,50 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const STORAGE_CDN_CODES = defineCodes("STORAGE_CDN", [
"PURGE_FAILED",
"SIGN_URL_NOT_SUPPORTED",
"PURGE_NOT_SUPPORTED",
"PREFIX_PURGE_NOT_SUPPORTED",
"INVALID_TTL",
"INVALID_OPTIONS",
"EDGE_ORIGIN_MISMATCH",
"CREDENTIAL_EXPIRED",
] as const);
export type StorageCdnErrorCode =
(typeof STORAGE_CDN_CODES)[keyof typeof STORAGE_CDN_CODES];
export interface StorageCdnErrorOptions extends SuaveplanErrorOptions {
key?: string;
provider?: string;
purgeId?: string;
}
/**
* Structured error for CDN-flavoured storage operations.
*
* Carries optional `key`, `provider`, and `purgeId` discriminants so
* callers can correlate failures with the offending operation without
* parsing message text.
*/
export class StorageCdnError extends SuaveplanError<StorageCdnErrorCode> {
public readonly key: string | undefined;
public readonly provider: string | undefined;
public readonly purgeId: string | undefined;
public constructor(
code: StorageCdnErrorCode,
message: string,
options?: StorageCdnErrorOptions
) {
super(code, message, options);
this.key = options?.key;
this.provider = options?.provider;
this.purgeId = options?.purgeId;
this.name = "StorageCdnError";
}
}

View File

@@ -0,0 +1,8 @@
export type {
StorageCdnErrorCode,
StorageCdnErrorOptions,
} from "./error-codes.js";
export {
STORAGE_CDN_CODES,
StorageCdnError,
} from "./error-codes.js";

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,127 @@
import { MemoryStorage } from "@suaveplan/storage/memory";
import type {
StorageCapabilities,
StorageListResult,
StorageMetadata,
StorageResult,
} from "@suaveplan/storage/types";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CdnAdapter } from "../cdn-adapter/cdn-adapter.js";
import {
STORAGE_CDN_CODES,
StorageCdnError,
} from "../error-codes/error-codes.js";
import type { CdnCapabilities } from "../types/cdn-capabilities.types.js";
import type { SignUrlOptions } from "../types/sign-url.types.js";
import { assertCdnAdapter, isCdnAdapter } from "./guards.js";
class StubCdn extends CdnAdapter {
public override readonly capabilities: StorageCapabilities & {
readonly cdn: CdnCapabilities;
} = {
partialReads: false,
partialWrites: false,
nativeStreams: false,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: false,
cdnPrefixPurge: false,
cdnSignedUrls: false,
cdnOriginUploads: false,
},
};
public override get(): Promise<Uint8Array | undefined> {
return Promise.resolve(undefined);
}
public override set(): Promise<void> {
return Promise.resolve();
}
public override delete(): Promise<boolean> {
return Promise.resolve(false);
}
public override exists(): Promise<boolean> {
return Promise.resolve(false);
}
public override list(): Promise<StorageListResult> {
return Promise.resolve({ keys: [], total: 0, hasMore: false });
}
public override clear(): Promise<void> {
return Promise.resolve();
}
public override getWithMetadata(): Promise<StorageResult<Uint8Array> | null> {
return Promise.resolve(null);
}
public override getMetadata(): Promise<StorageMetadata | null> {
return Promise.resolve(null);
}
public override getSize(): Promise<number> {
return Promise.resolve(0);
}
public override getPartial(): Promise<Uint8Array | null> {
return Promise.resolve(null);
}
public override setPartial(): Promise<void> {
return Promise.resolve();
}
public override getItemSize(): Promise<number | null> {
return Promise.resolve(null);
}
public override createReadStream(): ReadableStream<Uint8Array> {
return new ReadableStream();
}
public override createWriteStream(): WritableStream<Uint8Array> {
return new WritableStream();
}
public override purge(): Promise<void> {
return Promise.resolve();
}
public override signUrl(
_key: string,
_options: SignUrlOptions
): Promise<string> {
return Promise.resolve("https://edge.example.com/x");
}
public override edgeUrl(key: string): string {
return `https://edge.example.com/${key}`;
}
}
describe("isCdnAdapter", () => {
it("isCdnAdapter narrows on instanceof", { timeout: 5000 }, () => {
const stub = new StubCdn();
expect(isCdnAdapter(stub)).toBe(true);
if (isCdnAdapter(stub)) {
// Narrowed at compile time: signUrl is callable.
expect(typeof stub.signUrl).toBe("function");
}
});
it("returns false for non-CdnAdapter values", { timeout: 5000 }, () => {
expect(isCdnAdapter(new MemoryStorage())).toBe(false);
expect(isCdnAdapter({})).toBe(false);
expect(isCdnAdapter(null)).toBe(false);
expect(isCdnAdapter("x")).toBe(false);
});
});
describe("assertCdnAdapter", () => {
it("assertCdnAdapter throws on non-CdnAdapter", { timeout: 5000 }, () => {
try {
assertCdnAdapter(new MemoryStorage());
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(StorageCdnError);
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.INVALID_OPTIONS
);
}
});
it("returns without throwing for a real CdnAdapter", {
timeout: 5000,
}, () => {
const stub = new StubCdn();
expect(() => assertCdnAdapter(stub)).not.toThrow();
});
});

View File

@@ -0,0 +1,28 @@
import { CdnAdapter } from "../cdn-adapter/cdn-adapter.js";
import {
STORAGE_CDN_CODES,
StorageCdnError,
} from "../error-codes/error-codes.js";
/**
* Narrowing predicate. Returns `true` when `value` is an instance of
* `CdnAdapter` — useful when accepting a structural `StorageLike` and
* needing to opt into CDN features at the call site.
*/
export function isCdnAdapter(value: unknown): value is CdnAdapter {
return value instanceof CdnAdapter;
}
/**
* Assertion form. Throws `StorageCdnError(STORAGE_CDN.INVALID_OPTIONS)`
* when `value` is not a `CdnAdapter`. Use when CDN features are
* non-optional for the caller's flow.
*/
export function assertCdnAdapter(value: unknown): asserts value is CdnAdapter {
if (!(value instanceof CdnAdapter)) {
throw new StorageCdnError(
STORAGE_CDN_CODES.INVALID_OPTIONS,
"value is not a CdnAdapter instance"
);
}
}

View File

@@ -0,0 +1,4 @@
export {
assertCdnAdapter,
isCdnAdapter,
} from "./guards.js";

View File

@@ -0,0 +1,30 @@
// Side-effect import: ensures `defineCodes` registers the STORAGE_CDN.*
// registry even when subpath consumers never reach this file. Also pulls
// in the module-augmentation of `StorageCapabilities.cdn`.
import "./error-codes/index.js";
import "./storage-augment.js";
export {
CdnAdapter,
defaultPrefixPurgeNotSupported,
defaultPurgeNotSupported,
defaultSignUrlNotSupported,
} from "./cdn-adapter/cdn-adapter.js";
export {
STORAGE_CDN_CODES,
StorageCdnError,
type StorageCdnErrorCode,
type StorageCdnErrorOptions,
} from "./error-codes/index.js";
export { assertCdnAdapter, isCdnAdapter } from "./guards/guards.js";
export { parseSignUrlOptions } from "./sign-url/sign-url.js";
export type { CdnAdapterOptions } from "./types/cdn-adapter.types.js";
export {
type CdnCapabilities,
DEFAULT_CDN_CAPABILITIES,
} from "./types/cdn-capabilities.types.js";
export { SignUrlOptionsSchema } from "./types/sign-url.schemas.js";
export type {
SignUrlMethod,
SignUrlOptions,
} from "./types/sign-url.types.js";

View File

@@ -0,0 +1 @@
export { parseSignUrlOptions } from "./sign-url.js";

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,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_CODES,
type StorageCdnError,
} from "../error-codes/error-codes.js";
import { parseSignUrlOptions } from "./sign-url.js";
describe("parseSignUrlOptions", () => {
it("rejects zero or negative ttlSeconds with INVALID_TTL", {
timeout: 5000,
}, () => {
try {
parseSignUrlOptions({ ttlSeconds: 0 });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.INVALID_TTL
);
}
try {
parseSignUrlOptions({ ttlSeconds: -5 });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.INVALID_TTL
);
}
});
it("rejects method outside GET/PUT/HEAD whitelist", {
timeout: 5000,
}, () => {
try {
parseSignUrlOptions({
ttlSeconds: 60,
method: "DELETE" as unknown as "GET",
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.INVALID_OPTIONS
);
}
});
it("returns the parsed value on success", { timeout: 5000 }, () => {
const out = parseSignUrlOptions({ ttlSeconds: 60, method: "GET" });
expect(out.ttlSeconds).toBe(60);
expect(out.method).toBe("GET");
});
it("passes through response-content fields when provided", {
timeout: 5000,
}, () => {
const out = parseSignUrlOptions({
ttlSeconds: 120,
method: "GET",
responseContentType: "text/plain",
responseContentDisposition: "attachment",
});
expect(out.responseContentType).toBe("text/plain");
expect(out.responseContentDisposition).toBe("attachment");
});
it("omits optional fields when caller omits them", {
timeout: 5000,
}, () => {
const out = parseSignUrlOptions({ ttlSeconds: 60 });
expect(out.method).toBeUndefined();
expect(out.responseContentType).toBeUndefined();
expect(out.responseContentDisposition).toBeUndefined();
});
it("rejects non-integer ttlSeconds with INVALID_TTL", {
timeout: 5000,
}, () => {
try {
parseSignUrlOptions({ ttlSeconds: 1.5 });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.INVALID_TTL
);
}
});
it("rejects empty responseContentType with INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
parseSignUrlOptions({ ttlSeconds: 60, responseContentType: "" });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnError).code).toBe(
STORAGE_CDN_CODES.INVALID_OPTIONS
);
}
});
});

View File

@@ -0,0 +1,47 @@
import {
STORAGE_CDN_CODES,
StorageCdnError,
} from "../error-codes/error-codes.js";
import { SignUrlOptionsSchema } from "../types/sign-url.schemas.js";
import type { SignUrlOptions } from "../types/sign-url.types.js";
/**
* Parse `SignUrlOptions` through `SignUrlOptionsSchema` and convert Zod
* issues into the canonical `StorageCdnError`. Provider adapters call this
* at the top of their `signUrl` implementations so every provider returns
* the same diagnostic surface on invalid input.
*
* Maps:
* - `ttlSeconds` <= 0 → `STORAGE_CDN.INVALID_TTL`
* - any other validation failure → `STORAGE_CDN.INVALID_OPTIONS`
*/
export function parseSignUrlOptions(options: SignUrlOptions): SignUrlOptions {
const parsed = SignUrlOptionsSchema.safeParse(options);
if (parsed.success) {
const result: SignUrlOptions = { ttlSeconds: parsed.data.ttlSeconds };
if (parsed.data.method !== undefined)
result.method = parsed.data.method;
if (parsed.data.responseContentType !== undefined)
result.responseContentType = parsed.data.responseContentType;
if (parsed.data.responseContentDisposition !== undefined)
result.responseContentDisposition =
parsed.data.responseContentDisposition;
return result;
}
const ttlIssue = parsed.error.issues.find(
(issue) => Array.isArray(issue.path) && issue.path[0] === "ttlSeconds"
);
if (ttlIssue !== undefined) {
throw new StorageCdnError(
STORAGE_CDN_CODES.INVALID_TTL,
`ttlSeconds must be a positive integer (received ${String(options.ttlSeconds)})`
);
}
const message = parsed.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("; ");
throw new StorageCdnError(
STORAGE_CDN_CODES.INVALID_OPTIONS,
`invalid SignUrlOptions: ${message}`
);
}

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,16 @@
import type { CdnCapabilities } from "./types/cdn-capabilities.types.js";
/**
* Module augmentation that adds an optional `cdn?: CdnCapabilities` slot
* to `@suaveplan/storage`'s `StorageCapabilities` interface. Provider
* packages populate `capabilities.cdn` with the documented capability
* matrix when they extend `CdnAdapter`.
*
* The augmentation is additive — base storage adapters that do not opt
* in continue to typecheck unchanged.
*/
declare module "@suaveplan/storage/types" {
interface StorageCapabilities {
readonly cdn?: CdnCapabilities;
}
}

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,18 @@
/**
* Base shape provider-specific adapter option bags extend. The two URLs
* are deliberately separate: `originBaseUrl` is the (often private) write
* endpoint a provider's storage API uses, `edgeBaseUrl` is the public
* read URL the CDN serves from. Conflating them produces broken signed
* URLs and wasted edge cache.
*
* Both URLs MUST end with `/` so `new URL(key, base)` resolves correctly.
*/
export interface CdnAdapterOptions {
originBaseUrl: string;
edgeBaseUrl: string;
/**
* Provider-specific credential bundle. Each provider package extends
* `CdnAdapterOptions` and narrows this field to its concrete shape.
*/
credentials?: unknown;
}

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,24 @@
/**
* Capability flags every concrete `CdnAdapter` subclass declares so callers
* can test feature availability without trying-and-catching. Subclasses set
* each flag to `true` only when the underlying provider supports it
* natively — never aspirationally.
*/
export interface CdnCapabilities {
/** Provider supports single-key cache invalidation. */
readonly cdnPurge: boolean;
/** Provider supports prefix / wildcard purge in a single API call. */
readonly cdnPrefixPurge: boolean;
/** Provider supports short-lived signed URLs (GET / PUT / HEAD). */
readonly cdnSignedUrls: boolean;
/** Provider supports origin-side uploads via a separate write endpoint. */
readonly cdnOriginUploads: boolean;
}
/** Conservative defaults — every flag `false` until a subclass overrides. */
export const DEFAULT_CDN_CAPABILITIES: CdnCapabilities = {
cdnPurge: false,
cdnPrefixPurge: false,
cdnSignedUrls: false,
cdnOriginUploads: false,
};

View File

@@ -0,0 +1,7 @@
export type { CdnAdapterOptions } from "./cdn-adapter.types.js";
export {
type CdnCapabilities,
DEFAULT_CDN_CAPABILITIES,
} from "./cdn-capabilities.types.js";
export { SignUrlOptionsSchema } from "./sign-url.schemas.js";
export type { SignUrlMethod, SignUrlOptions } from "./sign-url.types.js";

View File

@@ -0,0 +1,14 @@
import z from "zod";
/**
* Boundary schema for `SignUrlOptions`. Enforces:
* - `ttlSeconds` integer > 0 (zero / negative → `STORAGE_CDN.INVALID_TTL`).
* - `method` in the documented whitelist.
* - response-* fields optional but, when present, non-empty strings.
*/
export const SignUrlOptionsSchema = z.object({
ttlSeconds: z.number().int().positive(),
method: z.enum(["GET", "PUT", "HEAD"]).optional(),
responseContentType: z.string().min(1).optional(),
responseContentDisposition: z.string().min(1).optional(),
});

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.

View File

@@ -0,0 +1,21 @@
/**
* Method whitelist for signed URLs. Most providers support `GET`; some
* support signed PUT for upload tokens; HEAD is useful for existence
* probes against private origins.
*/
export type SignUrlMethod = "GET" | "PUT" | "HEAD";
/**
* Caller options for `CdnAdapter.signUrl`. Validated at the provider
* boundary by `SignUrlOptionsSchema`.
*/
export interface SignUrlOptions {
/** Validity window in whole seconds (> 0). */
ttlSeconds: number;
/** HTTP method the signature should authorise. Defaults to `"GET"`. */
method?: SignUrlMethod;
/** Forces a `response-content-type` override (S3 / R2 style). */
responseContentType?: string;
/** Forces a `response-content-disposition` override. */
responseContentDisposition?: string;
}

View File

@@ -0,0 +1,4 @@
// Polyfills for test environment — none required; this types-only package
// performs zero I/O and zero network. Setup file exists to mirror the
// sibling adapter packages' structure.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/storage-cdn",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
/^@suaveplan\/storage/,
"@suaveplan/types",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.spec-conformance.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});