Initial Commit
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user