Initial Commit
This commit is contained in:
93
packages/persistence/storage/storage-cdn/README.md
Normal file
93
packages/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.
|
||||
|
||||
102
packages/persistence/storage/storage-cdn/package.json
Normal file
102
packages/persistence/storage/storage-cdn/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
CdnAdapter,
|
||||
defaultPrefixPurgeNotSupported,
|
||||
defaultPurgeNotSupported,
|
||||
defaultSignUrlNotSupported,
|
||||
} from "./cdn-adapter.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
StorageCdnErrorCode,
|
||||
StorageCdnErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
STORAGE_CDN_CODES,
|
||||
StorageCdnError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
assertCdnAdapter,
|
||||
isCdnAdapter,
|
||||
} from "./guards.js";
|
||||
30
packages/persistence/storage/storage-cdn/src/index.ts
Normal file
30
packages/persistence/storage/storage-cdn/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { parseSignUrlOptions } from "./sign-url.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
12
packages/persistence/storage/storage-cdn/tsconfig.json
Normal file
12
packages/persistence/storage/storage-cdn/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/persistence/storage/storage-cdn/tsconfig.lib.json
Normal file
12
packages/persistence/storage/storage-cdn/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
36
packages/persistence/storage/storage-cdn/vite.config.ts
Normal file
36
packages/persistence/storage/storage-cdn/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/persistence/storage/storage-cdn/vitest.config.ts
Normal file
28
packages/persistence/storage/storage-cdn/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user