first commit

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

View File

@@ -0,0 +1,112 @@
# @suaveplan/storage-cdn-bunnycdn - BunnyCDN Storage Zone + Pull Zone provider for @suaveplan/storage-cdn. AccessKey-header CRUD against regional Storage Zones (de/ny/la/sg/syd/uk), single + prefix purge via the Bunny.net management API, HMAC-SHA256 Token Authentication signed URLs, Range reads, native streaming, full AbortSignal threading, Retry-After honoured, OTel surface with provider+storage_region attributes.
BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMetadata`/`getPartial`/`createReadStream`/`createWriteStream` against the regional Storage Zone with `AccessKey` header auth, plus `purge`/`purgePrefix`/`signUrl`/`edgeUrl` against the public Pull Zone. HMAC-SHA256 Token Authentication (URL-safe base64, no padding) for signed URLs. Single + wildcard prefix purge via the Bunny.net management API. Transient-status retry honouring `Retry-After`. Full OTel surface with `provider="bunnycdn"` and `storage_region` attributes.
<!-- begin:auto-docs -->
## Documentation tree
- [bunny-cdn-adapter](./src/bunny-cdn-adapter/bunny-cdn-adapter.md) — `BunnyCdnAdapter` binds `@suaveplan/storage`'s `AbstractStorage` contract to BunnyCDN Storage Zones for storage operati…
- [bunny-cdn-adapter.base](./src/bunny-cdn-adapter/bunny-cdn-adapter.base.md) — `BunnyCdnAdapterBase` carries the shared internal machinery every `BunnyCdnAdapter` instance needs: the resolved config…
- [bunny-cdn-adapter.helpers](./src/bunny-cdn-adapter/bunny-cdn-adapter.helpers.md) — Internal helpers used by `BunnyCdnAdapter` and its sibling streams module. Splits boundary concerns (option parsing, he…
- [bunny-cdn-adapter](./src/bunny-cdn-adapter/bunny-cdn-adapter.md) — `BunnyCdnAdapter` binds `@suaveplan/storage`'s `AbstractStorage` contract to BunnyCDN Storage Zones for storage operati…
- [bunny-cdn-adapter.streams](./src/bunny-cdn-adapter/bunny-cdn-adapter.streams.md) — Builds the `ReadableStream` and `WritableStream` instances `BunnyCdnAdapter` returns from `createReadStream` / `createW…
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN_BUNNYCDN` error namespace used by every module in this package. Codes are produced…
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN_BUNNYCDN` error namespace used by every module in this package. Codes are produced…
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before any fetch is issued against BunnyCDN Storage, and provides…
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before any fetch is issued against BunnyCDN Storage, and provides…
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-bunnycdn`: counters (`reads_total`, `writes_total`, `…
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-bunnycdn`: counters (`reads_total`, `writes_total`, `…
- [pull-zone-client](./src/pull-zone-client/pull-zone-client.md) — Owns the Bunny.net management-API purge operation. `purgeUrl(ctx, edgeUrl, signal?)` issues `POST…
- [pull-zone-client](./src/pull-zone-client/pull-zone-client.md) — Owns the Bunny.net management-API purge operation. `purgeUrl(ctx, edgeUrl, signal?)` issues `POST…
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"bunny…
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"bunny…
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests against BunnyCDN Storage Zones and the Bunny.net management API. Distinguishe…
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests against BunnyCDN Storage Zones and the Bunny.net management API. Distinguishe…
- [storage-client](./src/storage-client/storage-client.md) — Owns every Storage Zone HTTP operation `BunnyCdnAdapter` needs: PUT, GET, HEAD, DELETE, Range read, and streaming GET.…
- [storage-client](./src/storage-client/storage-client.md) — Owns every Storage Zone HTTP operation `BunnyCdnAdapter` needs: PUT, GET, HEAD, DELETE, Range read, and streaming GET.…
- [token-auth](./src/token-auth/token-auth.md) — Computes BunnyCDN Pull Zone Token Authentication query strings — `?token=<urlSafeBase64(sha256(key + path + expires))>&…
- [token-auth](./src/token-auth/token-auth.md) — Computes BunnyCDN Pull Zone Token Authentication query strings — `?token=<urlSafeBase64(sha256(key + path + expires))>&…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/storage-cdn-bunnycdn
```
## Quick start
```ts
import { BunnyCdnAdapter } from "@suaveplan/storage-cdn-bunnycdn";
const storage = new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "de",
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
purgeApiKey: process.env["BUNNY_PURGE_KEY"]!,
});
await storage.set("photos/a.jpg", bytes);
await storage.purge("photos/a.jpg");
await storage.purgePrefix("photos/");
const signed = await storage.signUrl("photos/a.jpg", { ttlSeconds: 300 });
```
## Factory registration
```ts
import { registerBunnyCdnProvider } from "@suaveplan/storage-cdn-bunnycdn";
import { createStorage } from "@suaveplan/storage/factory";
registerBunnyCdnProvider();
const storage = createStorage({ type: "bunnycdn", config });
```
## Modules
- [error-codes](./src/error-codes.md) — `STORAGE_CDN_BUNNYCDN_CODES` registry + `StorageCdnBunnycdnError`.
- [constants](./src/constants/constants.md) — region whitelist, default hosts, retry defaults, `storageHostFor` helper.
- [storage-client](./src/storage-client/storage-client.md) — PUT/GET/DELETE/HEAD/Range/streaming against the regional Storage Zone.
- [pull-zone-client](./src/pull-zone-client/pull-zone-client.md) — `purgeUrl` for single + wildcard purge via `api.bunny.net/purge`.
- [token-auth](./src/token-auth/token-auth.md) — HMAC-SHA256 Token Authentication query builder.
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — strict key validation + per-segment encoder.
- [retry-helper](./src/retry-helper/retry-helper.md) — transient classifier + `Retry-After` honour.
- [otel](./src/otel/otel.md) — meters, histograms, counters, tracer.
- [bunny-cdn-adapter](./src/bunny-cdn-adapter/bunny-cdn-adapter.md) — main `BunnyCdnAdapter` class.
- [registry](./src/registry/registry.md) — module-augmentation slot for `createStorage({ type: "bunnycdn", ... })`.
## Capabilities matrix
```ts
{
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: true,
cdnPrefixPurge: true,
cdnSignedUrls: true,
cdnOriginUploads: true,
},
}
```
## Features
- **Universal purity** — no `node:*` or DOM-only imports.
- **Full Storage Zone surface** — origin PUT/GET/DELETE/HEAD with Range, native streaming, AbortSignal.
- **CDN integration** — single + prefix purge, signed (Token Authentication) URLs, edge URL composition.
- **Region-aware host selection** — `de` uses the unprefixed host (`storage.bunnycdn.com`); other regions prepend the code (`ny.storage.bunnycdn.com`, …).
- **Two-key model** — per-zone `storageAccessKey` for CRUD vs. account-level `purgeApiKey` for invalidation; rotated independently.
- **Retry on transient outcomes** — `408`/`425`/`429`/`5xx` retried with `Retry-After` honoured.
- **OTel built-in** — `storage_cdn_bunnycdn.*` instruments with `provider="bunnycdn"` and `storage_region` attributes for cross-provider dashboards.
- **100 / 100 / 100 / 100 Istanbul coverage**.
## License
MIT — see `LICENSE` at the repo root.

View File

@@ -0,0 +1,61 @@
# bunny-cdn-adapter.base
## Purpose
`BunnyCdnAdapterBase` carries the shared internal machinery every `BunnyCdnAdapter` instance needs: the resolved configuration, the per-operation `ctxFor` bundle builder, and the OTel-wrapping `wrap` helper. The public `BunnyCdnAdapter` extends this base and adds the concrete public methods — splitting the class this way keeps each file under the 300-line budget while still exposing a single `instanceof BunnyCdnAdapter` surface to consumers.
## Features
- `config: ResolvedConfig` — parsed at construction; `protected` so the subclass reads it directly.
- `ctxFor(key, signal)` — builds the `StorageClientCtx` the storage-client module consumes: resolves headers, threads retry options and signal.
- `wrap(op, signal, fn)` — runs `fn` inside a try/catch that records duration on success and emits the correct outcome counter on failure.
- Capability matrix declared once at the module level (`CAPS`) so the subclass inherits the canonical shape (including `cdnPrefixPurge: true`, unique to this provider).
## Usage: Basic
```ts
import { BunnyCdnAdapterBase } from "@suaveplan/storage-cdn-bunnycdn";
class MyAdapter extends BunnyCdnAdapterBase {
// implement AbstractStorage + CdnAdapter methods using
// this.config, this.ctxFor(...), this.wrap(...)
}
```
## Usage: Advanced
```ts
import { BunnyCdnAdapterBase } from "@suaveplan/storage-cdn-bunnycdn";
import { storageGet } from "@suaveplan/storage-cdn-bunnycdn";
class CustomLoggingAdapter extends BunnyCdnAdapterBase {
public override async get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined> {
return this.wrap("get", signal, async () => {
const ctx = await this.ctxFor(key, signal);
const start = Date.now();
const result = await storageGet(ctx, key);
customMetrics.observe("bunny_get_ms", Date.now() - start);
return result;
});
}
// ... rest omitted
}
```
## API Reference
### `abstract class BunnyCdnAdapterBase extends CdnAdapter`
- `protected readonly config: ResolvedConfig` — parsed options + resolved fetch / now.
- `public override readonly capabilities` — documented `StorageCapabilities & { cdn: CdnCapabilities }` matrix.
- `constructor(options: BunnyCdnAdapterOptions)` — validates options through the schema.
- `protected ctxFor(key, signal): Promise<StorageClientCtx>` — returns the bundle storage-client modules consume.
- `protected wrap<T>(op, signal, fn): Promise<T>` — wraps `fn` in OTel + abort guards.
## Implementation Notes
- The base is `abstract` so it can't be instantiated directly — only via `BunnyCdnAdapter`. The abstract qualifier is structural (the abstract `purge`/`signUrl`/`edgeUrl` from `CdnAdapter` are unimplemented here).
- `config` is `protected readonly` rather than `#private` so the subclass file can reference it as `this.config`. Hiding it with `#private` would require duplicating every method into the base file.
- `wrap` records the outcome in both success and failure paths — the success counter increments inside the operation methods (for ops that carry `bytes`/`direction` attributes), and `wrap` itself records duration unconditionally.
- The `CAPS` constant is module-level and shared by every instance; it's frozen at compile time via the `readonly` modifiers on the interface.
- The split is a deliberate organisational choice; consumers see only `BunnyCdnAdapter`. The base is exported so subclasses outside the package can build alternative implementations sharing the same internal contract.

View File

@@ -0,0 +1,91 @@
# bunny-cdn-adapter.helpers
## Purpose
Internal helpers used by `BunnyCdnAdapter` and its sibling streams module. Splits boundary concerns (option parsing, header resolution, abort guard, response-shape interpretation, outcome classification) out of the public class so the latter stays under the 300-line budget and the helpers can be unit-tested in isolation.
## Features
- `resolveConfig(options)` — parses options through `BunnyCdnAdapterOptionsSchema`; throws `STORAGE_CDN_BUNNYCDN.INVALID_REGION` for region typos and `INVALID_OPTIONS` for every other validation failure.
- `resolveHeaders(resolver, key)` — static record or async per-op callback; defensive copy on return.
- `throwIfAborted(signal)` — pre-fetch abort guard producing `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR` (with `cause = signal.reason`).
- `outcomeFromError(err)` — translates any thrown error to the `outcome` attribute the OTel wrapper records.
- `parseMetadata(response)` — extracts `StorageMetadata` from a HEAD response.
- `consumeHead(response, zone, region, success)` — processes a HEAD response: 404 → `null`, 401/403 → `STORAGE_AUTH_FAILED`, other non-ok → `UPSTREAM_ERROR`, 2xx → `success(response)`.
## Usage: Basic
```ts
import { resolveConfig, parseMetadata } from "@suaveplan/storage-cdn-bunnycdn";
const config = resolveConfig({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
});
```
## Usage: Advanced
```ts
import {
consumeHead,
outcomeFromError,
resolveHeaders,
throwIfAborted,
} from "@suaveplan/storage-cdn-bunnycdn";
async function customExists(ctx, key, signal) {
throwIfAborted(signal);
const headers = await resolveHeaders(ctx.options.headers, key);
const response = await ctx.fetch(buildUrl(ctx, key), { method: "HEAD", headers, signal });
try {
return consumeHead(response, ctx.zone, ctx.region, () => true) ?? false;
} catch (err) {
recordOp({ op: "exists", outcome: outcomeFromError(err), durationMs: 0, storageRegion: ctx.region });
throw err;
}
}
```
## API Reference
### `interface ResolvedConfig`
`{ options, storageZone, accessKey, pullZoneHostname, region, tokenAuthKey, purgeApiKey, fetch, now }`.
### `resolveConfig(options): ResolvedConfig`
Routes region-specific schema failures to `INVALID_REGION`; all others to `INVALID_OPTIONS`.
### `resolveHeaders(resolver, key): Promise<Record<string, string>>`
- `undefined` → `{}`.
- Static record → shallow copy.
- Function → awaited, then shallow-copied.
### `throwIfAborted(signal?): void`
Throws when `signal?.aborted === true`.
### `outcomeFromError(err): "auth_failed" | "not_found" | "upstream_error"`
`StorageCdnBunnycdnError` → maps by `code`; anything else → `"upstream_error"`.
### `parseMetadata(response): StorageMetadata`
Extracts `Content-Length` (defaulting `size` to 0 when absent or unparsed) and `Last-Modified` (defaulting `modifiedAt` to `Date.now()` when missing or unparseable). Returns `createdAt === modifiedAt`.
### `consumeHead<T>(response, zone, region, success): T | null`
- `404` → `null`.
- `401`/`403` → `STORAGE_CDN_BUNNYCDN.STORAGE_AUTH_FAILED`.
- other non-ok → `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
- `2xx` → `success(response)`.
## Implementation Notes
- The error-status table maps `401` and `403` both to `STORAGE_AUTH_FAILED` because BunnyCDN returns the same access-denied semantics for both — distinguishing them at the adapter would force every caller to handle two paths with identical recovery.
- `outcomeFromError` returns a narrower enum than the OTel attribute set because the adapter's `wrap` augments the outcome with `"ok"` on the success path. Adding a new error code requires extending the switch.
- `resolveHeaders` defensively copies its inputs so a caller mutating their static record between calls doesn't leak into in-flight requests.
- `resolveConfig` reads the first issue to detect a region-specific failure; only the dedicated `INVALID_REGION` code makes it to operators on that single failure mode.

View File

@@ -0,0 +1,79 @@
# bunny-cdn-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BunnyCdnAdapter` binds `@suaveplan/storage`'s `AbstractStorage` contract to BunnyCDN Storage Zones for storage operations and the Bunny.net management API + Pull Zone Token Authentication for CDN purge and signed URLs. The class wires the storage-client, pull-zone-client, token-auth signer, retry helper, and OTel instruments into one cohesive surface so application code calls `storage.set(...)`, `storage.purge(...)`, `storage.purgePrefix(...)`, `storage.signUrl(...)` against a single instance.
Unlike Rackspace, BunnyCDN supports **prefix purge natively** via the `?url=...*` trailing-wildcard form, so this adapter exposes a `purgePrefix(prefix, signal?)` method beyond the `CdnAdapter` base contract.
## Features
- Full `AbstractStorage` surface — `get`, `set`, `delete`, `exists`, `getMetadata`, `getWithMetadata`, `getItemSize`, `getPartial`, `createReadStream`, `createWriteStream`, `setPartial`. `list`, `clear`, `getSize` throw `UPSTREAM_ERROR` (use the Bunny Storage listing API directly).
- Full `CdnAdapter` surface — `purge`, `signUrl`, `edgeUrl`.
- Adapter-specific `purgePrefix(prefix, signal?)` for wildcard cache invalidation.
- Capabilities matrix declares `partialReads: true`, `nativeStreams: true`, and `cdn.cdnPrefixPurge: true` (the only adapter in the family that does so far).
- AbortSignal threaded through every public method.
- Retry on transient outcomes honouring `Retry-After`.
- OTel duration / IO bytes histograms + per-op outcome counters with `provider="bunnycdn"` and `storage_region` attributes.
## Usage: Basic
```ts
import { BunnyCdnAdapter } from "@suaveplan/storage-cdn-bunnycdn";
const storage = new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "de",
});
await storage.set("a/b.png", bytes);
const edge = storage.edgeUrl("a/b.png"); // → https://mycdn.b-cdn.net/a/b.png
```
## Usage: Advanced
```ts
import { BunnyCdnAdapter, createBunnyCdnAdapter } from "@suaveplan/storage-cdn-bunnycdn";
const storage = createBunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "ny",
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
purgeApiKey: process.env["BUNNY_PURGE_KEY"]!,
retry: { maxAttempts: 5, baseDelayMs: 200 },
headers: { "content-type": "image/png" },
});
await storage.set("photos/a.png", bytes);
await storage.purgePrefix("photos/");
const signed = await storage.signUrl("photos/a.png", { ttlSeconds: 300 });
```
## API Reference
### `class BunnyCdnAdapter extends CdnAdapter`
- `constructor(options: BunnyCdnAdapterOptions)` — validates options at the boundary; throws `STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS` or `INVALID_REGION` on schema failure.
- Read methods: `get`, `exists`, `getMetadata`, `getWithMetadata`, `getItemSize`, `getPartial`, `createReadStream`.
- Write methods: `set`, `delete`, `setPartial`, `createWriteStream`.
- CDN methods: `purge`, `purgePrefix`, `signUrl`, `edgeUrl`.
- Unsupported (throw `UPSTREAM_ERROR`): `list`, `clear`, `getSize`.
### `createBunnyCdnAdapter(options): BunnyCdnAdapter`
Convenience factory mirroring `createHttpStorage`.
## Implementation Notes
- `edgeUrl` is synchronous because it composes from `pullZoneHostname` (configured at construction) — no auth round-trip is needed. Pull Zone hostnames are configured ahead of time in the Bunny.net dashboard.
- `purge` and `purgePrefix` both go through the Bunny.net management API at `api.bunny.net/purge`. Prefix purge appends `*` to the encoded edge URL — Bunny's documented wildcard form.
- `signUrl` parses options via `parseSignUrlOptions` (from `@suaveplan/storage-cdn`) before calling the Token Authentication signer; invalid TTLs surface as `STORAGE_CDN.INVALID_TTL` and route through the workspace-wide error namespace.
- `list`, `clear`, `getSize` are intentionally not implemented because Bunny Storage's listing API returns a non-standard JSON shape that would require provider-specific knowledge to map onto `StorageListResult`. Operators that need them should use the Storage Zone listing endpoint directly.
- `setPartial` delegates to `AbstractStorage.defaultSetPartial` (read-modify-write); Bunny Storage does not support random-access writes.

View File

@@ -0,0 +1,59 @@
# bunny-cdn-adapter.streams
## Purpose
Builds the `ReadableStream` and `WritableStream` instances `BunnyCdnAdapter` returns from `createReadStream` / `createWriteStream`. Splits the stream plumbing out of the main class body so each piece can be unit-tested without instantiating the full adapter.
`buildReadStream` lazily resolves the `StorageClientCtx` promise on the first `pull` (the adapter passes a promise that resolves once headers are computed) then pumps the `Response.body` chunks 1:1 to the consumer. `buildWriteStream` buffers chunks until `close` and dispatches via the caller-supplied `commit` callback — the adapter passes `(body, signal) => this.set(safe, body, undefined, signal)` so the write goes through the OTel-wrapped `set` path.
## Features
- Native streaming: `buildReadStream` returns `Response.body` chunks without buffering.
- Error propagation: mid-stream errors surface via `controller.error`; aborts propagate through the underlying fetch's signal.
- `buildWriteStream` accepts an injectable `commit` callback so the adapter (or test fixtures) controls how the buffered body is shipped.
- `abort()` handler drops buffered chunks promptly so memory does not linger after cancellation.
## Usage: Basic
```ts
import { buildReadStream, buildWriteStream } from "@suaveplan/storage-cdn-bunnycdn";
const stream = buildReadStream(ctxPromise, "k", signal);
for await (const chunk of stream) processChunk(chunk);
```
## Usage: Advanced
```ts
import { buildWriteStream } from "@suaveplan/storage-cdn-bunnycdn";
const ws = buildWriteStream({
key: "k",
signal,
commit: async (body, s) => storage.set("k", body, undefined, s),
});
const writer = ws.getWriter();
await writer.write(chunk);
await writer.close(); // triggers commit
```
## API Reference
### `buildReadStream(ctxPromise, key, signal?): ReadableStream<Uint8Array>`
Starts the GET on first read; pumps the response body until `done`. `Response.body === null` closes the stream cleanly without enqueueing.
### `interface WriteStreamArgs`
`{ key, signal, commit }` — `commit(body, signal)` is invoked once at `close` time with the concatenated buffer.
### `buildWriteStream(args): WritableStream<Uint8Array>`
Buffers chunks, concatenates on `close`, dispatches via `commit`. `abort()` clears the buffer without committing.
## Implementation Notes
- Buffered-then-flush is the right tradeoff for BunnyCDN Storage PUT: their API expects a known `Content-Length`, which means chunked transfers without orchestration aren't supported. Provider-specific streaming uploads (the documented multi-part upload pattern) belong in a separate helper.
- `abort()` clears `chunks` rather than reusing the array — clearing length forces garbage collection of every enqueued buffer once the writer releases its reference.
- `releaseLock()` on the response body reader runs in `finally` so a mid-stream error doesn't leak the lock.
- `ctxPromise` is awaited inside `start`; if header resolution fails (e.g. a caller-supplied async `headers` callback rejects), the rejection surfaces via `controller.error`, which is what consumers expect for stream initialisation failures.

View File

@@ -0,0 +1,69 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the canonical `STORAGE_CDN_BUNNYCDN` error namespace used by every module in this package. Codes are produced via `@suaveplan/error.defineCodes` so the resulting string values are guaranteed-unique within the workspace. The companion `StorageCdnBunnycdnError` class extends `SuaveplanError` and adds four diagnostic discriminants — `key`, `zone`, `region`, `httpStatus` — so consumers can correlate failures with the offending BunnyCDN operation without parsing message text.
## Features
- 8 stable error codes covering storage auth, purge auth, zone-not-found, missing keys, region/options validation, and upstream failures.
- `StorageCdnBunnycdnError` subclass with `key`/`zone`/`region`/`httpStatus` carry fields.
- Cause-chain preservation via `SuaveplanError`'s `cause` option.
- Type-safe `StorageCdnBunnycdnErrorCode` discriminant inferred from the `defineCodes` tuple.
## Usage: Basic
```ts
import { STORAGE_CDN_BUNNYCDN_CODES, StorageCdnBunnycdnError } from "@suaveplan/storage-cdn-bunnycdn";
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED,
"Storage Zone refused the AccessKey",
{ zone: "myz", region: "de", httpStatus: 401 },
);
```
## Usage: Advanced
```ts
import { STORAGE_CDN_BUNNYCDN_CODES, StorageCdnBunnycdnError } from "@suaveplan/storage-cdn-bunnycdn";
try {
await storage.purge("a/b.bin");
} catch (err) {
if (err instanceof StorageCdnBunnycdnError && err.code === STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED) {
operatorLog.warn({ zone: err.zone, region: err.region }, "Purge AccessKey rotated?");
await rotateAndRetry();
}
throw err;
}
```
## API Reference
### `STORAGE_CDN_BUNNYCDN_CODES`
Frozen record. Codes: `STORAGE_AUTH_FAILED`, `PURGE_AUTH_FAILED`, `ZONE_NOT_FOUND`, `TOKEN_AUTH_KEY_MISSING`, `PURGE_API_KEY_MISSING`, `INVALID_REGION`, `INVALID_OPTIONS`, `UPSTREAM_ERROR`.
### `StorageCdnBunnycdnErrorCode`
Union of the namespaced code string values.
### `class StorageCdnBunnycdnError extends SuaveplanError<StorageCdnBunnycdnErrorCode>`
- `constructor(code, message, options?)` — `options` accepts `cause`, `details`, `key`, `zone`, `region`, `httpStatus`.
- Carry-fields exposed as `readonly` properties.
- `name = "StorageCdnBunnycdnError"`.
## Implementation Notes
- `STORAGE_AUTH_FAILED` covers `401`/`403` from the Storage Zone — bad `AccessKey` or insufficient permissions on the zone.
- `PURGE_AUTH_FAILED` is a separate code from `STORAGE_AUTH_FAILED` because the purge API uses a different account-level key; operators rotate them independently.
- `ZONE_NOT_FOUND` is reserved for callers that wrap this package and produce the error externally — the adapter itself maps `404` on a known key to `undefined`/`null` per the `AbstractStorage` contract.
- `TOKEN_AUTH_KEY_MISSING` and `PURGE_API_KEY_MISSING` are caller-configuration errors raised by `signUrl` and `purge` respectively when their dependent secrets weren't supplied.
- `INVALID_REGION` is distinct from `INVALID_OPTIONS` so dashboards can alert on region-typo configuration mistakes without sweeping every other validation failure into the same rule.
- The `key` / `zone` / `region` carry-fields are all optional so unit-test fixtures can construct minimal errors without inventing fake provider identifiers.

View File

@@ -0,0 +1,62 @@
# key-sanitizer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates and normalises caller-supplied storage keys before any fetch is issued against BunnyCDN Storage, and provides the per-segment URL encoder used to build absolute object URLs. Keys must be POSIX-form relative paths — no `..`, no leading `/`, no Windows drive prefixes, no control characters, no values longer than `MAX_KEY_LENGTH` (2048 chars). Rejections throw `StorageCdnBunnycdnError` with code `STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS`.
Mirrors the Rackspace sibling's approach — one error code for every rejection because Bunny operators don't need to distinguish "key too long" from "key has a control char" at runtime.
## Features
- Strict validation: empty, non-string, control char, leading `/`, drive-letter prefix, `..` traversal, overlong (>2048).
- Normalisation: Windows `\` → POSIX `/` (idempotent on already-POSIX input).
- Per-segment URL encoder (`encodeKey`) applies `encodeURIComponent` to each `/`-separated segment, preserving slashes.
- All rejections share one canonical error code for uniform operator alerting.
## Usage: Basic
```ts
import { encodeKey, sanitizeKey } from "@suaveplan/storage-cdn-bunnycdn";
const safe = sanitizeKey("photos/family/picnic 2025.jpg");
const encoded = encodeKey(safe); // → "photos/family/picnic%202025.jpg"
```
## Usage: Advanced
```ts
import { encodeKey, sanitizeKey, StorageCdnBunnycdnError } from "@suaveplan/storage-cdn-bunnycdn";
function composePullZoneUrl(hostname: string, key: string): string {
const safe = sanitizeKey(key);
return `https://${hostname}/${encodeKey(safe)}`;
}
try {
composePullZoneUrl("mycdn.b-cdn.net", "../etc/passwd");
} catch (err) {
if (err instanceof StorageCdnBunnycdnError) operatorLog.warn({ err }, "Caller bug — bad key");
}
```
## API Reference
### `sanitizeKey(key: string): string`
Throws `StorageCdnBunnycdnError(STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS)` for any rejection reason. Returns the input with Windows `\` replaced by `/`.
### `encodeKey(key: string): string`
Splits on `/`, applies `encodeURIComponent` per segment, re-joins.
## Implementation Notes
- Combining key-length and key-content rejections into one code is a deliberate UX choice — Bunny operators report on a single rule, and the `details` field on the error preserves the message text for diagnostic logs.
- The traversal check runs after `\` → `/` normalisation so `a\..\b` is rejected (post-normalisation it has a `..` segment).
- Control-character detection is a single O(n) pass scanning every code unit; it costs negligible time relative to the subsequent `fetch`.
- `encodeKey` is separate from `sanitizeKey` because URL encoding is a transport concern — callers that compose paths for non-fetch destinations (Token Authentication signing paths, log messages) want the sanitised but unencoded form.
- The `MAX_KEY_LENGTH = 2048` cap matches `@suaveplan/storage-http` and `@suaveplan/storage-cdn-rackspace`; BunnyCDN's documented maximum is well below that, so the limit is conservatively wide.

View File

@@ -0,0 +1,81 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-bunnycdn`: counters (`reads_total`, `writes_total`, `deletes_total`, `purges_total`, `signed_urls_total`, `errors_total`, `range_fallback_total`), histograms (`op_duration_ms`, `io_bytes`), and a tracer scoped to `@suaveplan/storage-cdn-bunnycdn`. Every measurement carries `provider="bunnycdn"` and the configured `storage_region` so dashboards can break BunnyCDN traffic down by region and aggregate across providers without losing detail.
## Features
- Lazy instrument construction so consumers can install a custom `MeterProvider` before the first operation.
- `setBunnyMeterProvider(provider)` override hook with auto-rebuild on next access.
- `recordOp({ op, outcome, durationMs, storageRegion, bytes?, direction? })` emits the full observation set.
- `recordRangeFallback(storageRegion)` — surfaces origins that ignore Range.
- `resetInstruments()` for test isolation.
- Dedicated counters for CDN-specific operations (`purges_total`, `signed_urls_total`).
## Usage: Basic
```ts
import { recordOp } from "@suaveplan/storage-cdn-bunnycdn";
recordOp({ op: "purge", outcome: "ok", durationMs: 42, storageRegion: "de" });
```
## Usage: Advanced
```ts
import {
resetInstruments,
setBunnyMeterProvider,
recordOp,
} from "@suaveplan/storage-cdn-bunnycdn";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [periodicReader] });
setBunnyMeterProvider(provider);
resetInstruments();
recordOp({ op: "signUrl", outcome: "ok", durationMs: 1, storageRegion: "de" });
```
## API Reference
### `setBunnyMeterProvider(provider?): void`
Override or restore the meter provider; invalidates the cached instrument set.
### `getInstruments(force?): BunnyInstruments`
Returns the cached bundle. `force = true` rebuilds.
### `recordOp(input): void`
`input.outcome` decides which counter increments:
- `"ok"` + read-ish op → `reads_total`.
- `"ok"` + `set`/`setPartial` → `writes_total`.
- `"ok"` + `delete`/`clear` → `deletes_total`.
- `"ok"` + `purge`/`purgePrefix` → `purges_total`.
- `"ok"` + `signUrl` → `signed_urls_total`.
- anything else → `errors_total`.
Always records `op_duration_ms{op, outcome, provider, storage_region}`. When `bytes` and `direction` are both provided, also records `io_bytes{op, direction, provider, storage_region}`.
### `recordRangeFallback(storageRegion): void`
Increments `range_fallback_total{outcome="ok", provider="bunnycdn", storage_region}`.
### `resetInstruments(): void`
Drops the cached bundle without changing the meter provider.
## Implementation Notes
- The cache is critical for hot-path performance; `setBunnyMeterProvider` is the one path that intentionally invalidates it.
- `provider="bunnycdn"` is hard-coded — every measurement from this package belongs to BunnyCDN by definition. Cross-provider dashboards aggregate on the `provider` attribute, so dropping it would defeat family-wide observability.
- `storage_region` is sourced from the resolved configuration, not the operation argument, because operations don't take a region — the adapter is bound to one region per instance.
- The tracer is acquired separately from the meter; no caching is required because `trace.getTracer` is idempotent.
- `range_fallback_total` carries `outcome="ok"` only — fallback succeeds (just less efficiently); upstream failures during fallback surface via `errors_total`.

View File

@@ -0,0 +1,74 @@
# pull-zone-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the Bunny.net management-API purge operation. `purgeUrl(ctx, edgeUrl, signal?)` issues `POST https://api.bunny.net/purge?url=<encodedEdgeUrl>` with the account-level `AccessKey` header. The caller composes the absolute edge URL (e.g. `https://<pullZoneHostname>/<key>` or the same URL with a trailing `*` for prefix purge) — keeping URL composition out of this module means the same helper handles single-key and wildcard purges without branching.
The function `purgeApiKey` from the bundle is **required**; missing it throws `STORAGE_CDN_BUNNYCDN.PURGE_API_KEY_MISSING` before any fetch happens.
## Features
- Single function with a tight contract — no class state.
- `purgeApiKey` enforced at the top; missing or empty key → `PURGE_API_KEY_MISSING` (no fetch issued).
- 401/403 → `PURGE_AUTH_FAILED`, other non-ok → `UPSTREAM_ERROR`.
- Errors carry `zone`, `region`, and `httpStatus` for diagnostics.
- AbortSignal threaded into the fetch init.
## Usage: Basic
```ts
import { purgeUrl } from "@suaveplan/storage-cdn-bunnycdn";
await purgeUrl(
{ fetch, purgeApiKey: "key-1234", pullZoneHostname: "mycdn.b-cdn.net", zone: "myz", region: "de" },
"https://mycdn.b-cdn.net/a/b.bin",
);
```
## Usage: Advanced
```ts
import { purgeUrl } from "@suaveplan/storage-cdn-bunnycdn";
async function purgePrefix(ctx, prefix: string) {
// BunnyCDN's purge endpoint treats `*` as a wildcard suffix.
await purgeUrl(ctx, `https://${ctx.pullZoneHostname}/${encodeURI(prefix)}*`);
}
async function purgeSafelyWithKeyRotation(ctx, edgeUrl: string) {
try {
await purgeUrl(ctx, edgeUrl);
} catch (err) {
if (err instanceof StorageCdnBunnycdnError && err.code === STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED) {
const refreshed = await rotateAccessKey();
await purgeUrl({ ...ctx, purgeApiKey: refreshed }, edgeUrl);
return;
}
throw err;
}
}
```
## API Reference
### `interface PullZoneCtx`
`{ fetch, purgeApiKey, pullZoneHostname, zone, region }`. `purgeApiKey` is `string | undefined`; absent value triggers `PURGE_API_KEY_MISSING`.
### `purgeUrl(ctx, edgeUrl, signal?): Promise<void>`
- `2xx` → resolves.
- `401`/`403` → `STORAGE_CDN_BUNNYCDN.PURGE_AUTH_FAILED`.
- other non-ok → `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
- Missing `purgeApiKey` → `STORAGE_CDN_BUNNYCDN.PURGE_API_KEY_MISSING` (no fetch issued).
## Implementation Notes
- Purge URL composition is the caller's responsibility because the same endpoint handles both single keys and prefix purges (caller appends `*` for prefix mode). Pushing composition into the helper would force every caller through an enum or a flag.
- The `AccessKey` header is the account-level key from the Bunny.net dashboard, NOT the per-zone storage key. They're rotated independently and have different scopes; conflating them is the typical "purge stopped working after I rotated the storage key" bug.
- 404 on purge is treated as success at the *caller* level (we don't see it here because the BunnyCDN API returns `200 OK` even when the object was already evicted) — operators relying on a 404 signal should switch to the management API's status endpoint.
- The `pullZoneHostname` field is unused inside this function but is part of the bundle so callers can build edge URLs without a second context object.

View File

@@ -0,0 +1,78 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"bunnycdn"` slot, and exposes `registerBunnyCdnProvider()` to bind `BunnyCdnAdapter` as the constructor for that slot. Consumers who prefer factory-style instantiation (`createStorage({ type: "bunnycdn", config })`) call `registerBunnyCdnProvider()` once at boot and never reference the class directly.
Registration is idempotent — duplicate calls are no-ops, so wiring this into multiple bootstrappers (test fixtures + production startup) is safe.
## Features
- TypeScript module augmentation — `createStorage<"bunnycdn">` now type-checks with `BunnyCdnAdapterOptions`.
- Idempotent `registerBunnyCdnProvider()` — safe to call from multiple bootstrap paths.
- Zero runtime dependencies beyond `@suaveplan/storage/factory` and the local `BunnyCdnAdapter` class.
## Usage: Basic
```ts
import { registerBunnyCdnProvider } from "@suaveplan/storage-cdn-bunnycdn";
import { createStorage } from "@suaveplan/storage/factory";
registerBunnyCdnProvider();
const storage = createStorage({
type: "bunnycdn",
config: {
storageZone: "myz",
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
pullZoneHostname: "mycdn.b-cdn.net",
},
});
```
## Usage: Advanced
```ts
import { registerBunnyCdnProvider } from "@suaveplan/storage-cdn-bunnycdn";
import { createStorage } from "@suaveplan/storage/factory";
registerBunnyCdnProvider();
function tenantStorage(tenant: { zone: string; region: "de" | "ny" | "uk" }) {
return createStorage({
type: "bunnycdn",
config: {
...baseConfig,
storageZone: tenant.zone,
storageRegion: tenant.region,
},
});
}
```
## API Reference
### `registerBunnyCdnProvider(): void`
Registers `BunnyCdnAdapter` against `StorageFactory` under the `"bunnycdn"` slot when not already registered. Calling it twice is a no-op.
### Module augmentation
```ts
declare module "@suaveplan/storage/factory" {
interface StorageProviderRegistry { bunnycdn: BunnyCdnAdapter }
interface StorageProviderConfig { bunnycdn: BunnyCdnAdapterOptions }
}
```
This means `createStorage<"bunnycdn">(...)` returns `BunnyCdnAdapter` and the `config` parameter is typed as `BunnyCdnAdapterOptions`.
## Implementation Notes
- The cast `BunnyCdnAdapter as unknown as new (config?: unknown) => BunnyCdnAdapter` is required because `StorageFactory`'s `ProviderCtor` is intentionally loose (`config?: unknown`) — adapter constructors take typed options that don't satisfy the registry's structural shape without the cast.
- `hasStorageProvider("bunnycdn")` is the idempotency gate. After `unregisterStorageProvider("bunnycdn")` or `clearStorageProviders()`, a subsequent `registerBunnyCdnProvider()` call cleanly re-registers.
- The augmentation is pure type-level — there is no runtime cost beyond the one-time `Map.set` in the factory.
- This module imports `BunnyCdnAdapter` directly rather than via the package root barrel to avoid a circular import (the root barrel re-exports `registerBunnyCdnProvider`).

View File

@@ -0,0 +1,71 @@
# retry-helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Coordinates retry logic for HTTP requests against BunnyCDN Storage Zones and the Bunny.net management API. Distinguishes transient outcomes (`408`/`425`/`429`/`5xx`) from fatal ones, honours `Retry-After` when the server provides it, falls back to exponential backoff with a configurable cap, and surfaces exhausted retries as `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR` with `zone`/`region` context.
Structurally similar to the sibling `storage-cdn-rackspace` retry helper but lives in this package so error codes carry BunnyCDN-specific diagnostics.
## Features
- `isTransientStatus(status)` — `408`, `425`, `429`, or any `5xx`.
- `parseRetryAfter(value, now)` — delta-seconds and HTTP-date forms; capped at `RETRY_AFTER_MAX_DELAY_MS` (60s).
- `retryWithBackoff(fn, options?)` — runs `fn` up to `maxAttempts`, sleeps between transient outcomes, supports cancellation.
- Injectable `TimersLike` so tests pin behaviour under a `FakeClock`.
- Exhausted retries surface as `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
## Usage: Basic
```ts
import { retryWithBackoff } from "@suaveplan/storage-cdn-bunnycdn";
const response = await retryWithBackoff(() => fetch("https://storage.bunnycdn.com/myz/k"));
```
## Usage: Advanced
```ts
import { retryWithBackoff } from "@suaveplan/storage-cdn-bunnycdn";
const response = await retryWithBackoff(
() => fetch(url, { signal, headers: { AccessKey: process.env["BUNNY_KEY"]! } }),
{
retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10_000, honourRetryAfter: true },
timers: fakeTimers,
signal,
},
);
```
## API Reference
### `retryWithBackoff(fn, options?): Promise<Response>`
- `fn: () => Promise<Response>` — attempts the request.
- `options.retry?` — `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `honourRetryAfter`.
- `options.timers?` — defaults to `globalThis.setTimeout`/`clearTimeout`.
- `options.signal?` — cancels the wait between attempts; pre-aborted signal short-circuits with `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
Returns the last response after exhaustion or throws on synthesised exhaustion / abort.
### `isTransientStatus(status: number): boolean`
True for `408`, `425`, `429`, or any value in `[500, 599]`.
### `parseRetryAfter(value: string | null, now: number): number | undefined`
- Numeric value → delta-seconds × 1000 (capped at `RETRY_AFTER_MAX_DELAY_MS`).
- HTTP-date → `parseDate − now` (clamped to 0 if in the past, capped on the high side).
- Missing / empty / unparseable → `undefined`.
## Implementation Notes
- The Retry-After ceiling is **distinct from** `maxDelayMs`; the latter governs exponential backoff, the former specifically defends against the "stall an hour" abuse pattern.
- `sleep` listens to the abort event with `{ once: true }` and tears down both the abort listener and the underlying timer on either resolution path — no listener leaks.
- The helper is `fetch`-shaped (returns a `Response`) rather than generic — HTTP semantics drive every retry decision.
- Synthesised exhaustion (no response, no error) is only reachable when `maxAttempts === 0`; the schema validates `> 0` at the boundary so the path is defensive.
- Mid-wait abort throws `UPSTREAM_ERROR` (with `cause = signal.reason`) rather than a separate `ABORTED` code because the BunnyCDN error registry deliberately keeps abort under the upstream umbrella — operators monitoring "request canceled" already filter on `cause`-shape, not a separate code.

View File

@@ -0,0 +1,79 @@
# storage-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns every Storage Zone HTTP operation `BunnyCdnAdapter` needs: PUT, GET, HEAD, DELETE, Range read, and streaming GET. Each function takes a `StorageClientCtx` bundle (fetch + AccessKey + zone + region + retry options + signal + headers) and composes the regional Storage Zone URL via `buildStorageUrl`. The adapter constructs the bundle once per operation and never threads the fields by hand.
The split between this module and the public `BunnyCdnAdapter` keeps the HTTP semantics testable in isolation and gives provider-internal callers a clean entry point if they need to drive Storage Zone IO outside the adapter's `AbstractStorage` shape.
## Features
- `storageGet` — full-body GET; 404 → `undefined`; 401/403 → `STORAGE_AUTH_FAILED`; transient → retried.
- `storageHead` — HEAD with retry; returns the raw response.
- `storagePut` — PUT with `AccessKey`, `Content-Type`, `Content-Length`; non-ok → `errorForStatus`.
- `storageDelete` — DELETE; 200 → `true`, 404 → `false`, other → throw.
- `storagePartial` — Range read; delegates 206/200/416 disambiguation to `@suaveplan/storage-http`'s `consumeRangeResponse`; increments range-fallback counter on 200.
- `storageStream` — single GET without retry; returns the raw `Response` for the caller to pump.
- `buildStorageUrl` — composes `https://<region-host>/<zone>/<encodedKey>` using `storageHostFor`.
- AbortSignal threaded into every fetch via `withSignal`.
## Usage: Basic
```ts
import { storageGet } from "@suaveplan/storage-cdn-bunnycdn";
const bytes = await storageGet(
{ fetch, accessKey: "ak", zone: "myz", region: "de", retry: {}, signal: undefined, headers: {} },
"a/b.bin",
);
```
## Usage: Advanced
```ts
import {
buildStorageUrl,
storagePartial,
storagePut,
type StorageClientCtx,
} from "@suaveplan/storage-cdn-bunnycdn";
async function copyRange(src: StorageClientCtx, dst: StorageClientCtx, key: string, off: number, len: number) {
const bytes = await storagePartial(src, key, off, len);
if (bytes === null) return;
await storagePut(dst, key, bytes, "application/octet-stream");
return buildStorageUrl(dst, key);
}
```
## API Reference
### `interface StorageClientCtx`
`{ fetch, accessKey, zone, region, retry, signal, headers }`.
### `buildStorageUrl(ctx, key): string`
`https://<storageHostFor(region)>/<encodeURIComponent(zone)>/<encodeKey(key)>`.
### `storageGet`, `storageHead`, `storagePut`, `storageDelete`, `storagePartial`, `storageStream`
See the corresponding TS signatures in `storage-client.ts`. Status → code mapping:
| status | code |
|---|---|
| 401, 403 | `STORAGE_AUTH_FAILED` |
| 404 (GET/DELETE) | `undefined` / `false` (no throw) |
| other non-ok | `UPSTREAM_ERROR` after retry exhaustion |
## Implementation Notes
- The `AccessKey` header is added by `baseHeaders` *after* the caller's custom headers, so a caller's `headers` callback cannot accidentally override it with a different value.
- Range reads bypass the auth-check for non-401/403 statuses because `consumeRangeResponse` understands `206`/`200`/`416` natively — duplicating the logic would mask the dedicated range fallback metric.
- `storageStream` skips `retryWithBackoff` because streaming responses can't be retried mid-stream without buffering, and the retry classifier is response-shape rather than stream-shape.
- All errors carry `zone`, `region`, and `httpStatus` so monitoring dashboards can break failures down by tenant.
- The function-level split rather than a class is intentional — every function is stateless and the per-operation bundle is the only piece worth caching.

View File

@@ -0,0 +1,69 @@
# token-auth
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Computes BunnyCDN Pull Zone Token Authentication query strings — `?token=<urlSafeBase64(sha256(key + path + expires))>&expires=<unix>`, optionally with `&token_path=<prefix>` for path-scoped tokens. The signing scheme is BunnyCDN's documented Pull Zone protection: concatenate the per-Pull-Zone signing key with the path and expiry, SHA-256 hash, and URL-safe-base64 encode (no padding).
The adapter calls this from `signUrl`; consumers never invoke it directly unless they need to sign a URL outside the adapter's standard `<pullZoneHostname>/<key>` pattern.
## Features
- SHA-256 via `crypto.subtle` — runs in Node 18+, Bun, Deno, browsers, Workers.
- URL-safe base64 output: `+` → `-`, `/` → `_`, no `=` padding (matches BunnyCDN's verifier).
- Optional `tokenPath` query parameter for prefix-scoped tokens.
- Throws `STORAGE_CDN_BUNNYCDN.TOKEN_AUTH_KEY_MISSING` when the key is empty or missing.
- Pure async function — no state, no side effects.
## Usage: Basic
```ts
import { buildTokenAuthQuery } from "@suaveplan/storage-cdn-bunnycdn";
const query = await buildTokenAuthQuery({
tokenAuthKey: "per-zone-secret",
path: "/photos/a.jpg",
expires: Math.floor(Date.now() / 1000) + 60,
});
// query === "?token=<urlSafeBase64>&expires=<unix>"
```
## Usage: Advanced
```ts
import { buildTokenAuthQuery } from "@suaveplan/storage-cdn-bunnycdn";
async function signedFolderUrl(hostname: string, folder: string, ttl: number): Promise<string> {
const expires = Math.floor(Date.now() / 1000) + ttl;
const tokenPath = `/${folder}/`;
const query = await buildTokenAuthQuery({
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
path: tokenPath,
expires,
tokenPath,
});
return `https://${hostname}${tokenPath}${query}`;
}
```
## API Reference
### `interface TokenAuthInput`
`{ tokenAuthKey, path, expires, tokenPath? }`. `path` is the absolute path on the Pull Zone (with leading `/`). `expires` is Unix seconds. `tokenPath` enables prefix-scoped tokens (the token authorises every URL under the prefix until expiry).
### `buildTokenAuthQuery(input): Promise<string>`
Returns the `?token=...&expires=...` query string to append to the absolute edge URL. Throws `STORAGE_CDN_BUNNYCDN.TOKEN_AUTH_KEY_MISSING` on empty `tokenAuthKey`.
## Implementation Notes
- BunnyCDN's signing scheme is SHA-256 (not HMAC). The secret is concatenated directly with `path + expires` and hashed; the URL-safe-base64 encoding makes the result safe for query-parameter inclusion.
- Padding is stripped because BunnyCDN's verifier rejects tokens with `=` characters; this is a documented quirk of their implementation.
- `expires` is Unix seconds — BunnyCDN's standard everywhere. Passing milliseconds produces verifier rejections that read like "token expired" but are actually "token format wrong".
- The function is async because `crypto.subtle.digest` is Promise-returning. The underlying SHA-256 is fast (≪1ms).
- The shipped tests assert determinism (same inputs → identical output), format correctness, and difference under varied inputs; byte-exact reference vectors are the responsibility of integration tests against a live BunnyCDN account.
- `btoa` is part of the universal runtime baseline (Node 18+, Bun, Deno, browsers, Workers); a Node-only `Buffer` fallback is not needed.

View File

@@ -0,0 +1,79 @@
# adapter.types
## Purpose
Declares `BunnyCdnAdapterOptions` — the option bag `BunnyCdnAdapter` accepts — and the supporting `BunnyRetryOptions` shape. The interface extends `CdnAdapterOptions` from `@suaveplan/storage-cdn` so it carries the `originBaseUrl` / `edgeBaseUrl` slots every CDN provider in the family exposes, then adds BunnyCDN-specific fields: storage zone + access key, pull zone hostname, region, optional Token Authentication key, optional purge API key, and the standard injectable hooks (`fetch`, `timers`, `now`, `headers`).
## Features
- Extends `CdnAdapterOptions` so cross-provider helpers can accept either shape.
- `storageZone` + `storageAccessKey` for Storage Zone CRUD; both required.
- `pullZoneHostname` is a bare host (no scheme, no trailing slash) — validated by the Zod schema.
- `storageRegion` whitelisted to BunnyCDN's documented regions (`de`/`ny`/`la`/`sg`/`syd`/`uk`); defaults to `de` (Falkenstein, unprefixed).
- `tokenAuthKey` optional — required only for `signUrl`.
- `purgeApiKey` optional — required only for `purge`/`purgePrefix`.
- Injectable `fetch`, `timers`, `now`, and per-key `headers` resolver for testability.
## Usage: Basic
```ts
import type { BunnyCdnAdapterOptions } from "@suaveplan/storage-cdn-bunnycdn";
const options: BunnyCdnAdapterOptions = {
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
};
```
## Usage: Advanced
```ts
import type {
BunnyCdnAdapterOptions,
BunnyRetryOptions,
} from "@suaveplan/storage-cdn-bunnycdn";
const retry: BunnyRetryOptions = {
maxAttempts: 5,
baseDelayMs: 200,
maxDelayMs: 10_000,
honourRetryAfter: true,
};
const options: BunnyCdnAdapterOptions = {
storageZone: "myz",
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "ny",
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
purgeApiKey: process.env["BUNNY_PURGE_KEY"]!,
headers: async (key) => ({ "x-suaveplan-key": key }),
retry,
};
```
## API Reference
### `interface BunnyCdnAdapterOptions extends CdnAdapterOptions`
- `storageZone: string` — required Storage Zone name.
- `storageAccessKey: string` — required per-zone storage password.
- `pullZoneHostname: string` — bare host of the Pull Zone, e.g. `mycdn.b-cdn.net`.
- `storageRegion?: BunnyCdnRegion` — defaults to `"de"`.
- `tokenAuthKey?: string` — required only when calling `signUrl`.
- `purgeApiKey?: string` — required only when calling `purge`/`purgePrefix`.
- `fetch?`, `timers?`, `now?` — injectable for tests.
- `headers?` — static record or `(key) => Record<string, string> | Promise<...>`.
- `retry?: BunnyRetryOptions` — retry tuning.
### `interface BunnyRetryOptions`
`{ maxAttempts?, baseDelayMs?, maxDelayMs?, honourRetryAfter? }` — defaults documented in `constants.md`.
## Implementation Notes
- `pullZoneHostname` validation rejects scheme prefixes and trailing slashes at the Zod boundary — the most common configuration mistake is pasting the full URL the Bunny dashboard displays, which would silently produce broken edge URLs.
- `tokenAuthKey` and `purgeApiKey` are intentionally separate fields because they're different secrets with different scopes — Token Authentication is a per-Pull-Zone signing key, while the purge API key is account-level.
- `storageRegion` is a strict literal union enforced at the Zod boundary; passing an unknown region surfaces as `STORAGE_CDN_BUNNYCDN.INVALID_REGION` at construction time.
- `originBaseUrl` / `edgeBaseUrl` (inherited from `CdnAdapterOptions`) are optional and unused by this adapter's logic — `pullZoneHostname` is the authoritative public edge — but they're accepted (and trailing-slash-validated when present) for cross-provider helpers that work against either base type.