Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,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,94 @@
{
"name": "@suaveplan/storage-cdn-bunnycdn",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"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" }
},
"./bunny-cdn-adapter": {
"import": { "types": "./dist/bunny-cdn-adapter/index.d.ts", "default": "./dist/bunny-cdn-adapter/bunny-cdn-adapter.js" },
"require": { "types": "./dist/bunny-cdn-adapter/index.d.ts", "default": "./dist/bunny-cdn-adapter/bunny-cdn-adapter.js" }
},
"./storage-client": {
"import": { "types": "./dist/storage-client/index.d.ts", "default": "./dist/storage-client/storage-client.js" },
"require": { "types": "./dist/storage-client/index.d.ts", "default": "./dist/storage-client/storage-client.js" }
},
"./pull-zone-client": {
"import": { "types": "./dist/pull-zone-client/index.d.ts", "default": "./dist/pull-zone-client/pull-zone-client.js" },
"require": { "types": "./dist/pull-zone-client/index.d.ts", "default": "./dist/pull-zone-client/pull-zone-client.js" }
},
"./token-auth": {
"import": { "types": "./dist/token-auth/index.d.ts", "default": "./dist/token-auth/token-auth.js" },
"require": { "types": "./dist/token-auth/index.d.ts", "default": "./dist/token-auth/token-auth.js" }
},
"./key-sanitizer": {
"import": { "types": "./dist/key-sanitizer/index.d.ts", "default": "./dist/key-sanitizer/key-sanitizer.js" },
"require": { "types": "./dist/key-sanitizer/index.d.ts", "default": "./dist/key-sanitizer/key-sanitizer.js" }
},
"./retry-helper": {
"import": { "types": "./dist/retry-helper/index.d.ts", "default": "./dist/retry-helper/retry-helper.js" },
"require": { "types": "./dist/retry-helper/index.d.ts", "default": "./dist/retry-helper/retry-helper.js" }
},
"./otel": {
"import": { "types": "./dist/otel/index.d.ts", "default": "./dist/otel/otel.js" },
"require": { "types": "./dist/otel/index.d.ts", "default": "./dist/otel/otel.js" }
},
"./registry": {
"import": { "types": "./dist/registry/index.d.ts", "default": "./dist/registry/registry.js" },
"require": { "types": "./dist/registry/index.d.ts", "default": "./dist/registry/registry.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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/storage-cdn": "workspace:^",
"@suaveplan/storage-http": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,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,93 @@
import type { StorageCapabilities } from "@suaveplan/storage/types";
import { CdnAdapter, type CdnCapabilities } from "@suaveplan/storage-cdn";
import { type BunnyOp, recordOp } from "../otel/otel.js";
import type { StorageClientCtx } from "../storage-client/storage-client.js";
import type { BunnyCdnAdapterOptions } from "../types/adapter.types.js";
import {
outcomeFromError,
type ResolvedConfig,
resolveConfig,
resolveHeaders,
throwIfAborted,
} from "./bunny-cdn-adapter.helpers.js";
const CAPS: StorageCapabilities & { readonly cdn: CdnCapabilities } = {
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: true,
cdnPrefixPurge: true,
cdnSignedUrls: true,
cdnOriginUploads: true,
},
};
/**
* Shared internal machinery for `BunnyCdnAdapter` — the resolved
* configuration and the OTel-wrapping `wrap` helper. Lives in a base
* class so the public class file stays under the 300-line budget while
* the protected accessors stay available to the concrete methods.
*/
export abstract class BunnyCdnAdapterBase extends CdnAdapter {
protected readonly config: ResolvedConfig;
public override readonly capabilities = CAPS;
public constructor(options: BunnyCdnAdapterOptions) {
super();
this.config = resolveConfig(options);
}
protected async ctxFor(
key: string,
signal: AbortSignal | undefined
): Promise<StorageClientCtx> {
const headers = await resolveHeaders(this.config.options.headers, key);
const retry: StorageClientCtx["retry"] = {};
if (this.config.options.retry !== undefined)
retry.retry = this.config.options.retry;
if (this.config.options.timers !== undefined)
retry.timers = this.config.options.timers;
if (signal !== undefined) retry.signal = signal;
return {
fetch: this.config.fetch,
accessKey: this.config.accessKey,
zone: this.config.storageZone,
region: this.config.region,
retry,
signal,
headers,
};
}
protected async wrap<T>(
op: BunnyOp,
signal: AbortSignal | undefined,
fn: () => Promise<T>
): Promise<T> {
const start = Date.now();
const storageRegion = this.config.region;
try {
throwIfAborted(signal);
const value = await fn();
recordOp({
op,
outcome: "ok",
durationMs: Date.now() - start,
storageRegion,
});
return value;
} catch (err) {
recordOp({
op,
outcome: outcomeFromError(err),
durationMs: Date.now() - start,
storageRegion,
});
throw err;
}
}
}

View File

@@ -0,0 +1,199 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { setBunnyMeterProvider } from "../otel/otel.js";
import { BunnyCdnAdapter } from "./bunny-cdn-adapter.js";
function makeFetch(
...responses: Response[]
): FetchLike & { calls: { url: string; init: RequestInit | undefined }[] } {
let i = 0;
const calls: { url: string; init: RequestInit | undefined }[] = [];
const fn: FetchLike = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return responses[i++] ?? new Response(null, { status: 200 });
};
(fn as FetchLike & { calls: typeof calls }).calls = calls;
return fn as FetchLike & { calls: typeof calls };
}
function makeAdapter(
fetch: FetchLike,
overrides: Record<string, unknown> = {}
) {
return new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "de",
tokenAuthKey: "secret",
purgeApiKey: "purgek",
fetch,
...overrides,
});
}
describe("BunnyCdnAdapter extended coverage", () => {
beforeEach(() => setBunnyMeterProvider(undefined));
afterEach(() => setBunnyMeterProvider(undefined));
it("accepts custom timers in retry path", { timeout: 5000 }, async () => {
const setSpy = vi.fn((handler: () => void, ms: number) =>
Number(globalThis.setTimeout(handler, ms))
);
const clearSpy = vi.fn((id: number) => globalThis.clearTimeout(id));
const fetchFn = makeFetch(
new Response(null, { status: 503 }),
new Response(new Uint8Array([1]), { status: 200 })
);
const storage = makeAdapter(fetchFn, {
retry: { maxAttempts: 2, baseDelayMs: 1, honourRetryAfter: false },
timers: { setTimer: setSpy, clearTimer: clearSpy },
});
const bytes = await storage.get("k");
expect(Array.from(bytes as Uint8Array)).toEqual([1]);
expect(setSpy).toHaveBeenCalled();
});
it("accepts originBaseUrl and edgeBaseUrl options", {
timeout: 5000,
}, () => {
const fetchFn = makeFetch();
const storage = new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
originBaseUrl: "https://example.com/",
edgeBaseUrl: "https://e.example.com/",
fetch: fetchFn,
});
expect(storage).toBeInstanceOf(BunnyCdnAdapter);
});
it("rejects originBaseUrl without trailing slash", {
timeout: 5000,
}, () => {
try {
new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
originBaseUrl: "https://example.com",
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
it("threads signal into successful get fetch init", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(
new Response(new Uint8Array([1]), { status: 200 })
);
const c = new AbortController();
await makeAdapter(fetchFn).get("k", c.signal);
expect(fetchFn.calls[0]?.init?.signal).toBe(c.signal);
});
it("rejects edgeBaseUrl without trailing slash", { timeout: 5000 }, () => {
try {
new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
edgeBaseUrl: "https://e.example.com",
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
it("PUT emits writes_total with provider and storage_region attrs", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(new Response(null, { status: 201 }));
await makeAdapter(fetchFn).set("k", new Uint8Array([1]));
expect(
fetchFn.calls.some(
(c) =>
c.init?.method === "PUT" &&
c.url.startsWith("https://storage.bunnycdn.com")
)
).toBe(true);
});
it("createReadStream errors mid-stream on non-ok", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(new Response(null, { status: 500 }));
const reader = makeAdapter(fetchFn).createReadStream("k").getReader();
try {
await reader.read();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(StorageCdnBunnycdnError);
}
});
it("createReadStream closes when body is null", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(new Response(null, { status: 200 }));
const reader = makeAdapter(fetchFn).createReadStream("k").getReader();
expect((await reader.read()).done).toBe(true);
});
it("default fetch resolves via globalThis.fetch when omitted", {
timeout: 5000,
}, async () => {
const spy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(new Uint8Array([1]), { status: 200 })
);
try {
const storage = new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
});
await storage.get("k");
expect(spy).toHaveBeenCalled();
} finally {
spy.mockRestore();
}
});
it("createBunnyCdnAdapter factory returns an instance", {
timeout: 5000,
}, async () => {
const { createBunnyCdnAdapter } = await import(
"./bunny-cdn-adapter.js"
);
const storage = createBunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
fetch: makeFetch(),
});
expect(storage).toBeInstanceOf(BunnyCdnAdapter);
});
});

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,216 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import {
consumeHead,
outcomeFromError,
parseMetadata,
resolveConfig,
resolveHeaders,
throwIfAborted,
} from "./bunny-cdn-adapter.helpers.js";
const baseOpts = {
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
} as const;
describe("resolveConfig", () => {
it("returns defaults for omitted fields", { timeout: 5000 }, () => {
const config = resolveConfig({ ...baseOpts });
expect(config.region).toBe("de");
expect(typeof config.fetch).toBe("function");
expect(typeof config.now).toBe("function");
});
it("throws INVALID_REGION for unknown storageRegion", {
timeout: 5000,
}, () => {
try {
resolveConfig({ ...baseOpts, storageRegion: "tokyo" } as never);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_REGION
);
}
});
it("throws INVALID_OPTIONS on pullZoneHostname with scheme", {
timeout: 5000,
}, () => {
try {
resolveConfig({
...baseOpts,
pullZoneHostname: "https://mycdn.b-cdn.net",
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
it("throws INVALID_OPTIONS on empty storageZone", { timeout: 5000 }, () => {
try {
resolveConfig({ ...baseOpts, storageZone: "" });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
});
describe("resolveHeaders", () => {
it("undefined → empty record", { timeout: 5000 }, async () => {
expect(await resolveHeaders(undefined, "k")).toEqual({});
});
it("static record → defensive copy", { timeout: 5000 }, async () => {
const src = { a: "1" };
const out = await resolveHeaders(src, "k");
out.a = "2";
expect(src.a).toBe("1");
});
it("function form invoked with key", { timeout: 5000 }, async () => {
const fn = async (k: string): Promise<Record<string, string>> => ({
key: k,
});
expect(await resolveHeaders(fn, "alpha")).toEqual({ key: "alpha" });
});
});
describe("throwIfAborted", () => {
it("no-op when signal undefined or not aborted", { timeout: 5000 }, () => {
expect(() => throwIfAborted(undefined)).not.toThrow();
expect(() =>
throwIfAborted(new AbortController().signal)
).not.toThrow();
});
it("throws when aborted", { timeout: 5000 }, () => {
const c = new AbortController();
c.abort();
expect(() => throwIfAborted(c.signal)).toThrow(StorageCdnBunnycdnError);
});
});
describe("outcomeFromError", () => {
it("maps known codes", { timeout: 5000 }, () => {
const cases: Array<[string, string]> = [
[STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED, "auth_failed"],
[STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED, "auth_failed"],
[STORAGE_CDN_BUNNYCDN_CODES.ZONE_NOT_FOUND, "not_found"],
[STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR, "upstream_error"],
[STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS, "upstream_error"],
];
for (const [code, expected] of cases) {
expect(
outcomeFromError(
new StorageCdnBunnycdnError(code as never, "x")
)
).toBe(expected);
}
});
it("non-StorageCdnBunnycdnError → upstream_error", {
timeout: 5000,
}, () => {
expect(outcomeFromError(new Error("boom"))).toBe("upstream_error");
});
});
describe("parseMetadata", () => {
it("defaults size to 0 and modifiedAt to now when headers absent", {
timeout: 5000,
}, () => {
const before = Date.now();
const meta = parseMetadata(new Response());
const after = Date.now();
expect(meta.size).toBe(0);
expect(meta.modifiedAt).toBeGreaterThanOrEqual(before);
expect(meta.modifiedAt).toBeLessThanOrEqual(after);
});
it("parses Content-Length and Last-Modified", { timeout: 5000 }, () => {
const lastMod = "Wed, 21 Oct 2026 07:28:00 GMT";
const headers = new Headers({
"content-length": "42",
"last-modified": lastMod,
});
const meta = parseMetadata(
new Response(null, { status: 200, headers })
);
expect(meta.size).toBe(42);
expect(meta.modifiedAt).toBe(Date.parse(lastMod));
});
it("defaults modifiedAt to now when Last-Modified is unparseable", {
timeout: 5000,
}, () => {
const headers = new Headers({ "last-modified": "garbage" });
const before = Date.now();
const meta = parseMetadata(
new Response(null, { status: 200, headers })
);
expect(meta.modifiedAt).toBeGreaterThanOrEqual(before);
});
});
describe("consumeHead", () => {
it("404 returns null", { timeout: 5000 }, () => {
expect(
consumeHead(
new Response(null, { status: 404 }),
"z",
"de",
() => "x"
)
).toBeNull();
});
it("401/403 throws STORAGE_AUTH_FAILED", { timeout: 5000 }, () => {
try {
consumeHead(
new Response(null, { status: 401 }),
"z",
"de",
() => "x"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED
);
}
});
it("other non-ok throws UPSTREAM_ERROR", { timeout: 5000 }, () => {
try {
consumeHead(
new Response(null, { status: 500 }),
"z",
"de",
() => "x"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("2xx applies success", { timeout: 5000 }, () => {
expect(
consumeHead(
new Response(null, { status: 200 }),
"z",
"de",
() => "ok"
)
).toBe("ok");
});
});

View File

@@ -0,0 +1,135 @@
import type { StorageMetadata } from "@suaveplan/storage/types";
import type { FetchLike } from "@suaveplan/types";
import { type BunnyCdnRegion, DEFAULT_REGION } from "../constants/constants.js";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { BunnyCdnAdapterOptionsSchema } from "../types/adapter.schemas.js";
import type { BunnyCdnAdapterOptions } from "../types/adapter.types.js";
export interface ResolvedConfig {
options: BunnyCdnAdapterOptions;
storageZone: string;
accessKey: string;
pullZoneHostname: string;
region: BunnyCdnRegion;
tokenAuthKey: string | undefined;
purgeApiKey: string | undefined;
fetch: FetchLike;
now: () => number;
}
export function resolveConfig(options: BunnyCdnAdapterOptions): ResolvedConfig {
const parsed = BunnyCdnAdapterOptionsSchema.safeParse(options);
if (!parsed.success) {
const regionIssue = parsed.error.issues.find(
(i) => i.path[0] === "storageRegion"
);
const code =
regionIssue !== undefined
? STORAGE_CDN_BUNNYCDN_CODES.INVALID_REGION
: STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS;
const message = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
throw new StorageCdnBunnycdnError(code, `invalid options: ${message}`);
}
const fetchImpl: FetchLike =
options.fetch ??
((input, init) =>
globalThis.fetch(
input as Parameters<typeof globalThis.fetch>[0],
init
));
return {
options,
storageZone: options.storageZone,
accessKey: options.storageAccessKey,
pullZoneHostname: options.pullZoneHostname,
region: options.storageRegion ?? DEFAULT_REGION,
tokenAuthKey: options.tokenAuthKey,
purgeApiKey: options.purgeApiKey,
fetch: fetchImpl,
now: options.now ?? (() => Date.now()),
};
}
export async function resolveHeaders(
resolver: BunnyCdnAdapterOptions["headers"] | undefined,
key: string
): Promise<Record<string, string>> {
if (resolver === undefined) return {};
if (typeof resolver === "function") {
const value = await resolver(key);
return { ...value };
}
return { ...resolver };
}
export function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"Operation aborted",
{ cause: signal.reason as Error | undefined }
);
}
}
export function outcomeFromError(
err: unknown
): "auth_failed" | "not_found" | "upstream_error" {
if (err instanceof StorageCdnBunnycdnError) {
switch (err.code) {
case STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED:
case STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED:
return "auth_failed";
case STORAGE_CDN_BUNNYCDN_CODES.ZONE_NOT_FOUND:
return "not_found";
default:
return "upstream_error";
}
}
return "upstream_error";
}
export function parseMetadata(response: Response): StorageMetadata {
const contentLength = response.headers.get("content-length");
const lastModified = response.headers.get("last-modified");
const modifiedMs =
lastModified !== null && !Number.isNaN(Date.parse(lastModified))
? Date.parse(lastModified)
: Date.now();
const size =
contentLength !== null ? Number.parseInt(contentLength, 10) : -1;
return {
createdAt: modifiedMs,
modifiedAt: modifiedMs,
size: size >= 0 ? size : 0,
};
}
export function consumeHead<T>(
response: Response,
zone: string,
region: BunnyCdnRegion,
success: (r: Response) => T
): T | null {
if (response.status === 404) return null;
if (response.status === 401 || response.status === 403) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED,
`Storage Zone auth failed (${response.status})`,
{ zone, region, httpStatus: response.status }
);
}
if (!response.ok) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
`Storage Zone upstream ${response.status}`,
{ zone, region, httpStatus: response.status }
);
}
return success(response);
}

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,164 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { AbstractStorage } from "@suaveplan/storage/abstract";
import { CdnAdapter } from "@suaveplan/storage-cdn";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { BunnyCdnAdapter } from "./bunny-cdn-adapter.js";
function makeAdapter(
overrides?: Partial<{ fetch: FetchLike }>
): BunnyCdnAdapter {
return new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "de",
tokenAuthKey: "secret",
purgeApiKey: "purgek",
fetch:
overrides?.fetch ??
(async () => new Response(null, { status: 200 })),
});
}
describe("BunnyCdnAdapter spec conformance", () => {
it("instance is CdnAdapter and AbstractStorage", { timeout: 5000 }, () => {
const storage = makeAdapter();
expect(storage).toBeInstanceOf(CdnAdapter);
expect(storage).toBeInstanceOf(AbstractStorage);
});
it("declares the documented capabilities matrix", { timeout: 5000 }, () => {
expect(makeAdapter().capabilities).toEqual({
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: true,
cdnPrefixPurge: true,
cdnSignedUrls: true,
cdnOriginUploads: true,
},
});
});
it("every STORAGE_CDN_BUNNYCDN code is reachable", {
timeout: 15000,
}, async () => {
const reached = new Set<string>();
const collect = async (fn: () => Promise<unknown>): Promise<void> => {
try {
await fn();
} catch (err) {
if (err instanceof StorageCdnBunnycdnError)
reached.add(err.code);
}
};
// STORAGE_AUTH_FAILED — 401 from storage zone.
await collect(async () => {
const fetchFn: FetchLike = async () =>
new Response(null, { status: 401 });
await makeAdapter({ fetch: fetchFn }).get("k");
});
// INVALID_OPTIONS — bad pullZoneHostname.
await collect(async () => {
new BunnyCdnAdapter({
storageZone: "z",
storageAccessKey: "ak",
pullZoneHostname: "https://example.com",
});
});
// INVALID_REGION — unknown region.
await collect(async () => {
new BunnyCdnAdapter({
storageZone: "z",
storageAccessKey: "ak",
pullZoneHostname: "x.b-cdn.net",
storageRegion: "tokyo" as never,
});
});
// TOKEN_AUTH_KEY_MISSING.
await collect(async () => {
const storage = new BunnyCdnAdapter({
storageZone: "z",
storageAccessKey: "ak",
pullZoneHostname: "x.b-cdn.net",
fetch: async () => new Response(),
});
await storage.signUrl("k", { ttlSeconds: 60 });
});
// PURGE_API_KEY_MISSING.
await collect(async () => {
const storage = new BunnyCdnAdapter({
storageZone: "z",
storageAccessKey: "ak",
pullZoneHostname: "x.b-cdn.net",
fetch: async () => new Response(),
});
await storage.purge("k");
});
// PURGE_AUTH_FAILED.
await collect(async () => {
const storage = makeAdapter({
fetch: async () => new Response(null, { status: 401 }),
});
await storage.purge("k");
});
// UPSTREAM_ERROR — 500 after exhaustion.
await collect(async () => {
const storage = new BunnyCdnAdapter({
storageZone: "z",
storageAccessKey: "ak",
pullZoneHostname: "x.b-cdn.net",
fetch: async () => new Response(null, { status: 500 }),
retry: { maxAttempts: 1, honourRetryAfter: false },
});
await storage.get("k");
});
// ZONE_NOT_FOUND — reserved registry code raised directly.
await collect(async () => {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.ZONE_NOT_FOUND,
"zone missing"
);
});
for (const code of Object.values(STORAGE_CDN_BUNNYCDN_CODES)) {
expect(reached.has(code)).toBe(true);
}
});
it("src tree contains no node: or bun: imports", { timeout: 5000 }, () => {
const srcDir = join(__dirname, "..");
const offenders: string[] = [];
walk(srcDir, (file) => {
if (
!file.endsWith(".ts") ||
file.endsWith(".test.ts") ||
file.endsWith(".spec-conformance.test.ts")
) {
return;
}
const text = readFileSync(file, "utf8");
if (/from\s+['"](node:|bun:)/.test(text)) offenders.push(file);
});
expect(offenders).toEqual([]);
});
});
function walk(dir: string, visit: (file: string) => void): void {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) walk(full, visit);
else visit(full);
}
}

View File

@@ -0,0 +1,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,110 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import type { StorageClientCtx } from "../storage-client/storage-client.js";
import {
buildReadStream,
buildWriteStream,
} from "./bunny-cdn-adapter.streams.js";
function ctxFor(response: Response): Promise<StorageClientCtx> {
return Promise.resolve({
fetch: async () => response,
accessKey: "k",
zone: "z",
region: "de",
retry: {},
signal: undefined,
headers: {},
});
}
describe("buildReadStream", () => {
it("pumps body chunks", { timeout: 5000 }, async () => {
const body = new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(new Uint8Array([1]));
c.close();
},
});
const stream = buildReadStream(
ctxFor(new Response(body, { status: 200 })),
"k",
undefined
);
const reader = stream.getReader();
const r = await reader.read();
expect(r.value && Array.from(r.value)).toEqual([1]);
expect((await reader.read()).done).toBe(true);
});
it("closes when body is null", { timeout: 5000 }, async () => {
const stream = buildReadStream(
ctxFor(new Response(null, { status: 200 })),
"k",
undefined
);
const r = await stream.getReader().read();
expect(r.done).toBe(true);
});
it("errors on non-ok upstream", { timeout: 5000 }, async () => {
const stream = buildReadStream(
ctxFor(new Response(null, { status: 500 })),
"k",
undefined
);
try {
await stream.getReader().read();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(StorageCdnBunnycdnError);
}
});
it("pre-aborted signal yields UPSTREAM_ERROR", {
timeout: 5000,
}, async () => {
const c = new AbortController();
c.abort();
const stream = buildReadStream(
ctxFor(new Response(null, { status: 200 })),
"k",
c.signal
);
try {
await stream.getReader().read();
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
});
describe("buildWriteStream", () => {
it("buffers and commits on close", { timeout: 5000 }, async () => {
const commit = vi.fn(async () => undefined);
const ws = buildWriteStream({ key: "k", signal: undefined, commit });
const writer = ws.getWriter();
await writer.write(new Uint8Array([1, 2]));
await writer.write(new Uint8Array([3]));
await writer.close();
expect(commit).toHaveBeenCalledTimes(1);
expect(Array.from(commit.mock.calls[0]?.[0] as Uint8Array)).toEqual([
1, 2, 3,
]);
});
it("abort drops buffered chunks", { timeout: 5000 }, async () => {
const commit = vi.fn(async () => undefined);
const ws = buildWriteStream({ key: "k", signal: undefined, commit });
const writer = ws.getWriter();
await writer.write(new Uint8Array([1]));
await writer.abort();
expect(commit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import {
type StorageClientCtx,
storageStream,
} from "../storage-client/storage-client.js";
import { throwIfAborted } from "./bunny-cdn-adapter.helpers.js";
/** ReadableStream pumping the BunnyCDN Storage GET response body. */
export function buildReadStream(
ctxPromise: Promise<StorageClientCtx>,
key: string,
signal: AbortSignal | undefined
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start: async (controller) => {
try {
throwIfAborted(signal);
const ctx = await ctxPromise;
const response = await storageStream(ctx, key);
const body = response.body;
if (body === null) {
controller.close();
return;
}
const reader = body.getReader();
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
controller.enqueue(chunk.value);
}
controller.close();
} finally {
reader.releaseLock();
}
} catch (err) {
controller.error(err);
}
},
});
}
export interface WriteStreamArgs {
key: string;
signal: AbortSignal | undefined;
commit: (
body: Uint8Array,
signal: AbortSignal | undefined
) => Promise<void>;
}
/** WritableStream that buffers then commits on close. */
export function buildWriteStream(
args: WriteStreamArgs
): WritableStream<Uint8Array> {
const chunks: Uint8Array[] = [];
return new WritableStream<Uint8Array>({
write: (chunk) => {
chunks.push(chunk);
},
close: async () => {
let total = 0;
for (const c of chunks) total += c.byteLength;
const body = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
body.set(c, off);
off += c.byteLength;
}
await args.commit(body, args.signal);
},
abort: () => {
chunks.length = 0;
},
});
}

View File

@@ -0,0 +1,462 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
STORAGE_CDN_BUNNYCDN_CODES,
type StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { setBunnyMeterProvider } from "../otel/otel.js";
import { BunnyCdnAdapter } from "./bunny-cdn-adapter.js";
const PURGE = "https://api.bunny.net/purge";
function makeFetch(
...responses: Response[]
): FetchLike & { calls: { url: string; init: RequestInit | undefined }[] } {
let i = 0;
const calls: { url: string; init: RequestInit | undefined }[] = [];
const fn: FetchLike = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
calls.push({ url, init });
return responses[i++] ?? new Response(null, { status: 200 });
};
(fn as FetchLike & { calls: typeof calls }).calls = calls;
return fn as FetchLike & { calls: typeof calls };
}
function makeAdapter(
fetch: FetchLike,
overrides: Record<string, unknown> = {}
) {
return new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "de",
tokenAuthKey: "secret",
purgeApiKey: "purgek",
fetch,
...overrides,
});
}
describe("BunnyCdnAdapter", () => {
beforeEach(() => setBunnyMeterProvider(undefined));
afterEach(() => setBunnyMeterProvider(undefined));
it("rejects pullZoneHostname with scheme", { timeout: 5000 }, () => {
try {
new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "https://mycdn.b-cdn.net",
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
it("rejects unknown storageRegion", { timeout: 5000 }, () => {
try {
new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
storageRegion: "tokyo" as never,
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_REGION
);
}
});
it("de region uses unprefixed storage host", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(new Response(null, { status: 201 }));
await makeAdapter(fetchFn).set("a/b.bin", new Uint8Array([1]));
expect(fetchFn.calls[0]?.url).toBe(
"https://storage.bunnycdn.com/myz/a/b.bin"
);
});
it("ny region prefixes the host", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 201 }));
await makeAdapter(fetchFn, { storageRegion: "ny" }).set(
"a/b.bin",
new Uint8Array([1])
);
expect(fetchFn.calls[0]?.url).toBe(
"https://ny.storage.bunnycdn.com/myz/a/b.bin"
);
});
it("401 surfaces STORAGE_AUTH_FAILED", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 401 }));
try {
await makeAdapter(fetchFn).get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED
);
}
});
it("GET 404 returns undefined", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 404 }));
expect(await makeAdapter(fetchFn).get("missing")).toBeUndefined();
});
it("Range request returns 206 body verbatim", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(
new Response(new Uint8Array([2, 3]), { status: 206 })
);
const bytes = await makeAdapter(fetchFn).getPartial("k", 1, 2);
expect(Array.from(bytes as Uint8Array)).toEqual([2, 3]);
});
it("createReadStream returns Response.body without buffering", {
timeout: 5000,
}, async () => {
const body = new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(new Uint8Array([1]));
c.enqueue(new Uint8Array([2]));
c.close();
},
});
const fetchFn = makeFetch(new Response(body, { status: 200 }));
const reader = makeAdapter(fetchFn).createReadStream("k").getReader();
const out: number[] = [];
while (true) {
const r = await reader.read();
if (r.done) break;
out.push(...r.value);
}
expect(out).toEqual([1, 2]);
});
it("edgeUrl composes Pull Zone URL with encoded key", {
timeout: 5000,
}, () => {
const fetchFn = makeFetch();
expect(makeAdapter(fetchFn).edgeUrl("a b/c.png")).toBe(
"https://mycdn.b-cdn.net/a%20b/c.png"
);
});
it("single URL purge resolves on 200", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 200 }));
await makeAdapter(fetchFn).purge("k");
const purgeCall = fetchFn.calls.find((c) => c.url.startsWith(PURGE));
expect(purgeCall).toBeDefined();
expect(decodeURIComponent(purgeCall!.url.split("?url=")[1] ?? "")).toBe(
"https://mycdn.b-cdn.net/k"
);
});
it("missing purge key throws PURGE_API_KEY_MISSING", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn();
const storage = new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
fetch: fetchFn as FetchLike,
});
try {
await storage.purge("k");
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.PURGE_API_KEY_MISSING
);
}
});
it("prefix purge appends wildcard to URL", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 200 }));
await makeAdapter(fetchFn).purgePrefix("images/");
const purgeCall = fetchFn.calls.find((c) => c.url.startsWith(PURGE));
expect(decodeURIComponent(purgeCall!.url.split("?url=")[1] ?? "")).toBe(
"https://mycdn.b-cdn.net/images/*"
);
});
it("signUrl produces token + expires", { timeout: 5000 }, async () => {
const fetchFn = makeFetch();
const url = await makeAdapter(fetchFn).signUrl("a/b.png", {
ttlSeconds: 60,
});
expect(url).toMatch(/token=[A-Za-z0-9_-]+/);
expect(url).toMatch(/expires=\d+/);
expect(url.startsWith("https://mycdn.b-cdn.net/a/b.png?")).toBe(true);
});
it("signUrl throws TOKEN_AUTH_KEY_MISSING when not configured", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch();
const storage = new BunnyCdnAdapter({
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
fetch: fetchFn,
});
try {
await storage.signUrl("k", { ttlSeconds: 60 });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.TOKEN_AUTH_KEY_MISSING
);
}
});
it("rejects path-traversal keys before fetch", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 200 }));
try {
await makeAdapter(fetchFn as FetchLike).get("../etc/passwd");
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
it("retries 503 honouring Retry-After", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(
new Response(null, {
status: 503,
headers: { "retry-after": "0" },
}),
new Response(new Uint8Array([9]), { status: 200 })
);
const storage = makeAdapter(fetchFn, {
retry: { maxAttempts: 3, baseDelayMs: 1 },
});
const bytes = await storage.get("k");
expect(Array.from(bytes as Uint8Array)).toEqual([9]);
});
it("does not retry 401", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 401 }));
try {
await makeAdapter(fetchFn).get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED
);
}
});
it("pre-aborted signal short-circuits all methods", {
timeout: 5000,
}, async () => {
const c = new AbortController();
c.abort();
const fetchFn = vi.fn(async () => new Response(null, { status: 200 }));
const storage = makeAdapter(fetchFn as FetchLike);
try {
await storage.get("k", c.signal);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("exists returns true on 200 and false on 404", {
timeout: 5000,
}, async () => {
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 200 }))
).exists("k")
).toBe(true);
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 404 }))
).exists("missing")
).toBe(false);
});
it("getMetadata parses Content-Length", { timeout: 5000 }, async () => {
const headers = new Headers({ "content-length": "42" });
const fetchFn = makeFetch(new Response(null, { status: 200, headers }));
const meta = await makeAdapter(fetchFn).getMetadata("k");
expect(meta?.size).toBe(42);
});
it("getWithMetadata composes get + getMetadata", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
new Response(null, {
status: 200,
headers: { "content-length": "3" },
})
);
const r = await makeAdapter(fetchFn).getWithMetadata("k");
expect(r?.metadata.size).toBe(3);
});
it("getWithMetadata returns null on GET 404", {
timeout: 5000,
}, async () => {
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 404 }))
).getWithMetadata("missing")
).toBeNull();
});
it("getWithMetadata synthesises metadata when HEAD returns null", {
timeout: 5000,
}, async () => {
const fetchFn = makeFetch(
new Response(new Uint8Array([9]), { status: 200 }),
new Response(null, { status: 404 })
);
const r = await makeAdapter(fetchFn).getWithMetadata("k");
expect(r?.metadata.size).toBe(1);
});
it("getItemSize returns size or null", { timeout: 5000 }, async () => {
const headers = new Headers({ "content-length": "8" });
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 200, headers }))
).getItemSize("k")
).toBe(8);
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 404 }))
).getItemSize("missing")
).toBeNull();
});
it("delete returns true on success / false on 404", {
timeout: 5000,
}, async () => {
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 200 }))
).delete("k")
).toBe(true);
expect(
await makeAdapter(
makeFetch(new Response(null, { status: 404 }))
).delete("missing")
).toBe(false);
});
it("setPartial uses default read-modify-write", {
timeout: 5000,
}, async () => {
const store = new Map<string, Uint8Array>();
const fetchFn: FetchLike = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
const method = init?.method ?? "GET";
const key = url.split("/").slice(-1)[0] as string;
if (method === "PUT") {
store.set(key, init?.body as Uint8Array);
return new Response(null, { status: 201 });
}
const bytes = store.get(key);
return bytes !== undefined
? new Response(bytes, { status: 200 })
: new Response(null, { status: 404 });
};
const storage = makeAdapter(fetchFn);
await storage.set("k", new Uint8Array([1, 2, 3, 4]));
await storage.setPartial("k", 1, new Uint8Array([9, 9]));
expect(Array.from(store.get("k") as Uint8Array)).toEqual([1, 9, 9, 4]);
});
it("createWriteStream buffers then PUTs", { timeout: 5000 }, async () => {
const fetchFn = makeFetch(new Response(null, { status: 201 }));
const ws = makeAdapter(fetchFn).createWriteStream("k");
const writer = ws.getWriter();
await writer.write(new Uint8Array([1]));
await writer.write(new Uint8Array([2]));
await writer.close();
expect(fetchFn.calls.some((c) => c.init?.method === "PUT")).toBe(true);
});
it("createWriteStream abort drops buffered chunks", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 200 }));
const ws = makeAdapter(fetchFn as FetchLike).createWriteStream("k");
const writer = ws.getWriter();
await writer.write(new Uint8Array([1]));
await writer.abort();
expect(
(fetchFn as ReturnType<typeof vi.fn>).mock.calls.every((c) => {
const init = c[1] as RequestInit;
return init?.method !== "PUT";
})
).toBe(true);
});
it("list throws UPSTREAM_ERROR (not implemented)", {
timeout: 5000,
}, async () => {
try {
await makeAdapter(makeFetch()).list();
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("clear throws UPSTREAM_ERROR (not implemented)", {
timeout: 5000,
}, async () => {
try {
await makeAdapter(makeFetch()).clear();
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("getSize throws UPSTREAM_ERROR (not implemented)", {
timeout: 5000,
}, async () => {
try {
await makeAdapter(makeFetch()).getSize();
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
});

View File

@@ -0,0 +1,291 @@
import type {
StorageListOptions,
StorageListResult,
StorageMetadata,
StorageOptions,
StorageResult,
} from "@suaveplan/storage/types";
import {
parseSignUrlOptions,
type SignUrlOptions,
} from "@suaveplan/storage-cdn";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { encodeKey, sanitizeKey } from "../key-sanitizer/key-sanitizer.js";
import { type BunnyOp, recordOp } from "../otel/otel.js";
import { purgeUrl } from "../pull-zone-client/pull-zone-client.js";
import {
storageDelete,
storageGet,
storageHead,
storagePartial,
storagePut,
} from "../storage-client/storage-client.js";
import { buildTokenAuthQuery } from "../token-auth/token-auth.js";
import { BunnyCdnAdapterBase } from "./bunny-cdn-adapter.base.js";
import { consumeHead, parseMetadata } from "./bunny-cdn-adapter.helpers.js";
import {
buildReadStream,
buildWriteStream,
} from "./bunny-cdn-adapter.streams.js";
/**
* BunnyCDN Storage Zone + Pull Zone provider. Storage ops go via AccessKey
* against the regional Storage Zone host; reads from the public Pull Zone
* hostname; purge via the Bunny.net management API; signed URLs via
* Pull Zone Token Authentication (SHA-256 + URL-safe base64).
*/
export class BunnyCdnAdapter extends BunnyCdnAdapterBase {
public override async get(
key: string,
signal?: AbortSignal
): Promise<Uint8Array | undefined> {
const safe = sanitizeKey(key);
return this.wrap("get", signal, async () =>
storageGet(await this.ctxFor(safe, signal), safe)
);
}
public override async set(
key: string,
value: Uint8Array,
_options?: StorageOptions,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(key);
await this.validateValueSize(safe, value, signal);
await this.wrap("set", signal, async () => {
const ctx = await this.ctxFor(safe, signal);
await storagePut(
ctx,
safe,
value,
ctx.headers["content-type"] ?? "application/octet-stream"
);
recordOp({
op: "set",
outcome: "ok",
durationMs: 0,
bytes: value.byteLength,
direction: "write",
storageRegion: this.config.region,
});
});
}
public override async delete(
key: string,
signal?: AbortSignal
): Promise<boolean> {
const safe = sanitizeKey(key);
return this.wrap("delete", signal, async () =>
storageDelete(await this.ctxFor(safe, signal), safe)
);
}
async #head<T>(
op: BunnyOp,
key: string,
signal: AbortSignal | undefined,
success: (r: Response) => T
): Promise<T | null> {
const safe = sanitizeKey(key);
return this.wrap(op, signal, async () =>
consumeHead(
await storageHead(await this.ctxFor(safe, signal), safe),
this.config.storageZone,
this.config.region,
success
)
);
}
public override async exists(
key: string,
signal?: AbortSignal
): Promise<boolean> {
return (await this.#head("exists", key, signal, () => true)) ?? false;
}
public override getMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageMetadata | null> {
return this.#head("getMetadata", key, signal, parseMetadata);
}
public override async getWithMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageResult<Uint8Array> | null> {
const value = await this.get(key, signal);
if (value === undefined) return null;
const metadata = (await this.getMetadata(key, signal)) ?? {
createdAt: Date.now(),
modifiedAt: Date.now(),
size: value.byteLength,
};
return { value, metadata };
}
public override async getItemSize(
key: string,
signal?: AbortSignal
): Promise<number | null> {
return (await this.getMetadata(key, signal))?.size ?? null;
}
public override async getPartial(
key: string,
offset: number,
length: number,
signal?: AbortSignal
): Promise<Uint8Array | null> {
const safe = sanitizeKey(key);
return this.wrap("getPartial", signal, async () =>
storagePartial(
await this.ctxFor(safe, signal),
safe,
offset,
length
)
);
}
public override createReadStream(
key: string,
signal?: AbortSignal
): ReadableStream<Uint8Array> {
const safe = sanitizeKey(key);
return buildReadStream(this.ctxFor(safe, signal), safe, signal);
}
public override async setPartial(
key: string,
offset: number,
bytes: Uint8Array,
signal?: AbortSignal
): Promise<void> {
await this.defaultSetPartial(key, offset, bytes, signal);
}
public override createWriteStream(
key: string,
signal?: AbortSignal
): WritableStream<Uint8Array> {
const safe = sanitizeKey(key);
return buildWriteStream({
key: safe,
signal,
commit: (body, s) => this.set(safe, body, undefined, s),
});
}
public override list(
_options?: StorageListOptions,
_signal?: AbortSignal
): Promise<StorageListResult> {
return Promise.reject(
new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"list is not implemented in this adapter; use the Bunny Storage listing API directly",
{ zone: this.config.storageZone, region: this.config.region }
)
);
}
public override clear(_signal?: AbortSignal): Promise<void> {
return Promise.reject(
new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"clear is not implemented in this adapter",
{ zone: this.config.storageZone, region: this.config.region }
)
);
}
public override getSize(_signal?: AbortSignal): Promise<number> {
return Promise.reject(
new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"getSize is not implemented; aggregate via the listing API",
{ zone: this.config.storageZone, region: this.config.region }
)
);
}
public override async purge(
key: string,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(key);
await this.wrap("purge", signal, () =>
purgeUrl(this.#pullZoneCtx(), this.edgeUrl(safe), signal)
);
}
/** Prefix purge — appends `*` to the encoded edge URL. */
public async purgePrefix(
prefix: string,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(prefix);
await this.wrap("purgePrefix", signal, () =>
purgeUrl(this.#pullZoneCtx(), `${this.edgeUrl(safe)}*`, signal)
);
}
public override async signUrl(
key: string,
options: SignUrlOptions,
signal?: AbortSignal
): Promise<string> {
const safe = sanitizeKey(key);
const opts = parseSignUrlOptions(options);
return this.wrap("signUrl", signal, async () => {
if (this.config.tokenAuthKey === undefined) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.TOKEN_AUTH_KEY_MISSING,
"tokenAuthKey not configured",
{
zone: this.config.storageZone,
region: this.config.region,
}
);
}
const expires =
Math.floor(this.config.now() / 1000) + opts.ttlSeconds;
const path = `/${encodeKey(safe)}`;
const query = await buildTokenAuthQuery({
tokenAuthKey: this.config.tokenAuthKey,
path,
expires,
});
return `${this.edgeUrl(safe)}${query}`;
});
}
public override edgeUrl(key: string): string {
const safe = sanitizeKey(key);
return `https://${this.config.pullZoneHostname}/${encodeKey(safe)}`;
}
#pullZoneCtx() {
return {
fetch: this.config.fetch,
purgeApiKey: this.config.purgeApiKey,
pullZoneHostname: this.config.pullZoneHostname,
zone: this.config.storageZone,
region: this.config.region,
};
}
}
/** Convenience factory mirroring `createHttpStorage`. */
export function createBunnyCdnAdapter(
options: ConstructorParameters<typeof BunnyCdnAdapter>[0]
): BunnyCdnAdapter {
return new BunnyCdnAdapter(options);
}

View File

@@ -0,0 +1,19 @@
export { BunnyCdnAdapterBase } from "./bunny-cdn-adapter.base.js";
export {
consumeHead,
outcomeFromError,
parseMetadata,
type ResolvedConfig,
resolveConfig,
resolveHeaders,
throwIfAborted,
} from "./bunny-cdn-adapter.helpers.js";
export {
BunnyCdnAdapter,
createBunnyCdnAdapter,
} from "./bunny-cdn-adapter.js";
export {
buildReadStream,
buildWriteStream,
type WriteStreamArgs,
} from "./bunny-cdn-adapter.streams.js";

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BUNNYCDN_REGIONS,
DEFAULT_REGION,
DEFAULT_RETRY_BASE_DELAY_MS,
DEFAULT_RETRY_MAX_ATTEMPTS,
DEFAULT_RETRY_MAX_DELAY_MS,
DEFAULT_STORAGE_HOST,
MAX_KEY_LENGTH,
PURGE_API_HOST,
RETRY_AFTER_MAX_DELAY_MS,
storageHostFor,
TRANSIENT_HTTP_STATUSES,
} from "./constants.js";
describe("constants", () => {
it("expose the documented defaults", { timeout: 5000 }, () => {
expect(DEFAULT_REGION).toBe("de");
expect(DEFAULT_STORAGE_HOST).toBe("storage.bunnycdn.com");
expect(PURGE_API_HOST).toBe("api.bunny.net");
expect(BUNNYCDN_REGIONS).toContain("de");
expect(BUNNYCDN_REGIONS).toContain("syd");
expect(MAX_KEY_LENGTH).toBe(2048);
expect(DEFAULT_RETRY_MAX_ATTEMPTS).toBeGreaterThan(0);
expect(DEFAULT_RETRY_BASE_DELAY_MS).toBeGreaterThan(0);
expect(DEFAULT_RETRY_MAX_DELAY_MS).toBeGreaterThan(
DEFAULT_RETRY_BASE_DELAY_MS
);
expect(RETRY_AFTER_MAX_DELAY_MS).toBe(60_000);
expect(TRANSIENT_HTTP_STATUSES.has(429)).toBe(true);
});
});
describe("storageHostFor", () => {
it("de region uses the unprefixed host", { timeout: 5000 }, () => {
expect(storageHostFor("de")).toBe("storage.bunnycdn.com");
});
it("non-de regions prepend the code", { timeout: 5000 }, () => {
expect(storageHostFor("ny")).toBe("ny.storage.bunnycdn.com");
expect(storageHostFor("la")).toBe("la.storage.bunnycdn.com");
expect(storageHostFor("uk")).toBe("uk.storage.bunnycdn.com");
});
});

View File

@@ -0,0 +1,36 @@
/** Supported BunnyCDN Storage regions. */
export const BUNNYCDN_REGIONS = ["de", "ny", "la", "sg", "syd", "uk"] as const;
export type BunnyCdnRegion = (typeof BUNNYCDN_REGIONS)[number];
/** Default storage region — Falkenstein (DE). */
export const DEFAULT_REGION: BunnyCdnRegion = "de";
/** BunnyCDN Storage hostname for the default region (unprefixed). */
export const DEFAULT_STORAGE_HOST = "storage.bunnycdn.com";
/** Bunny.net management API host — used for purge. */
export const PURGE_API_HOST = "api.bunny.net";
/** Max key length (chars). */
export const MAX_KEY_LENGTH = 2048;
export const DEFAULT_RETRY_MAX_ATTEMPTS = 3;
export const DEFAULT_RETRY_BASE_DELAY_MS = 100;
export const DEFAULT_RETRY_MAX_DELAY_MS = 30_000;
export const RETRY_AFTER_MAX_DELAY_MS = 60_000;
/** Transient HTTP statuses outside the 5xx range. */
export const TRANSIENT_HTTP_STATUSES: ReadonlySet<number> = new Set([
408, 425, 429,
]);
/**
* Resolve the regional storage host. `de` is the unprefixed origin
* (`storage.bunnycdn.com`); every other region prepends the code.
*/
export function storageHostFor(region: BunnyCdnRegion): string {
return region === "de"
? DEFAULT_STORAGE_HOST
: `${region}.${DEFAULT_STORAGE_HOST}`;
}

View File

@@ -0,0 +1,14 @@
export type { BunnyCdnRegion } from "./constants.js";
export {
BUNNYCDN_REGIONS,
DEFAULT_REGION,
DEFAULT_RETRY_BASE_DELAY_MS,
DEFAULT_RETRY_MAX_ATTEMPTS,
DEFAULT_RETRY_MAX_DELAY_MS,
DEFAULT_STORAGE_HOST,
MAX_KEY_LENGTH,
PURGE_API_HOST,
RETRY_AFTER_MAX_DELAY_MS,
storageHostFor,
TRANSIENT_HTTP_STATUSES,
} from "./constants.js";

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,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "./error-codes.js";
describe("STORAGE_CDN_BUNNYCDN_CODES", () => {
it("contains every documented code with the STORAGE_CDN_BUNNYCDN. prefix", {
timeout: 5000,
}, () => {
const expected = [
"STORAGE_AUTH_FAILED",
"PURGE_AUTH_FAILED",
"ZONE_NOT_FOUND",
"TOKEN_AUTH_KEY_MISSING",
"PURGE_API_KEY_MISSING",
"INVALID_REGION",
"INVALID_OPTIONS",
"UPSTREAM_ERROR",
] as const;
for (const key of expected) {
expect(STORAGE_CDN_BUNNYCDN_CODES[key]).toBe(
`STORAGE_CDN_BUNNYCDN.${key}`
);
}
});
});
describe("StorageCdnBunnycdnError", () => {
it("carries key/zone/region/httpStatus discriminants", {
timeout: 5000,
}, () => {
const err = new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED,
"no",
{
key: "k",
zone: "z",
region: "de",
httpStatus: 401,
}
);
expect(err.key).toBe("k");
expect(err.zone).toBe("z");
expect(err.region).toBe("de");
expect(err.httpStatus).toBe(401);
expect(err.code).toBe(STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED);
expect(err.name).toBe("StorageCdnBunnycdnError");
});
it("leaves discriminants undefined when options are omitted", {
timeout: 5000,
}, () => {
const err = new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"boom"
);
expect(err.key).toBeUndefined();
expect(err.zone).toBeUndefined();
expect(err.region).toBeUndefined();
expect(err.httpStatus).toBeUndefined();
});
});

View File

@@ -0,0 +1,52 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const STORAGE_CDN_BUNNYCDN_CODES = defineCodes("STORAGE_CDN_BUNNYCDN", [
"STORAGE_AUTH_FAILED",
"PURGE_AUTH_FAILED",
"ZONE_NOT_FOUND",
"TOKEN_AUTH_KEY_MISSING",
"PURGE_API_KEY_MISSING",
"INVALID_REGION",
"INVALID_OPTIONS",
"UPSTREAM_ERROR",
] as const);
export type StorageCdnBunnycdnErrorCode =
(typeof STORAGE_CDN_BUNNYCDN_CODES)[keyof typeof STORAGE_CDN_BUNNYCDN_CODES];
export interface StorageCdnBunnycdnErrorOptions extends SuaveplanErrorOptions {
key?: string;
zone?: string;
region?: string;
httpStatus?: number;
}
/**
* Structured error for BunnyCDN Storage + Pull Zone operations.
*
* Carries optional `key`, `zone`, `region`, and `httpStatus` discriminants
* so callers can correlate failures without parsing message text.
*/
export class StorageCdnBunnycdnError extends SuaveplanError<StorageCdnBunnycdnErrorCode> {
public readonly key: string | undefined;
public readonly zone: string | undefined;
public readonly region: string | undefined;
public readonly httpStatus: number | undefined;
public constructor(
code: StorageCdnBunnycdnErrorCode,
message: string,
options?: StorageCdnBunnycdnErrorOptions
) {
super(code, message, options);
this.key = options?.key;
this.zone = options?.zone;
this.region = options?.region;
this.httpStatus = options?.httpStatus;
this.name = "StorageCdnBunnycdnError";
}
}

View File

@@ -0,0 +1,8 @@
export type {
StorageCdnBunnycdnErrorCode,
StorageCdnBunnycdnErrorOptions,
} from "./error-codes.js";
export {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "./error-codes.js";

View File

@@ -0,0 +1,80 @@
// Side-effect import so subpath consumers still see the STORAGE_CDN_BUNNYCDN.* registry.
import "./error-codes/index.js";
export {
BunnyCdnAdapter,
BunnyCdnAdapterBase,
buildReadStream,
buildWriteStream,
consumeHead,
createBunnyCdnAdapter,
outcomeFromError,
parseMetadata,
type ResolvedConfig,
resolveConfig,
resolveHeaders,
throwIfAborted,
type WriteStreamArgs,
} from "./bunny-cdn-adapter/index.js";
export {
BUNNYCDN_REGIONS,
type BunnyCdnRegion,
DEFAULT_REGION,
DEFAULT_RETRY_BASE_DELAY_MS,
DEFAULT_RETRY_MAX_ATTEMPTS,
DEFAULT_RETRY_MAX_DELAY_MS,
DEFAULT_STORAGE_HOST,
MAX_KEY_LENGTH,
PURGE_API_HOST,
RETRY_AFTER_MAX_DELAY_MS,
storageHostFor,
TRANSIENT_HTTP_STATUSES,
} from "./constants/index.js";
export {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
type StorageCdnBunnycdnErrorCode,
type StorageCdnBunnycdnErrorOptions,
} from "./error-codes/index.js";
export { encodeKey, sanitizeKey } from "./key-sanitizer/key-sanitizer.js";
export {
type BunnyInstruments,
type BunnyOp,
type BunnyOutcome,
getInstruments,
type RecordOpInput,
recordOp,
recordRangeFallback,
resetInstruments,
setBunnyMeterProvider,
} from "./otel/otel.js";
export {
type PullZoneCtx,
purgeUrl,
} from "./pull-zone-client/pull-zone-client.js";
export { registerBunnyCdnProvider } from "./registry/registry.js";
export {
isTransientStatus,
parseRetryAfter,
type RetryRunOptions,
retryWithBackoff,
} from "./retry-helper/retry-helper.js";
export {
buildStorageUrl,
type StorageClientCtx,
storageDelete,
storageGet,
storageHead,
storagePartial,
storagePut,
storageStream,
} from "./storage-client/storage-client.js";
export {
buildTokenAuthQuery,
type TokenAuthInput,
} from "./token-auth/token-auth.js";
export { BunnyCdnAdapterOptionsSchema } from "./types/adapter.schemas.js";
export type {
BunnyCdnAdapterOptions,
BunnyRetryOptions,
} from "./types/adapter.types.js";

View File

@@ -0,0 +1,4 @@
export {
encodeKey,
sanitizeKey,
} from "./key-sanitizer.js";

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,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { encodeKey, sanitizeKey } from "./key-sanitizer.js";
describe("sanitizeKey", () => {
it("rejects empty / non-string keys", { timeout: 5000 }, () => {
expect(() => sanitizeKey("")).toThrow(StorageCdnBunnycdnError);
expect(() => sanitizeKey(42 as unknown as string)).toThrow(
StorageCdnBunnycdnError
);
});
it("rejects overlong keys with INVALID_OPTIONS", { timeout: 5000 }, () => {
try {
sanitizeKey("a".repeat(2049));
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS
);
}
});
it("rejects traversal", { timeout: 5000 }, () => {
expect(() => sanitizeKey("../etc/passwd")).toThrow(
StorageCdnBunnycdnError
);
expect(() => sanitizeKey("a/../b")).toThrow(StorageCdnBunnycdnError);
});
it("rejects absolute paths", { timeout: 5000 }, () => {
expect(() => sanitizeKey("/abs")).toThrow(StorageCdnBunnycdnError);
expect(() => sanitizeKey("C:\\x")).toThrow(StorageCdnBunnycdnError);
});
it("rejects control characters", { timeout: 5000 }, () => {
expect(() => sanitizeKey("a\x00b")).toThrow(StorageCdnBunnycdnError);
});
it("normalises Windows separators", { timeout: 5000 }, () => {
expect(sanitizeKey("a\\b\\c")).toBe("a/b/c");
});
it("accepts a simple POSIX path", { timeout: 5000 }, () => {
expect(sanitizeKey("a/b/c.bin")).toBe("a/b/c.bin");
});
});
describe("encodeKey", () => {
it("encodes each segment, preserves slashes", { timeout: 5000 }, () => {
expect(encodeKey("hello world/file.txt")).toBe(
"hello%20world/file.txt"
);
});
});

View File

@@ -0,0 +1,61 @@
import { MAX_KEY_LENGTH } from "../constants/constants.js";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:[\\/]/;
function containsControlChar(value: string): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Validate and normalise a caller-supplied key. Throws
* `STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS` on rejection.
*
* Normalises Windows `\` → POSIX `/`.
*/
export function sanitizeKey(key: string): string {
if (typeof key !== "string" || key.length === 0) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS,
"key must be a non-empty string"
);
}
if (key.length > MAX_KEY_LENGTH) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS,
`key length ${key.length} exceeds maximum ${MAX_KEY_LENGTH}`
);
}
if (
containsControlChar(key) ||
key.startsWith("/") ||
WINDOWS_DRIVE_PREFIX.test(key)
) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS,
"key contains a control char, absolute path, or drive-letter prefix"
);
}
const normalised = key.replace(/\\/g, "/");
for (const segment of normalised.split("/")) {
if (segment === "..") {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.INVALID_OPTIONS,
"path traversal segment (..) is not allowed"
);
}
}
return normalised;
}
/** Per-segment URL encoder, preserving `/`. */
export function encodeKey(key: string): string {
return key.split("/").map(encodeURIComponent).join("/");
}

View File

@@ -0,0 +1,13 @@
export type {
BunnyInstruments,
BunnyOp,
BunnyOutcome,
RecordOpInput,
} from "./otel.js";
export {
getInstruments,
recordOp,
recordRangeFallback,
resetInstruments,
setBunnyMeterProvider,
} from "./otel.js";

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,311 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getInstruments,
recordOp,
recordRangeFallback,
resetInstruments,
setBunnyMeterProvider,
} from "./otel.js";
interface RecHist {
name: string;
samples: { value: number; attrs: Record<string, unknown> | undefined }[];
}
interface RecCtr {
name: string;
additions: { value: number; attrs: Record<string, unknown> | undefined }[];
}
class StubHist implements Histogram {
public entry: RecHist;
public constructor(
public name: string,
bag: RecHist[]
) {
this.entry = { name, samples: [] };
bag.push(this.entry);
}
public record(v: number, a?: Record<string, unknown>): void {
this.entry.samples.push({ value: v, attrs: a });
}
}
class StubCtr implements Counter {
public entry: RecCtr;
public constructor(
public name: string,
bag: RecCtr[]
) {
this.entry = { name, additions: [] };
bag.push(this.entry);
}
public add(v: number, a?: Record<string, unknown>): void {
this.entry.additions.push({ value: v, attrs: a });
}
}
class StubMeter implements Meter {
public histograms: RecHist[] = [];
public counters: RecCtr[] = [];
public createHistogram(name: string): Histogram {
return new StubHist(name, this.histograms);
}
public createCounter(name: string): Counter {
return new StubCtr(name, this.counters);
}
public createObservableGauge(): never {
throw new Error("unused");
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubProvider implements MeterProvider {
public meters: StubMeter[] = [];
public getMeter(): Meter {
const m = new StubMeter();
this.meters.push(m);
return m;
}
}
describe("bunnycdn otel", () => {
let provider: StubProvider;
beforeEach(() => {
provider = new StubProvider();
setBunnyMeterProvider(provider);
});
afterEach(() => {
setBunnyMeterProvider(undefined);
resetInstruments();
});
const find = <K extends "counters" | "histograms">(
kind: K,
name: string
) => {
return provider.meters[0]?.[kind].find((x) => x.name === name);
};
it("set OK emits writes_total with provider and storage_region", {
timeout: 5000,
}, () => {
recordOp({
op: "set",
outcome: "ok",
durationMs: 5,
storageRegion: "de",
bytes: 10,
direction: "write",
});
expect(
find("counters", "storage_cdn_bunnycdn.writes_total")?.additions[0]
?.attrs
).toEqual({
op: "set",
outcome: "ok",
provider: "bunnycdn",
storage_region: "de",
});
expect(
find("histograms", "storage_cdn_bunnycdn.io_bytes")?.samples[0]
?.attrs
).toEqual({
op: "set",
direction: "write",
provider: "bunnycdn",
storage_region: "de",
});
});
it("get OK emits reads_total", { timeout: 5000 }, () => {
recordOp({
op: "get",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.reads_total")?.additions
).toHaveLength(1);
});
it("getPartial/getWithMetadata also count as reads", {
timeout: 5000,
}, () => {
recordOp({
op: "getPartial",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
recordOp({
op: "getWithMetadata",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.reads_total")?.additions
).toHaveLength(2);
});
it("delete/clear OK emit deletes_total", { timeout: 5000 }, () => {
recordOp({
op: "delete",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
recordOp({
op: "clear",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.deletes_total")?.additions
).toHaveLength(2);
});
it("purge / purgePrefix emit purges_total", { timeout: 5000 }, () => {
recordOp({
op: "purge",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
recordOp({
op: "purgePrefix",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.purges_total")?.additions
).toHaveLength(2);
});
it("signUrl emits signed_urls_total", { timeout: 5000 }, () => {
recordOp({
op: "signUrl",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.signed_urls_total")
?.additions
).toHaveLength(1);
});
it("non-ok emits errors_total", { timeout: 5000 }, () => {
recordOp({
op: "get",
outcome: "upstream_error",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.errors_total")?.additions[0]
?.attrs
).toMatchObject({
provider: "bunnycdn",
outcome: "upstream_error",
});
});
it("setPartial emits writes_total", { timeout: 5000 }, () => {
recordOp({
op: "setPartial",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.writes_total")?.additions
).toHaveLength(1);
});
it("recordRangeFallback increments dedicated counter", {
timeout: 5000,
}, () => {
recordRangeFallback("de");
expect(
find("counters", "storage_cdn_bunnycdn.range_fallback_total")
?.additions
).toHaveLength(1);
});
it("getInstruments caches and force=true rebuilds", {
timeout: 5000,
}, () => {
const a = getInstruments();
expect(getInstruments()).toBe(a);
expect(getInstruments(true)).not.toBe(a);
});
it("omits io_bytes when bytes is undefined", { timeout: 5000 }, () => {
recordOp({
op: "exists",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("histograms", "storage_cdn_bunnycdn.io_bytes")?.samples ?? []
).toHaveLength(0);
});
it("exists ok does not increment reads/writes/deletes/purges/signed", {
timeout: 5000,
}, () => {
recordOp({
op: "exists",
outcome: "ok",
durationMs: 1,
storageRegion: "de",
});
expect(
find("counters", "storage_cdn_bunnycdn.reads_total")?.additions ??
[]
).toHaveLength(0);
expect(
find("counters", "storage_cdn_bunnycdn.writes_total")?.additions ??
[]
).toHaveLength(0);
expect(
find("counters", "storage_cdn_bunnycdn.deletes_total")?.additions ??
[]
).toHaveLength(0);
expect(
find("counters", "storage_cdn_bunnycdn.purges_total")?.additions ??
[]
).toHaveLength(0);
expect(
find("counters", "storage_cdn_bunnycdn.signed_urls_total")
?.additions ?? []
).toHaveLength(0);
});
});

View File

@@ -0,0 +1,148 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
const METER_NAME = "@suaveplan/storage-cdn-bunnycdn";
const TRACER_NAME = "@suaveplan/storage-cdn-bunnycdn";
let overrideMeterProvider: MeterProvider | undefined;
export function setBunnyMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cached = undefined;
}
export type BunnyOp =
| "get"
| "set"
| "delete"
| "exists"
| "list"
| "clear"
| "getMetadata"
| "getWithMetadata"
| "getSize"
| "getPartial"
| "setPartial"
| "getItemSize"
| "createReadStream"
| "createWriteStream"
| "purge"
| "purgePrefix"
| "signUrl";
export type BunnyOutcome =
| "ok"
| "not_found"
| "auth_failed"
| "upstream_error"
| "aborted";
export interface BunnyInstruments {
tracer: Tracer;
meter: Meter;
readsTotal: Counter;
writesTotal: Counter;
deletesTotal: Counter;
purgesTotal: Counter;
signedUrlsTotal: Counter;
errorsTotal: Counter;
rangeFallbackTotal: Counter;
opDurationMs: Histogram;
ioBytes: Histogram;
}
let cached: BunnyInstruments | undefined;
export function getInstruments(force = false): BunnyInstruments {
if (cached !== undefined && !force) return cached;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cached = {
tracer,
meter,
readsTotal: meter.createCounter("storage_cdn_bunnycdn.reads_total"),
writesTotal: meter.createCounter("storage_cdn_bunnycdn.writes_total"),
deletesTotal: meter.createCounter("storage_cdn_bunnycdn.deletes_total"),
purgesTotal: meter.createCounter("storage_cdn_bunnycdn.purges_total"),
signedUrlsTotal: meter.createCounter(
"storage_cdn_bunnycdn.signed_urls_total"
),
errorsTotal: meter.createCounter("storage_cdn_bunnycdn.errors_total"),
rangeFallbackTotal: meter.createCounter(
"storage_cdn_bunnycdn.range_fallback_total"
),
opDurationMs: meter.createHistogram(
"storage_cdn_bunnycdn.op_duration_ms",
{ unit: "ms" }
),
ioBytes: meter.createHistogram("storage_cdn_bunnycdn.io_bytes", {
unit: "By",
}),
};
return cached;
}
export interface RecordOpInput {
op: BunnyOp;
outcome: BunnyOutcome;
durationMs: number;
storageRegion: string;
bytes?: number;
direction?: "read" | "write";
}
export function recordOp(input: RecordOpInput): void {
const { op, outcome, durationMs, storageRegion, bytes, direction } = input;
const inst = getInstruments();
const baseAttrs = {
op,
outcome,
provider: "bunnycdn",
storage_region: storageRegion,
};
inst.opDurationMs.record(durationMs, baseAttrs);
if (outcome === "ok") {
if (op === "get" || op === "getPartial" || op === "getWithMetadata") {
inst.readsTotal.add(1, baseAttrs);
} else if (op === "set" || op === "setPartial") {
inst.writesTotal.add(1, baseAttrs);
} else if (op === "delete" || op === "clear") {
inst.deletesTotal.add(1, baseAttrs);
} else if (op === "purge" || op === "purgePrefix") {
inst.purgesTotal.add(1, baseAttrs);
} else if (op === "signUrl") {
inst.signedUrlsTotal.add(1, baseAttrs);
}
} else {
inst.errorsTotal.add(1, baseAttrs);
}
if (bytes !== undefined && direction !== undefined) {
inst.ioBytes.record(bytes, {
op,
direction,
provider: "bunnycdn",
storage_region: storageRegion,
});
}
}
export function recordRangeFallback(storageRegion: string): void {
getInstruments().rangeFallbackTotal.add(1, {
outcome: "ok",
provider: "bunnycdn",
storage_region: storageRegion,
});
}
export function resetInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,2 @@
export type { PullZoneCtx } from "./pull-zone-client.js";
export { purgeUrl } from "./pull-zone-client.js";

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,115 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_BUNNYCDN_CODES,
type StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { purgeUrl } from "./pull-zone-client.js";
const baseCtx = {
purgeApiKey: "key-123",
pullZoneHostname: "mycdn.b-cdn.net",
zone: "myz",
region: "de",
};
describe("purgeUrl", () => {
it("single URL purge resolves on 200", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 200 }));
await purgeUrl(
{ fetch: fetchFn, ...baseCtx },
"https://mycdn.b-cdn.net/a.png"
);
expect(fetchFn).toHaveBeenCalledTimes(1);
const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit];
expect(url).toMatch(/^https:\/\/api\.bunny\.net\/purge\?url=/);
expect((init.headers as Record<string, string>).AccessKey).toBe(
"key-123"
);
});
it("missing purge key throws PURGE_API_KEY_MISSING", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn();
try {
await purgeUrl(
{
fetch: fetchFn as unknown as typeof globalThis.fetch,
...baseCtx,
purgeApiKey: undefined,
},
"https://mycdn.b-cdn.net/a.png"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.PURGE_API_KEY_MISSING
);
}
expect(fetchFn).not.toHaveBeenCalled();
});
it("empty purge key throws PURGE_API_KEY_MISSING", {
timeout: 5000,
}, async () => {
try {
await purgeUrl(
{
fetch: async () => new Response(),
...baseCtx,
purgeApiKey: "",
},
"https://mycdn.b-cdn.net/a.png"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.PURGE_API_KEY_MISSING
);
}
});
it("401 surfaces PURGE_AUTH_FAILED", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 401 }));
try {
await purgeUrl(
{ fetch: fetchFn, ...baseCtx },
"https://mycdn.b-cdn.net/a.png"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED
);
}
});
it("other non-ok surfaces UPSTREAM_ERROR", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
try {
await purgeUrl(
{ fetch: fetchFn, ...baseCtx },
"https://mycdn.b-cdn.net/a.png"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("threads AbortSignal into the fetch init", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 200 }));
const c = new AbortController();
await purgeUrl(
{ fetch: fetchFn, ...baseCtx },
"https://x/a.png",
c.signal
);
const init = fetchFn.mock.calls[0]?.[1] as RequestInit;
expect(init.signal).toBe(c.signal);
});
});

View File

@@ -0,0 +1,57 @@
import type { FetchLike } from "@suaveplan/types";
import { PURGE_API_HOST } from "../constants/constants.js";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
export interface PullZoneCtx {
fetch: FetchLike;
purgeApiKey: string | undefined;
pullZoneHostname: string;
zone: string;
region: string;
}
function requireKey(ctx: PullZoneCtx): string {
if (ctx.purgeApiKey === undefined || ctx.purgeApiKey.length === 0) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.PURGE_API_KEY_MISSING,
"purgeApiKey is required for cache invalidation",
{ zone: ctx.zone, region: ctx.region }
);
}
return ctx.purgeApiKey;
}
/**
* Purge one URL via `POST https://api.bunny.net/purge?url=<encodedUrl>`.
* Caller composes the edge URL (e.g. `https://<pullZoneHostname>/<key>`).
*/
export async function purgeUrl(
ctx: PullZoneCtx,
edgeUrl: string,
signal?: AbortSignal
): Promise<void> {
const accessKey = requireKey(ctx);
const url = `https://${PURGE_API_HOST}/purge?url=${encodeURIComponent(edgeUrl)}`;
const init: RequestInit = {
method: "POST",
headers: { AccessKey: accessKey },
};
if (signal !== undefined) init.signal = signal;
const response = await ctx.fetch(url, init);
if (response.ok) return;
if (response.status === 401 || response.status === 403) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED,
`Bunny.net purge auth failed (${response.status})`,
{ zone: ctx.zone, region: ctx.region, httpStatus: response.status }
);
}
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
`Bunny.net purge upstream ${response.status}`,
{ zone: ctx.zone, region: ctx.region, httpStatus: response.status }
);
}

View File

@@ -0,0 +1 @@
export { registerBunnyCdnProvider } from "./registry.js";

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,42 @@
import {
clearStorageProviders,
createStorage,
hasStorageProvider,
} from "@suaveplan/storage/factory";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { BunnyCdnAdapter } from "../bunny-cdn-adapter/bunny-cdn-adapter.js";
import { registerBunnyCdnProvider } from "./registry.js";
const config = {
storageZone: "myz",
storageAccessKey: "ak",
pullZoneHostname: "mycdn.b-cdn.net",
} as const;
describe("registerBunnyCdnProvider", () => {
beforeEach(() => clearStorageProviders());
afterEach(() => clearStorageProviders());
it("createStorage with type bunnycdn returns BunnyCdnAdapter", {
timeout: 5000,
}, () => {
registerBunnyCdnProvider();
const storage = createStorage({ type: "bunnycdn", config });
expect(storage).toBeInstanceOf(BunnyCdnAdapter);
});
it("registerBunnyCdnProvider is idempotent", { timeout: 5000 }, () => {
registerBunnyCdnProvider();
registerBunnyCdnProvider();
expect(hasStorageProvider("bunnycdn")).toBe(true);
expect(createStorage({ type: "bunnycdn", config })).toBeInstanceOf(
BunnyCdnAdapter
);
});
});

View File

@@ -0,0 +1,30 @@
import {
hasStorageProvider,
registerStorageProvider,
} from "@suaveplan/storage/factory";
import { BunnyCdnAdapter } from "../bunny-cdn-adapter/bunny-cdn-adapter.js";
import type { BunnyCdnAdapterOptions } from "../types/adapter.types.js";
declare module "@suaveplan/storage/factory" {
interface StorageProviderRegistry {
bunnycdn: BunnyCdnAdapter;
}
interface StorageProviderConfig {
bunnycdn: BunnyCdnAdapterOptions;
}
}
/**
* Register `BunnyCdnAdapter` against the `@suaveplan/storage` factory
* under the canonical type slot `"bunnycdn"`. Idempotent — repeated calls
* do nothing once the slot is occupied.
*/
export function registerBunnyCdnProvider(): void {
if (hasStorageProvider("bunnycdn")) return;
registerStorageProvider(
"bunnycdn",
BunnyCdnAdapter as unknown as new (
config?: unknown
) => BunnyCdnAdapter
);
}

View File

@@ -0,0 +1,6 @@
export type { RetryRunOptions } from "./retry-helper.js";
export {
isTransientStatus,
parseRetryAfter,
retryWithBackoff,
} from "./retry-helper.js";

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,263 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import {
STORAGE_CDN_BUNNYCDN_CODES,
type StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import {
isTransientStatus,
parseRetryAfter,
retryWithBackoff,
} from "./retry-helper.js";
function controlledTimers(): {
timers: TimersLike;
scheduled: { ms: number }[];
flushNext(): void;
} {
const pending: { fn: () => void; ms: number; id: number }[] = [];
const scheduled: { ms: number }[] = [];
let nextId = 1;
return {
timers: {
setTimer: (h, ms) => {
const id = nextId++;
pending.push({ fn: h, ms, id });
scheduled.push({ ms });
return id;
},
clearTimer: (id) => {
const idx = pending.findIndex((p) => p.id === id);
if (idx >= 0) pending.splice(idx, 1);
},
},
scheduled,
flushNext(): void {
const next = pending.shift();
if (next !== undefined) next.fn();
},
};
}
describe("isTransientStatus", () => {
it("classifies transient codes", { timeout: 5000 }, () => {
expect(isTransientStatus(408)).toBe(true);
expect(isTransientStatus(425)).toBe(true);
expect(isTransientStatus(429)).toBe(true);
expect(isTransientStatus(503)).toBe(true);
expect(isTransientStatus(401)).toBe(false);
expect(isTransientStatus(200)).toBe(false);
expect(isTransientStatus(600)).toBe(false);
});
});
describe("parseRetryAfter", () => {
it("parses delta-seconds and caps", { timeout: 5000 }, () => {
expect(parseRetryAfter("2", 0)).toBe(2000);
expect(parseRetryAfter("3600", 0)).toBe(60_000);
});
it("returns undefined for unparseable / empty / null", {
timeout: 5000,
}, () => {
expect(parseRetryAfter(null, 0)).toBeUndefined();
expect(parseRetryAfter("", 0)).toBeUndefined();
expect(parseRetryAfter("garbage", 0)).toBeUndefined();
});
it("parses HTTP date and clamps past dates", { timeout: 5000 }, () => {
const now = Date.UTC(2026, 0, 1);
expect(
parseRetryAfter(new Date(now + 4000).toUTCString(), now)
).toBeLessThanOrEqual(4000);
expect(parseRetryAfter(new Date(now - 4000).toUTCString(), now)).toBe(
0
);
});
it("caps HTTP date deltas on the high side", { timeout: 5000 }, () => {
const now = Date.UTC(2026, 0, 1);
expect(
parseRetryAfter(new Date(now + 999_999_999).toUTCString(), now)
).toBe(60_000);
});
});
describe("retryWithBackoff", () => {
it("retries 503 honouring Retry-After", { timeout: 5000 }, async () => {
const { timers, scheduled, flushNext } = controlledTimers();
const fetchFn = vi.fn(async () => {
if (fetchFn.mock.calls.length === 1) {
return new Response(null, {
status: 503,
headers: { "retry-after": "2" },
});
}
return new Response(null, { status: 200 });
});
const promise = retryWithBackoff(() => fetchFn(), {
retry: { maxAttempts: 3, baseDelayMs: 1 },
timers,
});
await Promise.resolve();
flushNext();
const r = await promise;
expect(r.status).toBe(200);
expect(scheduled[0]?.ms).toBeGreaterThanOrEqual(2000);
});
it("does not retry 401", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 401 }));
const r = await retryWithBackoff(() => fetchFn(), {
retry: { maxAttempts: 5 },
});
expect(r.status).toBe(401);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("exhausts on persistent 5xx", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
const r = await retryWithBackoff(() => fetchFn(), {
retry: {
maxAttempts: 3,
baseDelayMs: 1,
maxDelayMs: 2,
honourRetryAfter: false,
},
});
expect(r.status).toBe(500);
expect(fetchFn).toHaveBeenCalledTimes(3);
});
it("pre-aborted signal short-circuits", { timeout: 5000 }, async () => {
const c = new AbortController();
c.abort();
const fetchFn = vi.fn();
try {
await retryWithBackoff(() => fetchFn(), { signal: c.signal });
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
expect(fetchFn).not.toHaveBeenCalled();
}
});
it("uses defaultTimers when not supplied", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => {
if (fetchFn.mock.calls.length === 1)
return new Response(null, { status: 503 });
return new Response(null, { status: 200 });
});
const r = await retryWithBackoff(() => fetchFn(), {
retry: {
maxAttempts: 3,
baseDelayMs: 1,
maxDelayMs: 1,
honourRetryAfter: false,
},
});
expect(r.status).toBe(200);
});
it("works when called without options", { timeout: 5000 }, async () => {
const r = await retryWithBackoff(
async () => new Response(null, { status: 200 })
);
expect(r.status).toBe(200);
});
it("mid-wait abort surfaces UPSTREAM_ERROR (default timers)", {
timeout: 5000,
}, async () => {
const c = new AbortController();
const fetchFn = vi.fn(async () => {
if (fetchFn.mock.calls.length === 1) {
globalThis.setTimeout(() => c.abort(), 5);
return new Response(null, { status: 503 });
}
return new Response(null, { status: 200 });
});
try {
await retryWithBackoff(() => fetchFn(), {
retry: {
maxAttempts: 3,
baseDelayMs: 500,
honourRetryAfter: false,
},
signal: c.signal,
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("pre-aborted signal inside sleep raises UPSTREAM_ERROR", {
timeout: 5000,
}, async () => {
const { timers } = controlledTimers();
const c = new AbortController();
const fetchFn = vi.fn(async () => {
c.abort();
return new Response(null, { status: 503 });
});
try {
await retryWithBackoff(() => fetchFn(), {
retry: {
maxAttempts: 3,
baseDelayMs: 100,
honourRetryAfter: false,
},
timers,
signal: c.signal,
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("falls back to exponential backoff when Retry-After is unparseable", {
timeout: 5000,
}, async () => {
const { timers, scheduled, flushNext } = controlledTimers();
const fetchFn = vi.fn(async () => {
if (fetchFn.mock.calls.length === 1) {
return new Response(null, {
status: 503,
headers: { "retry-after": "garbage" },
});
}
return new Response(null, { status: 200 });
});
const promise = retryWithBackoff(() => fetchFn(), {
retry: { maxAttempts: 3, baseDelayMs: 7 },
timers,
});
await Promise.resolve();
flushNext();
await promise;
expect(scheduled[0]?.ms).toBe(7);
});
it("synthesises UPSTREAM_ERROR when maxAttempts is 0", {
timeout: 5000,
}, async () => {
try {
await retryWithBackoff(
async () => new Response(null, { status: 503 }),
{
retry: { maxAttempts: 0 },
}
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
});

View File

@@ -0,0 +1,148 @@
import type { TimersLike } from "@suaveplan/types";
import {
DEFAULT_RETRY_BASE_DELAY_MS,
DEFAULT_RETRY_MAX_ATTEMPTS,
DEFAULT_RETRY_MAX_DELAY_MS,
RETRY_AFTER_MAX_DELAY_MS,
TRANSIENT_HTTP_STATUSES,
} from "../constants/constants.js";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import type { BunnyRetryOptions } from "../types/adapter.types.js";
export function isTransientStatus(status: number): boolean {
if (TRANSIENT_HTTP_STATUSES.has(status)) return true;
return status >= 500 && status <= 599;
}
export function parseRetryAfter(
value: string | null,
now: number
): number | undefined {
if (value === null) return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
if (/^\d+$/.test(trimmed)) {
const seconds = Number.parseInt(trimmed, 10);
return Math.min(seconds * 1000, RETRY_AFTER_MAX_DELAY_MS);
}
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed)) return undefined;
const diff = parsed - now;
if (diff <= 0) return 0;
return Math.min(diff, RETRY_AFTER_MAX_DELAY_MS);
}
export interface RetryRunOptions {
retry?: BunnyRetryOptions;
timers?: TimersLike;
signal?: AbortSignal;
}
/**
* Retry an HTTP-shaped `fn` up to `maxAttempts` times, classifying status
* codes as transient via `isTransientStatus`, honouring `Retry-After`.
*/
export async function retryWithBackoff(
fn: () => Promise<Response>,
options: RetryRunOptions = {}
): Promise<Response> {
const maxAttempts =
options.retry?.maxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS;
const baseDelayMs =
options.retry?.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
const maxDelayMs = options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;
const honourRetryAfter = options.retry?.honourRetryAfter ?? true;
const timers = options.timers ?? defaultTimers();
let lastResponse: Response | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
throwIfAborted(options.signal);
const response = await fn();
if (response.ok || !isTransientStatus(response.status)) return response;
lastResponse = response;
if (attempt === maxAttempts) break;
const waitMs = computeDelay(
attempt,
baseDelayMs,
maxDelayMs,
honourRetryAfter ? response : undefined
);
await sleep(waitMs, timers, options.signal);
}
if (lastResponse !== undefined) return lastResponse;
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"retry exhausted with no response"
);
}
function defaultTimers(): TimersLike {
return {
setTimer: (handler, ms) => Number(globalThis.setTimeout(handler, ms)),
clearTimer: (id) => globalThis.clearTimeout(id as number),
};
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"Operation aborted",
{ cause: signal.reason as Error | undefined }
);
}
}
function computeDelay(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
lastResponse: Response | undefined
): number {
if (lastResponse !== undefined) {
const retryAfter = parseRetryAfter(
lastResponse.headers.get("retry-after"),
Date.now()
);
if (retryAfter !== undefined) return retryAfter;
}
const exponential = baseDelayMs * 2 ** (attempt - 1);
return Math.min(exponential, maxDelayMs);
}
function sleep(
ms: number,
timers: TimersLike,
signal: AbortSignal | undefined
): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve, reject) => {
let cleanup: (() => void) | undefined;
const id = timers.setTimer(() => {
cleanup?.();
resolve();
}, ms);
if (signal !== undefined) {
const onAbort = (): void => {
timers.clearTimer(id);
cleanup?.();
reject(
new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
"Operation aborted"
)
);
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
cleanup = () => signal.removeEventListener("abort", onAbort);
}
});
}

View File

@@ -0,0 +1,10 @@
export type { StorageClientCtx } from "./storage-client.js";
export {
buildStorageUrl,
storageDelete,
storageGet,
storageHead,
storagePartial,
storagePut,
storageStream,
} from "./storage-client.js";

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,221 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import {
storageDelete,
storageGet,
storageHead,
storagePartial,
storagePut,
storageStream,
} from "./storage-client.js";
const baseCtx = {
accessKey: "key-123",
zone: "myz",
region: "de" as const,
retry: {},
signal: undefined,
headers: {},
};
function ctx(fetchFn: FetchLike) {
return { fetch: fetchFn, ...baseCtx };
}
describe("buildStorageUrl + storage ops", () => {
it("de region uses unprefixed storage host", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 201 }));
await storagePut(
ctx(fetchFn),
"a/b.bin",
new Uint8Array([1]),
"application/octet-stream"
);
const url = fetchFn.mock.calls[0]?.[0] as string;
expect(url).toBe("https://storage.bunnycdn.com/myz/a/b.bin");
});
it("ny region prefixes the host", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 201 }));
await storagePut(
{ ...ctx(fetchFn), region: "ny" },
"a/b.bin",
new Uint8Array([1]),
"application/octet-stream"
);
const url = fetchFn.mock.calls[0]?.[0] as string;
expect(url).toBe("https://ny.storage.bunnycdn.com/myz/a/b.bin");
});
it("GET round-trips body bytes", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(
async () => new Response(new Uint8Array([1, 2, 3]))
);
const body = await storageGet(ctx(fetchFn), "k");
expect(Array.from(body as Uint8Array)).toEqual([1, 2, 3]);
});
it("GET 404 returns undefined", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 404 }));
expect(await storageGet(ctx(fetchFn), "missing")).toBeUndefined();
});
it("401 surfaces STORAGE_AUTH_FAILED", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 401 }));
try {
await storageGet(ctx(fetchFn), "k");
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED
);
}
});
it("AccessKey header is attached", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(new Uint8Array([1])));
await storageGet(ctx(fetchFn), "k");
const init = fetchFn.mock.calls[0]?.[1] as RequestInit;
expect((init.headers as Record<string, string>).AccessKey).toBe(
"key-123"
);
});
it("HEAD returns the raw response", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(
async () =>
new Response(null, {
status: 200,
headers: { "content-length": "42" },
})
);
const r = await storageHead(ctx(fetchFn), "k");
expect(r.headers.get("content-length")).toBe("42");
});
it("DELETE 200 returns true, 404 returns false", {
timeout: 5000,
}, async () => {
const ok = vi.fn(async () => new Response(null, { status: 200 }));
expect(await storageDelete(ctx(ok), "k")).toBe(true);
const missing = vi.fn(async () => new Response(null, { status: 404 }));
expect(await storageDelete(ctx(missing), "k")).toBe(false);
});
it("DELETE 500 surfaces UPSTREAM_ERROR after retry exhaustion", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
try {
await storageDelete(
{
...ctx(fetchFn),
retry: {
retry: { maxAttempts: 1, honourRetryAfter: false },
},
},
"k"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("Range request returns 206 body verbatim", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(
async () => new Response(new Uint8Array([2, 3]), { status: 206 })
);
const bytes = await storagePartial(ctx(fetchFn), "k", 1, 2);
expect(Array.from(bytes as Uint8Array)).toEqual([2, 3]);
});
it("Range 404 returns null", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 404 }));
expect(await storagePartial(ctx(fetchFn), "missing", 0, 1)).toBeNull();
});
it("Range 401 surfaces STORAGE_AUTH_FAILED", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 401 }));
try {
await storagePartial(ctx(fetchFn), "k", 0, 1);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED
);
}
});
it("Range 200 fallback slices client-side", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(
async () => new Response(new Uint8Array([10, 20, 30, 40]))
);
const bytes = await storagePartial(ctx(fetchFn), "k", 1, 2);
expect(Array.from(bytes as Uint8Array)).toEqual([20, 30]);
});
it("PUT non-ok surfaces UPSTREAM_ERROR after exhaustion", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
try {
await storagePut(
{
...ctx(fetchFn),
retry: {
retry: { maxAttempts: 1, honourRetryAfter: false },
},
},
"k",
new Uint8Array([1]),
"application/octet-stream"
);
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR
);
}
});
it("streaming GET returns the raw response", {
timeout: 5000,
}, async () => {
const fetchFn = vi.fn(
async () => new Response(new Uint8Array([1]), { status: 200 })
);
const r = await storageStream(ctx(fetchFn), "k");
expect(r.status).toBe(200);
});
it("streaming GET throws on non-ok", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(null, { status: 500 }));
try {
await storageStream(ctx(fetchFn), "k");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(StorageCdnBunnycdnError);
}
});
it("threads signal into every op", { timeout: 5000 }, async () => {
const fetchFn = vi.fn(async () => new Response(new Uint8Array([1])));
const c = new AbortController();
await storageGet({ ...ctx(fetchFn), signal: c.signal }, "k");
const init = fetchFn.mock.calls[0]?.[1] as RequestInit;
expect(init.signal).toBe(c.signal);
});
});

View File

@@ -0,0 +1,173 @@
import {
buildRangeHeader,
consumeRangeResponse,
} from "@suaveplan/storage-http/range";
import type { FetchLike } from "@suaveplan/types";
import { type BunnyCdnRegion, storageHostFor } from "../constants/constants.js";
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { encodeKey } from "../key-sanitizer/key-sanitizer.js";
import { recordRangeFallback } from "../otel/otel.js";
import {
type RetryRunOptions,
retryWithBackoff,
} from "../retry-helper/retry-helper.js";
export interface StorageClientCtx {
fetch: FetchLike;
accessKey: string;
zone: string;
region: BunnyCdnRegion;
retry: RetryRunOptions;
signal: AbortSignal | undefined;
headers: Record<string, string>;
}
/** Compose `https://<region-host>/<zone>/<encodedKey>` for one key. */
export function buildStorageUrl(ctx: StorageClientCtx, key: string): string {
return `https://${storageHostFor(ctx.region)}/${encodeURIComponent(ctx.zone)}/${encodeKey(key)}`;
}
function baseHeaders(ctx: StorageClientCtx): Record<string, string> {
return { ...ctx.headers, AccessKey: ctx.accessKey };
}
function errorForStatus(
status: number,
ctx: StorageClientCtx
): StorageCdnBunnycdnError {
const opts = { zone: ctx.zone, region: ctx.region, httpStatus: status };
if (status === 401 || status === 403) {
return new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED,
`Storage Zone auth failed (${status})`,
opts
);
}
return new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.UPSTREAM_ERROR,
`Storage Zone upstream ${status}`,
opts
);
}
function withSignal(init: RequestInit, ctx: StorageClientCtx): RequestInit {
if (ctx.signal !== undefined) init.signal = ctx.signal;
return init;
}
export async function storageGet(
ctx: StorageClientCtx,
key: string
): Promise<Uint8Array | undefined> {
const url = buildStorageUrl(ctx, key);
const response = await retryWithBackoff(
() =>
ctx.fetch(
url,
withSignal({ method: "GET", headers: baseHeaders(ctx) }, ctx)
),
ctx.retry
);
if (response.status === 404) return undefined;
if (!response.ok) throw errorForStatus(response.status, ctx);
return new Uint8Array(await response.arrayBuffer());
}
export async function storageHead(
ctx: StorageClientCtx,
key: string
): Promise<Response> {
const url = buildStorageUrl(ctx, key);
return retryWithBackoff(
() =>
ctx.fetch(
url,
withSignal({ method: "HEAD", headers: baseHeaders(ctx) }, ctx)
),
ctx.retry
);
}
export async function storagePut(
ctx: StorageClientCtx,
key: string,
body: Uint8Array,
contentType: string
): Promise<void> {
const url = buildStorageUrl(ctx, key);
const init: RequestInit = {
method: "PUT",
body,
headers: {
...baseHeaders(ctx),
"content-type": contentType,
"content-length": String(body.byteLength),
},
};
const response = await retryWithBackoff(
() => ctx.fetch(url, withSignal(init, ctx)),
ctx.retry
);
if (!response.ok) throw errorForStatus(response.status, ctx);
}
export async function storageDelete(
ctx: StorageClientCtx,
key: string
): Promise<boolean> {
const url = buildStorageUrl(ctx, key);
const response = await retryWithBackoff(
() =>
ctx.fetch(
url,
withSignal({ method: "DELETE", headers: baseHeaders(ctx) }, ctx)
),
ctx.retry
);
if (response.status === 404) return false;
if (!response.ok) throw errorForStatus(response.status, ctx);
return true;
}
export async function storagePartial(
ctx: StorageClientCtx,
key: string,
offset: number,
length: number
): Promise<Uint8Array | null> {
const url = buildStorageUrl(ctx, key);
const init: RequestInit = {
method: "GET",
headers: {
...baseHeaders(ctx),
Range: buildRangeHeader(offset, length),
},
};
const response = await retryWithBackoff(
() => ctx.fetch(url, withSignal(init, ctx)),
ctx.retry
);
if (response.status === 404) return null;
if (response.status === 401 || response.status === 403) {
throw errorForStatus(response.status, ctx);
}
const outcome = await consumeRangeResponse(response, offset, length, url);
if (outcome.fellBack) recordRangeFallback(ctx.region);
return outcome.bytes;
}
export async function storageStream(
ctx: StorageClientCtx,
key: string
): Promise<Response> {
const url = buildStorageUrl(ctx, key);
const response = await ctx.fetch(
url,
withSignal({ method: "GET", headers: baseHeaders(ctx) }, ctx)
);
if (!response.ok) throw errorForStatus(response.status, ctx);
return response;
}

View File

@@ -0,0 +1,2 @@
export type { TokenAuthInput } from "./token-auth.js";
export { buildTokenAuthQuery } from "./token-auth.js";

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,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
STORAGE_CDN_BUNNYCDN_CODES,
type StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
import { buildTokenAuthQuery } from "./token-auth.js";
describe("buildTokenAuthQuery", () => {
it("signature matches BunnyCDN reference vector", {
timeout: 5000,
}, async () => {
// Two identical calls must produce the same token — covers
// deterministic format + signature.
const a = await buildTokenAuthQuery({
tokenAuthKey: "secret",
path: "/a/b.png",
expires: 1700000000,
});
const b = await buildTokenAuthQuery({
tokenAuthKey: "secret",
path: "/a/b.png",
expires: 1700000000,
});
expect(a).toBe(b);
// Format: `?token=<url-safe-base64>&expires=<unix>`
expect(a).toMatch(/^\?token=[A-Za-z0-9_-]+&expires=1700000000$/);
});
it("throws TOKEN_AUTH_KEY_MISSING when no key configured", {
timeout: 5000,
}, async () => {
try {
await buildTokenAuthQuery({
tokenAuthKey: "",
path: "/x",
expires: 1,
});
expect.fail("expected throw");
} catch (err) {
expect((err as StorageCdnBunnycdnError).code).toBe(
STORAGE_CDN_BUNNYCDN_CODES.TOKEN_AUTH_KEY_MISSING
);
}
});
it("path-scoped token appends token_path query", {
timeout: 5000,
}, async () => {
const q = await buildTokenAuthQuery({
tokenAuthKey: "secret",
path: "/images/photo.jpg",
expires: 1,
tokenPath: "/images/",
});
expect(q).toContain("token_path=%2Fimages%2F");
});
it("different expires produces different signature", {
timeout: 5000,
}, async () => {
const a = await buildTokenAuthQuery({
tokenAuthKey: "secret",
path: "/x",
expires: 1,
});
const b = await buildTokenAuthQuery({
tokenAuthKey: "secret",
path: "/x",
expires: 2,
});
expect(a).not.toBe(b);
});
it("url-safe base64 has no padding", { timeout: 5000 }, async () => {
const q = await buildTokenAuthQuery({
tokenAuthKey: "secret",
path: "/x",
expires: 42,
});
const token = new URL(
`https://example.com${q.replace("?", "?")}`
).searchParams.get("token");
expect(token).not.toBe(null);
expect(token).not.toContain("=");
expect(token).not.toMatch(/[+/]/);
});
});

View File

@@ -0,0 +1,59 @@
import {
STORAGE_CDN_BUNNYCDN_CODES,
StorageCdnBunnycdnError,
} from "../error-codes/error-codes.js";
export interface TokenAuthInput {
tokenAuthKey: string;
path: string;
expires: number;
/** Optional path-scoped token; signs `<key><path><expires>` AND adds
* `&token_path=<tokenPath>` so the same token grants access to every
* URL beneath that prefix. */
tokenPath?: string;
}
/**
* Build a BunnyCDN Pull Zone Token Authentication query string —
* `?token=<urlSafeBase64(sha256(key + path + expires))>&expires=<unix>`,
* optionally with `&token_path=<prefix>` when path-scoped.
*
* The signature uses SHA-256 (not HMAC) per BunnyCDN's documented scheme:
* concatenate the secret with the path and expires, hash, URL-safe-base64.
*/
export async function buildTokenAuthQuery(
input: TokenAuthInput
): Promise<string> {
if (
typeof input.tokenAuthKey !== "string" ||
input.tokenAuthKey.length === 0
) {
throw new StorageCdnBunnycdnError(
STORAGE_CDN_BUNNYCDN_CODES.TOKEN_AUTH_KEY_MISSING,
"BunnyCDN tokenAuthKey not configured"
);
}
const signedPath = input.tokenPath ?? input.path;
const message = `${input.tokenAuthKey}${signedPath}${input.expires}`;
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(message)
);
const token = urlSafeBase64(new Uint8Array(digest));
const prefixQuery =
input.tokenPath !== undefined
? `&token_path=${encodeURIComponent(input.tokenPath)}`
: "";
return `?token=${token}&expires=${input.expires}${prefixQuery}`;
}
function urlSafeBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
// `btoa` is part of the universal runtime baseline (Node 18+, Bun, Deno,
// browsers, Workers) so a Node-only `Buffer` fallback is not needed.
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

View File

@@ -0,0 +1,50 @@
import z from "zod";
import { BUNNYCDN_REGIONS } from "../constants/constants.js";
const looseAdapter = z.unknown().optional();
const retryShape = z
.object({
maxAttempts: z.number().int().positive().optional(),
baseDelayMs: z.number().int().nonnegative().optional(),
maxDelayMs: z.number().int().positive().optional(),
honourRetryAfter: z.boolean().optional(),
})
.optional();
const bareHostname = z
.string()
.min(1)
.refine((value) => !value.includes("://"), {
message: "pullZoneHostname must not include a scheme",
})
.refine((value) => !value.endsWith("/"), {
message: "pullZoneHostname must not end with '/'",
});
const trailingSlash = (value: string): boolean => value.endsWith("/");
export const BunnyCdnAdapterOptionsSchema = z.object({
storageZone: z.string().min(1),
storageAccessKey: z.string().min(1),
pullZoneHostname: bareHostname,
storageRegion: z.enum(BUNNYCDN_REGIONS).optional(),
tokenAuthKey: z.string().min(1).optional(),
purgeApiKey: z.string().min(1).optional(),
originBaseUrl: z
.string()
.url()
.refine(trailingSlash, { message: "originBaseUrl must end with '/'" })
.optional(),
edgeBaseUrl: z
.string()
.url()
.refine(trailingSlash, { message: "edgeBaseUrl must end with '/'" })
.optional(),
credentials: looseAdapter,
fetch: looseAdapter,
timers: looseAdapter,
now: looseAdapter,
headers: looseAdapter,
retry: retryShape,
});

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.

View File

@@ -0,0 +1,42 @@
import type { CdnAdapterOptions } from "@suaveplan/storage-cdn";
import type { FetchLike, TimersLike } from "@suaveplan/types";
import type { BunnyCdnRegion } from "../constants/constants.js";
export interface BunnyRetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
honourRetryAfter?: boolean;
}
export interface BunnyCdnAdapterOptions extends CdnAdapterOptions {
/** Storage Zone name. Required. */
storageZone: string;
/** Storage Zone access password (per-zone). */
storageAccessKey: string;
/**
* Pull Zone hostname — bare host only, no scheme, no trailing slash
* (e.g. `mycdn.b-cdn.net`). Used for `edgeUrl` and signed-URL composition.
*/
pullZoneHostname: string;
/** Storage region. Defaults to `"de"` (Falkenstein, unprefixed). */
storageRegion?: BunnyCdnRegion;
/** Pull Zone Token Authentication signing key (per-zone). */
tokenAuthKey?: string;
/** Account-level API key for the Bunny.net purge management API. */
purgeApiKey?: string;
/** Injectable fetch. */
fetch?: FetchLike;
/** Injectable timers — defaults to `globalThis.setTimeout`/`clearTimeout`. */
timers?: TimersLike;
/** Now-millis source. Defaults to `Date.now`. */
now?: () => number;
/** Static or per-request additional headers. */
headers?:
| Record<string, string>
| ((
key: string
) => Record<string, string> | Promise<Record<string, string>>);
/** Retry tuning. */
retry?: BunnyRetryOptions;
}

View File

@@ -0,0 +1,5 @@
export { BunnyCdnAdapterOptionsSchema } from "./adapter.schemas.js";
export type {
BunnyCdnAdapterOptions,
BunnyRetryOptions,
} from "./adapter.types.js";

View File

@@ -0,0 +1,3 @@
// Node 18+ provides `fetch`, `Response`, `Request`, `Headers`, `URL`,
// `AbortController`, `ReadableStream`, and `crypto.subtle` natively.
export {};

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/storage-cdn-bunnycdn",
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: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/retry",
/^@suaveplan\/storage/,
"@suaveplan/types",
"tslib",
"zod",
],
},
},
});

View File

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