Initial Commit
This commit is contained in:
112
packages/persistence/storage/storage-cdn-bunnycdn/README.md
Normal file
112
packages/persistence/storage/storage-cdn-bunnycdn/README.md
Normal 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.
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
StorageCdnBunnycdnErrorCode,
|
||||
StorageCdnBunnycdnErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
STORAGE_CDN_BUNNYCDN_CODES,
|
||||
StorageCdnBunnycdnError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
encodeKey,
|
||||
sanitizeKey,
|
||||
} from "./key-sanitizer.js";
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
BunnyInstruments,
|
||||
BunnyOp,
|
||||
BunnyOutcome,
|
||||
RecordOpInput,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
recordRangeFallback,
|
||||
resetInstruments,
|
||||
setBunnyMeterProvider,
|
||||
} from "./otel.js";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { PullZoneCtx } from "./pull-zone-client.js";
|
||||
export { purgeUrl } from "./pull-zone-client.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { registerBunnyCdnProvider } from "./registry.js";
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { RetryRunOptions } from "./retry-helper.js";
|
||||
export {
|
||||
isTransientStatus,
|
||||
parseRetryAfter,
|
||||
retryWithBackoff,
|
||||
} from "./retry-helper.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type { StorageClientCtx } from "./storage-client.js";
|
||||
export {
|
||||
buildStorageUrl,
|
||||
storageDelete,
|
||||
storageGet,
|
||||
storageHead,
|
||||
storagePartial,
|
||||
storagePut,
|
||||
storageStream,
|
||||
} from "./storage-client.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { TokenAuthInput } from "./token-auth.js";
|
||||
export { buildTokenAuthQuery } from "./token-auth.js";
|
||||
@@ -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.
|
||||
@@ -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(/[+/]/);
|
||||
});
|
||||
});
|
||||
@@ -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(/=+$/, "");
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { BunnyCdnAdapterOptionsSchema } from "./adapter.schemas.js";
|
||||
export type {
|
||||
BunnyCdnAdapterOptions,
|
||||
BunnyRetryOptions,
|
||||
} from "./adapter.types.js";
|
||||
@@ -0,0 +1,3 @@
|
||||
// Node 18+ provides `fetch`, `Response`, `Request`, `Headers`, `URL`,
|
||||
// `AbortController`, `ReadableStream`, and `crypto.subtle` natively.
|
||||
export {};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec-conformance.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user