first commit

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

View File

@@ -0,0 +1,114 @@
# @suaveplan/storage-cdn-rackspace - Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for @suaveplan/storage-cdn. v2 identity auth with proactive token refresh, service-catalog endpoint lookup (LON/IAD/DFW/ORD/SYD/HKG), HMAC-SHA1 TempURL signing, CDN purge, Range reads, native streaming, full AbortSignal threading, Retry-After honoured, OTel surface with provider+region attributes.
Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMetadata`/`getPartial`/`createReadStream`/`createWriteStream`/`getSize` against Cloud Files, plus `purge`/`signUrl`/`edgeUrl` against the Rackspace CDN. v2 identity auth with proactive token refresh, service-catalog endpoint lookup (LON/IAD/DFW/ORD/SYD/HKG), HMAC-SHA1 TempURL signing, transient-status retry honouring `Retry-After`, full OTel surface with `provider="rackspace"` and `region` attributes.
<!-- begin:auto-docs -->
## Documentation tree
- [auth-client](./src/auth-client/auth-client.md) — `RackspaceAuthClient` wraps Rackspace identity v2 (`POST /v2.0/tokens`) with the three behaviours every consumer of the…
- [auth-client](./src/auth-client/auth-client.md) — `RackspaceAuthClient` wraps Rackspace identity v2 (`POST /v2.0/tokens`) with the three behaviours every consumer of the…
- [cdn-client](./src/cdn-client/cdn-client.md) — Wraps the two Rackspace CDN management operations the adapter actually needs at runtime: `getCdnMetadata` (HEAD on the…
- [cdn-client](./src/cdn-client/cdn-client.md) — Wraps the two Rackspace CDN management operations the adapter actually needs at runtime: `getCdnMetadata` (HEAD on the…
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN_RACKSPACE` 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_RACKSPACE` 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 Rackspace Cloud Files, and pro…
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before any fetch is issued against Rackspace Cloud Files, and pro…
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-rackspace`: counters (`reads_total`, `writes_total`,…
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-rackspace`: counters (`reads_total`, `writes_total`,…
- [rackspace-cdn-adapter](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.md) — `RackspaceCdnAdapter` is the concrete `CdnAdapter` implementation that binds `@suaveplan/storage`'s `AbstractStorage` c…
- [rackspace-cdn-adapter.base](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.base.md) — `RackspaceCdnAdapterBase` carries the shared internal machinery every `RackspaceCdnAdapter` instance needs: the resolve…
- [rackspace-cdn-adapter.cdn-methods](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.cdn-methods.md) — Houses the CDN-side method implementations consumed by `RackspaceCdnAdapter`: `getSize` (via `fetchContainerSize`), `pu…
- [rackspace-cdn-adapter.helpers](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.helpers.md) — Internal helpers used by `RackspaceCdnAdapter` and its sibling ops/streams modules. Splits boundary concerns (option pa…
- [rackspace-cdn-adapter](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.md) — `RackspaceCdnAdapter` is the concrete `CdnAdapter` implementation that binds `@suaveplan/storage`'s `AbstractStorage` c…
- [rackspace-cdn-adapter.ops](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.ops.md) — Reusable Cloud Files HTTP operations used by `RackspaceCdnAdapter`: object GET (`objectGet`), HEAD (`objectHead`), PUT…
- [rackspace-cdn-adapter.streams](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.streams.md) — Builds the `ReadableStream` and `WritableStream` instances `RackspaceCdnAdapter` returns from `createReadStream` / `cre…
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"racks…
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"racks…
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests against Rackspace Cloud Files and the Rackspace CDN management API. Distingui…
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests against Rackspace Cloud Files and the Rackspace CDN management API. Distingui…
- [temp-url](./src/temp-url/temp-url.md) — Computes Swift TempURL signatures for short-lived signed URLs against Rackspace Cloud Files. The OpenStack Swift TempUR…
- [temp-url](./src/temp-url/temp-url.md) — Computes Swift TempURL signatures for short-lived signed URLs against Rackspace Cloud Files. The OpenStack Swift TempUR…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/storage-cdn-rackspace
```
## Quick start
```ts
import { RackspaceCdnAdapter } from "@suaveplan/storage-cdn-rackspace";
const storage = new RackspaceCdnAdapter({
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acc/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
username: process.env["RAX_USER"]!,
apiKey: process.env["RAX_KEY"]!,
region: "LON",
container: "myc",
tempUrlKey: process.env["RAX_TEMPURL_KEY"]!,
});
await storage.set("photos/a.jpg", bytes);
await storage.purge("photos/a.jpg");
const signed = await storage.signUrl("photos/a.jpg", { ttlSeconds: 300, method: "GET" });
```
## Factory registration
```ts
import { registerRackspaceCdnProvider } from "@suaveplan/storage-cdn-rackspace";
import { createStorage } from "@suaveplan/storage/factory";
registerRackspaceCdnProvider();
const storage = createStorage({ type: "rackspace-cdn", config });
```
## Modules
- [error-codes](./src/error-codes.md) — `STORAGE_CDN_RACKSPACE_CODES` registry + `StorageCdnRackspaceError`.
- [constants](./src/constants/constants.md) — region whitelist, default auth URL, retry defaults.
- [auth-client](./src/auth-client/auth-client.md) — v2 identity auth, token cache, service-catalog lookup, proactive refresh.
- [cdn-client](./src/cdn-client/cdn-client.md) — `getCdnMetadata`, `purgeObject`.
- [temp-url](./src/temp-url/temp-url.md) — HMAC-SHA1 TempURL signer via WebCrypto.
- [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.
- [rackspace-cdn-adapter](./src/rackspace-cdn-adapter/rackspace-cdn-adapter.md) — the main `RackspaceCdnAdapter` class.
- [registry](./src/registry/registry.md) — module-augmentation slot for `createStorage({ type: "rackspace-cdn", ... })`.
## Capabilities matrix
```ts
{
partialReads: true,
partialWrites: false,
nativeStreams: true,
atomicWrites: false,
contentAddressable: false,
cdn: {
cdnPurge: true,
cdnPrefixPurge: false,
cdnSignedUrls: true,
cdnOriginUploads: true,
},
}
```
## Features
- **Universal purity** — no `node:*` or DOM-only imports.
- **Full Cloud Files surface** — origin GET/PUT/DELETE/HEAD with Range, native streaming, AbortSignal.
- **CDN integration** — purge, signed (TempURL) URLs, edge URL composition.
- **Proactive auth refresh** — token re-issued five minutes before expiry; concurrent calls deduplicated.
- **Service-catalog endpoint resolution** — works against any region in the LON/IAD/DFW/ORD/SYD/HKG set; misconfigured region surfaces as `STORAGE_CDN_RACKSPACE.REGION_NOT_IN_CATALOG`.
- **Retry on transient outcomes** — `408`/`425`/`429`/`5xx` retried with `Retry-After` honoured.
- **OTel built-in** — `storage_cdn_rackspace.*` instruments with `provider="rackspace"` and `region` attributes for cross-provider dashboards.
- **100 / 100 / 100 / 100 Istanbul coverage**.
## License
MIT — see `LICENSE` at the repo root.

View File

@@ -0,0 +1,74 @@
# auth-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RackspaceAuthClient` wraps Rackspace identity v2 (`POST /v2.0/tokens`) with the three behaviours every consumer of the storage / CDN endpoints needs: a cached token reused across requests, a proactive refresh that fires before the documented expiry, and a service-catalog lookup that resolves the per-region `cloudFiles` and `cloudFilesCDN` endpoints in one round-trip. The adapter wires this client behind every public call so application code never thinks about token state or endpoint discovery.
## Features
- Single-shot auth — POSTs `apiKeyCredentials` and caches the result.
- Token cache reuses an in-memory `CachedToken` across calls.
- Proactive refresh five minutes before the documented expiry — no caller is blocked waiting for an in-flight re-auth on a near-expired token.
- Concurrent-call deduplication: parallel `getToken` calls share one auth request.
- Region-aware service-catalog lookup; rejects with `REGION_NOT_IN_CATALOG` when the response lacks an entry for the configured region.
- `invalidate()` hook so an outer 401 handler can drop the cache before re-trying.
- AbortSignal threaded into the auth fetch.
## Usage: Basic
```ts
import { RackspaceAuthClient } from "@suaveplan/storage-cdn-rackspace";
const auth = new RackspaceAuthClient({ username: "me", apiKey: "secret" });
const { tokenId, storageEndpoint, cdnEndpoint } = await auth.getToken();
```
## Usage: Advanced
```ts
import { RackspaceAuthClient } from "@suaveplan/storage-cdn-rackspace";
const auth = new RackspaceAuthClient({
authUrl: "https://lon.identity.api.rackspacecloud.com/v2.0",
username: process.env["RAX_USER"]!,
apiKey: process.env["RAX_KEY"]!,
region: "LON",
fetch: customFetch,
now: () => clock.now(),
});
async function withFreshToken<T>(op: (tokenId: string) => Promise<T>): Promise<T> {
try {
const { tokenId } = await auth.getToken();
return await op(tokenId);
} catch (err) {
auth.invalidate();
const { tokenId } = await auth.getToken();
return op(tokenId); // single retry
}
}
```
## API Reference
### `class RackspaceAuthClient`
- `constructor(options)` — `username`, `apiKey` required; `authUrl`, `region`, `fetch`, `now` optional with sensible defaults (`DEFAULT_AUTH_URL`, `"LON"`, `globalThis.fetch`, `Date.now`).
- `getToken(signal?): Promise<CachedToken>` — returns the cached token when still fresh; otherwise authenticates.
- `invalidate(): void` — drops the cache; the next `getToken` re-auths.
### `CachedToken`
`{ tokenId, expiresMs, storageEndpoint, cdnEndpoint, region }` — both endpoints carry a trailing slash so consumers can compose URLs via `new URL(key, endpoint)` safely.
## Implementation Notes
- Concurrent dedup uses a single `#pending` promise; the first caller initiates the request, subsequent callers await the same promise. The promise is cleared in `finally` so a rejected auth doesn't poison future calls.
- Proactive refresh is calculated against `now()` rather than wall-clock time so tests can pin the boundary precisely with an injected `now`.
- The trailing-slash invariant on endpoints is enforced inside `resolveEndpoints` — the Rackspace catalog historically returns URLs without trailing slashes in some regions, and downstream `new URL(rel, base)` would otherwise truncate the last path segment.
- `invalidate()` does not affect any in-flight `#pending` request — it only clears `#cache`. This matches the documented "re-auth once, retry once" pattern from the adapter's 401 recovery path.
- The auth request body uses the documented `RAX-KSKEY:apiKeyCredentials` shape — Rackspace's API-key variant of identity v2. Username/password is supported by identity v2 but is not the recommended Rackspace path.

View File

@@ -0,0 +1,66 @@
# cdn-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps the two Rackspace CDN management operations the adapter actually needs at runtime: `getCdnMetadata` (HEAD on the container's CDN URL to fetch `X-Cdn-Uri`, `X-Cdn-Ssl-Uri`, `X-Ttl`, `X-Cdn-Enabled`) and `purgeObject` (DELETE against the per-object CDN URL to invalidate the edge cache). Both functions accept a `CdnClientContext` that bundles the resolved `cdnEndpoint`, `token`, `container`, and `region`, so the calling code (the adapter) constructs the bundle once per operation and never has to thread the fields by hand.
## Features
- Two focused functions — no class to instantiate.
- `getCdnMetadata` returns `null` on 404, the parsed metadata otherwise.
- `purgeObject` resolves on 2xx and on 404 (already absent), throws `PURGE_RATE_LIMITED` on 429, and `UPSTREAM_ERROR` on other non-ok statuses.
- AbortSignal threaded into both fetch invocations.
- All errors carry `container`/`region`/`httpStatus` for diagnostics.
## Usage: Basic
```ts
import { purgeObject } from "@suaveplan/storage-cdn-rackspace";
await purgeObject(
{ fetch, token: "tok", cdnEndpoint: "https://cdn.example.com/", container: "myc", region: "LON" },
"a%2Fb.bin",
);
```
## Usage: Advanced
```ts
import { getCdnMetadata, purgeObject } from "@suaveplan/storage-cdn-rackspace";
async function purgeAfterUpload(ctx, key: string) {
const meta = await getCdnMetadata(ctx);
if (meta?.cdnEnabled !== true) return; // nothing to invalidate
await purgeObject(ctx, encodeURIComponent(key));
}
```
## API Reference
### `interface CdnClientContext`
`{ fetch, token, cdnEndpoint, container, region }` — `cdnEndpoint` is the management endpoint resolved from the service catalog.
### `interface CdnContainerMetadata`
`{ cdnUri, cdnSslUri, ttl, cdnEnabled }` — parsed verbatim from `X-Cdn-*` headers; missing headers default to empty string / 0 / false.
### `getCdnMetadata(ctx, signal?): Promise<CdnContainerMetadata | null>`
Returns `null` on 404, parsed metadata on 2xx, throws on other non-ok.
### `purgeObject(ctx, encodedKey, signal?): Promise<void>`
Issues `DELETE <cdnEndpoint><container>/<encodedKey>`. Resolves on 2xx or 404. Throws `STORAGE_CDN_RACKSPACE.PURGE_RATE_LIMITED` on 429. Other non-ok statuses surface as `UPSTREAM_ERROR`.
## Implementation Notes
- The `encodedKey` parameter is already URL-encoded by the caller — this module does not re-encode, which would double-escape values.
- 404 on purge is treated as success because Rackspace returns 404 for objects already evicted or never CDN-served; surfacing that as an error would force every caller to handle it specially.
- 429 is its own error code rather than `UPSTREAM_ERROR` because the management API has historically been rate-limited at ~25/min and operators want a dedicated alert rule.
- `cdnEnabled` parsing is case-insensitive (`true`/`True`/`TRUE`) — Rackspace has been inconsistent across documentation versions.
- The functions are intentionally not bundled into a class because there is no state worth caching across calls — the auth client already caches everything that survives between operations.

View File

@@ -0,0 +1,69 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the canonical `STORAGE_CDN_RACKSPACE` 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 `StorageCdnRackspaceError` class extends `SuaveplanError` and adds four diagnostic discriminants — `key`, `container`, `region`, and `httpStatus` — so consumers can correlate failures with the offending Rackspace operation without parsing message text.
## Features
- 9 stable error codes covering auth, container, CDN, TempURL, purge rate-limit, region-catalog, options, and upstream failures.
- `StorageCdnRackspaceError` subclass with `key`/`container`/`region`/`httpStatus` carry fields.
- Cause-chain preservation via `SuaveplanError`'s `cause` option.
- Type-safe `StorageCdnRackspaceErrorCode` discriminant inferred from the `defineCodes` tuple.
## Usage: Basic
```ts
import { STORAGE_CDN_RACKSPACE_CODES, StorageCdnRackspaceError } from "@suaveplan/storage-cdn-rackspace";
throw new StorageCdnRackspaceError(
STORAGE_CDN_RACKSPACE_CODES.PURGE_RATE_LIMITED,
"CDN purge throttled",
{ container: "myc", region: "LON", httpStatus: 429 },
);
```
## Usage: Advanced
```ts
import { STORAGE_CDN_RACKSPACE_CODES, StorageCdnRackspaceError } from "@suaveplan/storage-cdn-rackspace";
try {
await storage.purge("a/b.bin");
} catch (err) {
if (err instanceof StorageCdnRackspaceError && err.code === STORAGE_CDN_RACKSPACE_CODES.PURGE_RATE_LIMITED) {
operatorLog.warn({ container: err.container, region: err.region }, "Throttled — backing off");
await scheduleRetry({ delayMs: 60_000 });
}
throw err;
}
```
## API Reference
### `STORAGE_CDN_RACKSPACE_CODES`
Frozen record produced by `defineCodes("STORAGE_CDN_RACKSPACE", [...] as const)`. Codes: `AUTH_FAILED`, `TOKEN_EXPIRED`, `CONTAINER_NOT_FOUND`, `CDN_NOT_ENABLED`, `TEMP_URL_KEY_MISSING`, `PURGE_RATE_LIMITED`, `REGION_NOT_IN_CATALOG`, `INVALID_OPTIONS`, `UPSTREAM_ERROR`.
### `StorageCdnRackspaceErrorCode`
Union of the namespaced code string values.
### `class StorageCdnRackspaceError extends SuaveplanError<StorageCdnRackspaceErrorCode>`
- `constructor(code, message, options?)` — `options` accepts `cause`, `details`, `key`, `container`, `region`, `httpStatus`.
- Carry-fields exposed as `readonly` properties.
- `name = "StorageCdnRackspaceError"`.
## Implementation Notes
- `AUTH_FAILED` covers both `401 Unauthorized` and `403 Forbidden` from the storage endpoint — both indicate a bad token or insufficient scope.
- `TOKEN_EXPIRED` is reserved for downstream tooling that distinguishes proactive-expiry from on-demand-401-then-reauth. The adapter itself raises `AUTH_FAILED` when the re-auth path fails.
- `CONTAINER_NOT_FOUND` is raised for `404`/`410` on origin operations — Rackspace returns 410 when a container has been deleted but its tombstone is still indexed.
- `PURGE_RATE_LIMITED` is distinct from `UPSTREAM_ERROR` because purge has its own throttling regime (~25/min historically); operators benefit from a dedicated alert rule.
- `REGION_NOT_IN_CATALOG` is the configuration guard raised when the service catalog returned by identity v2 lacks an entry for the configured `region` — usually a typo or a tenant with restricted regions.
- `CDN_NOT_ENABLED` is provider-specific tooling code, raised when callers attempt CDN operations against a container whose CDN profile was never turned on.

View File

@@ -0,0 +1,62 @@
# key-sanitizer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates and normalises caller-supplied storage keys before any fetch is issued against Rackspace Cloud Files, 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 `StorageCdnRackspaceError` with code `STORAGE_CDN_RACKSPACE.INVALID_OPTIONS`.
Unlike `@suaveplan/storage-http`'s key sanitiser, this one collapses key-length and key-content errors into a single code because operators monitoring Rackspace traffic don't distinguish between "key too long" and "key has a control char" — both are caller bugs caught at the boundary.
## 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-rackspace";
const safe = sanitizeKey("a/b/file name.txt");
const encoded = encodeKey(safe); // → "a/b/file%20name.txt"
```
## Usage: Advanced
```ts
import { encodeKey, sanitizeKey, StorageCdnRackspaceError } from "@suaveplan/storage-cdn-rackspace";
function compose(storageEndpoint: string, container: string, key: string): string {
const safe = sanitizeKey(key);
return `${storageEndpoint}${encodeURIComponent(container)}/${encodeKey(safe)}`;
}
try {
compose(endpoint, "myc", "../etc/passwd");
} catch (err) {
if (err instanceof StorageCdnRackspaceError) operatorLog.warn({ err }, "Caller bug — bad key");
}
```
## API Reference
### `sanitizeKey(key: string): string`
Throws `StorageCdnRackspaceError(STORAGE_CDN_RACKSPACE.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. Preserves path hierarchy while encoding spaces, `?`, `#`, `&`, and non-ASCII characters.
## Implementation Notes
- Combining key-length and key-content rejections into one code is a deliberate UX choice — Rackspace 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 (TempURL signature paths, log messages) want the sanitised but unencoded form.
- The `MAX_KEY_LENGTH = 2048` cap matches `@suaveplan/storage-http`'s default — Rackspace's documented limit is 1024, so this is conservatively wider; the sibling `storage-cdn-rackspace.ops` layer prepends the container name which keeps the total path under documented limits.

View File

@@ -0,0 +1,81 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-rackspace`: 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-rackspace`. Every measurement carries `provider="rackspace"` and the configured `region` so dashboards can break Rackspace 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 runs.
- `setRackspaceMeterProvider(provider)` override hook with auto-rebuild on next access.
- `recordOp({ op, outcome, durationMs, region, bytes?, direction? })` emits the full observation set for one operation.
- `recordRangeFallback(region)` increments the dedicated counter — surfaces origins that ignore Range.
- `resetInstruments()` for test isolation.
- Dedicated counters for CDN-specific operations (`purges_total`, `signed_urls_total`) so operators can monitor purge rate-limit pressure separately from storage IO.
## Usage: Basic
```ts
import { recordOp } from "@suaveplan/storage-cdn-rackspace";
recordOp({ op: "purge", outcome: "ok", durationMs: 42, region: "LON" });
```
## Usage: Advanced
```ts
import {
resetInstruments,
setRackspaceMeterProvider,
recordOp,
} from "@suaveplan/storage-cdn-rackspace";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [periodicReader] });
setRackspaceMeterProvider(provider);
resetInstruments();
recordOp({ op: "signUrl", outcome: "ok", durationMs: 1, region: "LON" });
```
## API Reference
### `setRackspaceMeterProvider(provider?): void`
Override or restore the meter provider. Calling this invalidates the cached instrument set so the next `getInstruments()` rebinds.
### `getInstruments(force?): RackspaceInstruments`
Returns the cached instrument bundle (`tracer`, `meter`, every counter and histogram). `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` → `purges_total`.
- `"ok"` + `signUrl` → `signed_urls_total`.
- anything else → `errors_total`.
Always records `op_duration_ms{op, outcome, provider, region}`. When `bytes` and `direction` are both provided, also records `io_bytes{op, direction, provider, region}`.
### `recordRangeFallback(region): void`
Increments `range_fallback_total{outcome="ok", provider="rackspace", region}`.
### `resetInstruments(): void`
Drops the cached bundle without changing the meter provider.
## Implementation Notes
- The cache is critical for hot-path performance. `setRackspaceMeterProvider` is the one path that intentionally invalidates the cache.
- `provider="rackspace"` is hard-coded — every measurement from this package belongs to Rackspace by definition. Cross-provider dashboards aggregate on the `provider` attribute, so dropping it would defeat the family-wide observability.
- `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 — the fallback path always succeeds (just less efficiently); upstream failures during fallback surface via `errors_total`.

View File

@@ -0,0 +1,62 @@
# rackspace-cdn-adapter.base
## Purpose
`RackspaceCdnAdapterBase` carries the shared internal machinery every `RackspaceCdnAdapter` instance needs: the resolved configuration, the auth client, the per-operation `ctxFor` builder, and the OTel-wrapping `wrap` helper. The public `RackspaceCdnAdapter` class 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 RackspaceCdnAdapter` surface to consumers.
## Features
- `config: ResolvedConfig` — parsed at construction; protected so the subclass can read it without re-resolving.
- `auth: RackspaceAuthClient` — single client instance per adapter, caches tokens for the configured region.
- `ctxFor(key, signal)` — builds the `CallContext` the ops module consumes: fetches the token, 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.
## Usage: Basic
```ts
import { RackspaceCdnAdapterBase } from "@suaveplan/storage-cdn-rackspace";
class MyAdapter extends RackspaceCdnAdapterBase {
// implement abstract methods from AbstractStorage + CdnAdapter using
// this.config, this.auth, this.ctxFor(...), this.wrap(...)
}
```
## Usage: Advanced
```ts
import { RackspaceCdnAdapterBase } from "@suaveplan/storage-cdn-rackspace";
class CustomLoggingAdapter extends RackspaceCdnAdapterBase {
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 myObjectGet(ctx, key);
customMetrics.observe("rax_get_ms", Date.now() - start);
return result;
});
}
// ...
}
```
## API Reference
### `abstract class RackspaceCdnAdapterBase extends CdnAdapter`
- `protected readonly config: ResolvedConfig` — output of `resolveConfig` at construction time.
- `protected readonly auth: RackspaceAuthClient` — single auth client per adapter.
- `public override readonly capabilities` — the documented `StorageCapabilities & { cdn: CdnCapabilities }` matrix.
- `constructor(options: RackspaceCdnAdapterOptions)` — validates options, instantiates the auth client.
- `protected ctxFor(key, signal): Promise<CallContext>` — fetches the token, resolves headers, returns the bundle ops 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 `RackspaceCdnAdapter`. The abstract qualifier is structural (the abstract `purge`/`signUrl`/`edgeUrl` from `CdnAdapter` are unimplemented here) which TypeScript enforces.
- `config`/`auth` are `protected readonly` rather than `#private` so the subclass file can reference them as `this.config` / `this.auth`. Hiding them with `#private` would require duplicating every method into the base file or shipping an unsafe public accessor.
- `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 like `set`), and the `wrap` itself increments duration unconditionally.
- The `CAPS` module-level constant is shared by every instance — it's frozen at compile time via the `readonly` modifiers on the interface.
- This split is a deliberate organisational choice; consumers see only `RackspaceCdnAdapter`. The base is exported so subclasses outside the package can build alternative implementations sharing the same internal contract.

View File

@@ -0,0 +1,71 @@
# rackspace-cdn-adapter.cdn-methods
## Purpose
Houses the CDN-side method implementations consumed by `RackspaceCdnAdapter`: `getSize` (via `fetchContainerSize`), `purge` (via `performPurge`), `signUrl` (via `buildSignedUrl`), and `edgeUrl` (via `composeEdgeUrl`). Extracted from the public class file so the class body stays under the 300-line budget while the helpers remain unit-testable in isolation.
All four functions accept a `CdnMethodsCtx` bundle the class builds via its private `#cdnCtx()` accessor — keeps the helpers stateless and easy to fake in tests.
## Features
- `fetchContainerSize(ctx, signal?)` — HEAD on the container, parses `X-Container-Bytes-Used`.
- `performPurge(ctx, sanitisedKey, signal?)` — invokes `purgeObject` against the resolved CDN management endpoint.
- `buildSignedUrl(ctx, sanitisedKey, options, signal?)` — parses options, fetches the token, computes the Swift TempURL signature, composes the absolute storage URL.
- `composeEdgeUrl(ctx, sanitisedKey)` — synchronous URL composition against the configured `edgeBaseUrl`; throws `STORAGE_CDN_RACKSPACE.INVALID_OPTIONS` when absent.
- `CdnMethodsCtx` carries the auth client, fetch, container, region, TempURL key, edge URL, and now-function — the smallest shape that satisfies all four helpers.
## Usage: Basic
```ts
import { performPurge } from "@suaveplan/storage-cdn-rackspace";
await performPurge({ auth, fetch, container: "myc", region: "LON", tempUrlKey: undefined, edgeBaseUrl: undefined, now: Date.now }, "a/b.bin");
```
## Usage: Advanced
```ts
import {
buildSignedUrl,
composeEdgeUrl,
fetchContainerSize,
performPurge,
} from "@suaveplan/storage-cdn-rackspace";
async function operations(ctx) {
const totalBytes = await fetchContainerSize(ctx);
await performPurge(ctx, "a/b.bin");
const signed = await buildSignedUrl(ctx, "a/b.bin", { ttlSeconds: 60, method: "GET" });
const edge = composeEdgeUrl(ctx, "a/b.bin");
return { totalBytes, signed, edge };
}
```
## API Reference
### `interface CdnMethodsCtx`
`{ auth, fetch, container, region, tempUrlKey, edgeBaseUrl, now }` — bundle the class supplies once per call.
### `fetchContainerSize(ctx, signal?): Promise<number>`
Issues HEAD on the container; parses `X-Container-Bytes-Used` (defaults to 0 when missing). Throws `STORAGE_CDN_RACKSPACE.UPSTREAM_ERROR` on non-ok.
### `performPurge(ctx, sanitisedKey, signal?): Promise<void>`
Delegates to `purgeObject` from the cdn-client module, encoding the key first.
### `buildSignedUrl(ctx, sanitisedKey, options, signal?): Promise<string>`
Parses `SignUrlOptions` (throws on bad TTL/method), fetches the token, computes the TempURL signature, returns `<storageEndpoint><container>/<encodedKey><signature>`.
### `composeEdgeUrl(ctx, sanitisedKey): string`
Synchronous. Throws `STORAGE_CDN_RACKSPACE.INVALID_OPTIONS` when `edgeBaseUrl` is absent.
## Implementation Notes
- `composeEdgeUrl` is the one synchronous helper because `edgeUrl()` on the adapter is required to be synchronous by the `CdnAdapter` contract. Every other helper is async because it touches the auth client or the fetch surface.
- `buildSignedUrl` uses `new URL(...).pathname` to derive the absolute Swift path the TempURL signature operates over — Rackspace's storage URLs always include the `/v1/AUTH_<account>/...` prefix, and the signing body needs that exact prefix.
- The `now: () => number` function is injected rather than calling `Date.now()` directly so tests can pin the `expires` boundary for deterministic signature comparison.
- `CdnMethodsCtx` is intentionally a flat interface — passing the whole adapter instance would expose more surface than the helpers need and tie the helpers to the class identity.

View File

@@ -0,0 +1,84 @@
# rackspace-cdn-adapter.helpers
## Purpose
Internal helpers used by `RackspaceCdnAdapter` and its sibling ops/streams modules. Splits boundary concerns (option parsing, header resolution, abort guard, status → error mapping, 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 `RackspaceCdnAdapterOptionsSchema`, throws `STORAGE_CDN_RACKSPACE.INVALID_OPTIONS` on validation failure, and pre-resolves `fetch` / `now` / `region` defaults.
- `resolveHeaders(resolver, key)` — static record or async per-op callback; defensive copy on return.
- `throwIfAborted(signal)` — pre-fetch abort guard producing `STORAGE_CDN_RACKSPACE.UPSTREAM_ERROR` (with `cause = signal.reason`).
- `errorForStatus(status, container, region)` — maps `401`/`403`/`404`/`410`/other to the canonical `StorageCdnRackspaceError`.
- `outcomeFromError(err)` — translates any thrown error to the `outcome` attribute the OTel wrapper records.
## Usage: Basic
```ts
import { resolveConfig, errorForStatus } from "@suaveplan/storage-cdn-rackspace";
const config = resolveConfig({ originBaseUrl, edgeBaseUrl, username, apiKey, container });
const err = errorForStatus(401, "myc", "LON");
```
## Usage: Advanced
```ts
import {
errorForStatus,
outcomeFromError,
resolveHeaders,
throwIfAborted,
} from "@suaveplan/storage-cdn-rackspace";
async function customGet(ctx, key, signal) {
throwIfAborted(signal);
const headers = await resolveHeaders(ctx.options.headers, key);
const response = await ctx.fetch(buildUrl(ctx, key), { headers, signal });
if (!response.ok) {
const err = errorForStatus(response.status, ctx.container, ctx.region);
recordOp({ op: "get", outcome: outcomeFromError(err), durationMs: 0, region: ctx.region });
throw err;
}
return response;
}
```
## API Reference
### `interface ResolvedConfig`
`{ options, container, region, tempUrlKey, fetch, now }`.
### `resolveConfig(options): ResolvedConfig`
Throws `StorageCdnRackspaceError(STORAGE_CDN_RACKSPACE.INVALID_OPTIONS)` on schema failure.
### `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`.
### `errorForStatus(status, container, region): StorageCdnRackspaceError`
| status | code |
|---|---|
| 401, 403 | `AUTH_FAILED` |
| 404, 410 | `CONTAINER_NOT_FOUND` |
| other | `UPSTREAM_ERROR` |
### `outcomeFromError(err): "auth_failed" | "rate_limited" | "not_found" | "aborted" | "upstream_error"`
`StorageCdnRackspaceError` → maps by `code`; anything else → `"upstream_error"`.
## Implementation Notes
- The error-status table collapses `401`/`403` to `AUTH_FAILED` because Rackspace's auth model treats them as the same operator condition (token or scope problem) — 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 — every code MUST map deterministically so OTel counters stay correct.
- `resolveHeaders` defensively copies its inputs so a caller mutating their static record between calls doesn't leak into in-flight requests.
- `resolveConfig` requires `originBaseUrl` and `edgeBaseUrl` in the schema as optional only for forward compatibility with auto-discovery; the adapter accesses them via `config.options.edgeBaseUrl` and falls back to throwing when absent at `edgeUrl()` call time.

View File

@@ -0,0 +1,80 @@
# rackspace-cdn-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RackspaceCdnAdapter` is the concrete `CdnAdapter` implementation that binds `@suaveplan/storage`'s `AbstractStorage` contract to Rackspace Cloud Files (OpenStack Swift) for storage operations and the Rackspace CDN management API for cache invalidation. The class wires the auth client, the CDN client, the TempURL signer, the retry helper, and the OTel instrument set into one cohesive surface so application code calls `storage.set(...)`, `storage.purge(...)`, `storage.signUrl(...)` against a single instance and the adapter handles auth, endpoint discovery, retry, signing, and observability internally.
## Features
- Full `AbstractStorage` surface — `get`, `set`, `delete`, `exists`, `getMetadata`, `getWithMetadata`, `getItemSize`, `getPartial`, `createReadStream`, `createWriteStream`, `setPartial`, `getSize`. `list` and `clear` throw `UPSTREAM_ERROR` (intentionally not implemented; use Swift listing helpers if needed).
- Full `CdnAdapter` surface — `purge`, `signUrl`, `edgeUrl`.
- Capabilities matrix declares `partialReads: true`, `nativeStreams: true`, `cdnPurge: true`, `cdnPrefixPurge: false`, `cdnSignedUrls: true`, `cdnOriginUploads: true`.
- AbortSignal threaded through every public method.
- Retry on transient outcomes honouring `Retry-After`.
- OTel duration / IO bytes histograms + per-op outcome counters with `provider="rackspace"` and `region` attributes.
## Usage: Basic
```ts
import { RackspaceCdnAdapter } from "@suaveplan/storage-cdn-rackspace";
const storage = new RackspaceCdnAdapter({
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acc/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
username: process.env["RAX_USER"]!,
apiKey: process.env["RAX_KEY"]!,
region: "LON",
container: "myc",
});
await storage.set("photos/a.jpg", bytes);
await storage.purge("photos/a.jpg");
```
## Usage: Advanced
```ts
import { RackspaceCdnAdapter, createRackspaceCdnAdapter } from "@suaveplan/storage-cdn-rackspace";
const storage = createRackspaceCdnAdapter({
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acc/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
authUrl: "https://lon.identity.api.rackspacecloud.com/v2.0",
username: process.env["RAX_USER"]!,
apiKey: process.env["RAX_KEY"]!,
region: "LON",
container: "myc",
tempUrlKey: process.env["RAX_TEMPURL_KEY"]!,
retry: { maxAttempts: 5, baseDelayMs: 200, honourRetryAfter: true },
headers: { "content-type": "image/jpeg" },
});
const signed = await storage.signUrl("photos/a.jpg", { ttlSeconds: 300, method: "GET" });
```
## API Reference
### `class RackspaceCdnAdapter extends CdnAdapter`
- `constructor(options: RackspaceCdnAdapterOptions)` — validates options at the boundary; throws `STORAGE_CDN_RACKSPACE.INVALID_OPTIONS` on schema failure.
- Read methods: `get`, `exists`, `getMetadata`, `getWithMetadata`, `getItemSize`, `getPartial`, `createReadStream`, `getSize`.
- Write methods: `set`, `delete`, `setPartial`, `createWriteStream`.
- CDN methods: `purge`, `signUrl`, `edgeUrl`.
- Unsupported (throw `UPSTREAM_ERROR`): `list`, `clear`.
### `createRackspaceCdnAdapter(options): RackspaceCdnAdapter`
Convenience factory mirroring `createHttpStorage`.
## Implementation Notes
- `edgeUrl` is synchronous and requires `edgeBaseUrl` at construction time — the public CDN URL cannot be derived from auth state alone. Callers must pass it explicitly; the schema makes it optional only because constructor-time validation runs before auth.
- `purge` invokes `cdn-client.purgeObject` against the management endpoint resolved from the service catalog (not against `edgeBaseUrl`).
- `signUrl` parses options via `parseSignUrlOptions` (from `@suaveplan/storage-cdn`) before calling the TempURL signer — invalid TTLs surface as `STORAGE_CDN.INVALID_TTL` and route through the workspace-wide error namespace.
- `list` and `clear` are intentionally not implemented because Swift listing returns container-paginated JSON that doesn't map cleanly onto `StorageListResult` without provider-specific knowledge of marker pagination. Operators that need listing should use Swift's `GET /v1/account/container?format=json` directly or compose with a separate listing helper.
- `getSize` issues a HEAD on the container and parses `X-Container-Bytes-Used` — fast and accurate per Swift conventions.
- `setPartial` delegates to `AbstractStorage.defaultSetPartial` (read-modify-write); Swift does not natively support random-access writes.

View File

@@ -0,0 +1,89 @@
# rackspace-cdn-adapter.ops
## Purpose
Reusable Cloud Files HTTP operations used by `RackspaceCdnAdapter`: object GET (`objectGet`), HEAD (`objectHead`), PUT (`objectPut`), DELETE (`objectDelete`), Range read (`objectPartial`), streaming GET (`objectStream`), and `parseMetadata` for HEAD response headers. Each function takes a `CallContext` (fetch + endpoint + token + container + region + signal + retry + headers) and returns the result; the adapter composes them with the OTel wrapper and signal guards.
Split out so tests can exercise each operation in isolation without spinning up a full adapter.
## Features
- `objectGet` — full-body GET, 404/410 → `undefined`, 401/403/other → `errorForStatus` throw, retry on transient.
- `objectHead` — HEAD with retry.
- `objectPut` — PUT with auth token, content-type and content-length headers.
- `objectDelete` — DELETE; returns `true`/`false` for 2xx/404 outcomes.
- `objectPartial` — Range read; delegates 200/206/416 disambiguation to `@suaveplan/storage-http`'s `consumeRangeResponse`; increments range-fallback counter on 200.
- `objectStream` — single GET without retry; returns the raw `Response` so the caller can pump `body.getReader()`.
- `parseMetadata` — extracts `StorageMetadata` from HEAD response headers.
## Usage: Basic
```ts
import { objectGet } from "@suaveplan/storage-cdn-rackspace";
const bytes = await objectGet({
fetch, storageEndpoint, token, container, region, signal: undefined, retry: {}, headers: {}
}, "a/b.bin");
```
## Usage: Advanced
```ts
import {
objectHead,
objectPartial,
parseMetadata,
} from "@suaveplan/storage-cdn-rackspace";
async function existsAndMeta(ctx) {
const response = await objectHead(ctx, "a/b.bin");
if (response.status === 404) return null;
return parseMetadata(response);
}
async function readSlice(ctx, key, off, len) {
return objectPartial(ctx, key, off, len);
}
```
## API Reference
### `interface CallContext`
`{ fetch, storageEndpoint, token, container, region, signal, retry, headers }`.
### `objectGet(ctx, key): Promise<Uint8Array | undefined>`
Returns `undefined` on `404`/`410`, the response bytes on `200`, throws on other non-ok.
### `objectHead(ctx, key): Promise<Response>`
Raw response — caller decides.
### `objectPut(ctx, key, body, contentType): Promise<void>`
Throws on non-ok. The caller-supplied `contentType` overrides `headers["content-type"]`.
### `objectDelete(ctx, key): Promise<boolean>`
`true` on 2xx, `false` on 404/410, throws on other non-ok.
### `objectPartial(ctx, key, offset, length): Promise<Uint8Array | null>`
`null` on 404/410, sliced bytes from 206 or 200-fallback; increments `range_fallback_total` when origin returned 200.
### `objectStream(ctx, key): Promise<Response>`
Throws on non-ok; otherwise returns the raw response with the body intact for the caller to pump.
### `parseMetadata(response): StorageMetadata`
Extracts `Content-Length` (defaulting `size` to 0 when absent or unparsed), `Last-Modified` (defaulting to `Date.now()` when missing or unparseable).
## Implementation Notes
- The Range read uses `@suaveplan/storage-http`'s `consumeRangeResponse` directly to avoid duplicating the 200/206/416 disambiguation. The counter increment is wired locally so the metric carries `provider="rackspace"`.
- `objectStream` does NOT go through `retryWithBackoff` — streaming responses can't be retried mid-stream without buffering, and the underlying retry classifier is response-shape rather than stream-shape.
- `parseMetadata` returns a usable shape even when the origin omits `Content-Length` (some Swift configurations don't set it on partial-content responses). Callers that need exact size must call `getItemSize` against an origin that does.
- The `baseHeaders` helper always re-merges the auth token last so a caller-supplied `x-auth-token` cannot override it accidentally.
- `objectGet` and friends consume `ctx.retry` directly so callers customise retry per-operation if needed — the adapter currently passes one shared retry config per instance.

View File

@@ -0,0 +1,65 @@
# rackspace-cdn-adapter.streams
## Purpose
Builds the `ReadableStream` and `WritableStream` instances `RackspaceCdnAdapter` returns from `createReadStream` / `createWriteStream`, plus the `unsupported` error builder used by `list`/`clear`. 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 `CallContext` promise on the first `pull` (the adapter passes a promise that resolves once auth has produced a token), 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 1:1 to the consumer.
- 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.
- `unsupported` helper centralises the "not implemented" error shape used by `list` and `clear`.
## Usage: Basic
```ts
import { buildReadStream, buildWriteStream } from "@suaveplan/storage-cdn-rackspace";
const stream = buildReadStream(ctxPromise, "k", signal);
for await (const chunk of stream) processChunk(chunk);
```
## Usage: Advanced
```ts
import { buildWriteStream } from "@suaveplan/storage-cdn-rackspace";
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 the 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.
### `unsupported(method, container, region): StorageCdnRackspaceError`
`STORAGE_CDN_RACKSPACE.UPSTREAM_ERROR` with the method/container/region carried as discriminants.
## Implementation Notes
- Buffered-then-flush is the right tradeoff for Cloud Files PUT: Swift supports chunked transfers but requires either a known `Content-Length` upfront or a Dynamic Large Object scheme that needs orchestration the adapter doesn't currently own.
- `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 auth fails, the rejection surfaces via `controller.error`, which is what consumers expect for stream initialisation failures.
- `unsupported` carries `container` and `region` so monitoring rules can break "not implemented" alerts down by tenant — useful when migrating callers off the unsupported paths to a Swift listing helper.

View File

@@ -0,0 +1,78 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"rackspace-cdn"` slot, and exposes `registerRackspaceCdnProvider()` to bind `RackspaceCdnAdapter` as the constructor for that slot. Consumers who prefer factory-style instantiation (`createStorage({ type: "rackspace-cdn", config })`) call `registerRackspaceCdnProvider()` 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<"rackspace-cdn">` now type-checks with `RackspaceCdnAdapterOptions`.
- Idempotent `registerRackspaceCdnProvider()` — safe to call from multiple bootstrap paths.
- Zero runtime dependencies beyond `@suaveplan/storage/factory` and the local `RackspaceCdnAdapter` class.
## Usage: Basic
```ts
import { registerRackspaceCdnProvider } from "@suaveplan/storage-cdn-rackspace";
import { createStorage } from "@suaveplan/storage/factory";
registerRackspaceCdnProvider();
const storage = createStorage({
type: "rackspace-cdn",
config: {
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acc/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
username: "u",
apiKey: "k",
container: "myc",
region: "LON",
},
});
```
## Usage: Advanced
```ts
import { registerRackspaceCdnProvider } from "@suaveplan/storage-cdn-rackspace";
import { createStorage } from "@suaveplan/storage/factory";
// Bootstrap once per process.
registerRackspaceCdnProvider();
function tenantStorage(tenant: { container: string; region: "LON" | "IAD" }) {
return createStorage({
type: "rackspace-cdn",
config: { ...baseConfig, container: tenant.container, region: tenant.region },
});
}
```
## API Reference
### `registerRackspaceCdnProvider(): void`
Registers `RackspaceCdnAdapter` against `StorageFactory` under the `"rackspace-cdn"` slot when not already registered. Calling it twice is a no-op.
### Module augmentation
```ts
declare module "@suaveplan/storage/factory" {
interface StorageProviderRegistry { "rackspace-cdn": RackspaceCdnAdapter }
interface StorageProviderConfig { "rackspace-cdn": RackspaceCdnAdapterOptions }
}
```
This means `createStorage<"rackspace-cdn">(...)` returns `RackspaceCdnAdapter` and the `config` parameter is typed as `RackspaceCdnAdapterOptions`.
## Implementation Notes
- The cast `RackspaceCdnAdapter as unknown as new (config?: unknown) => RackspaceCdnAdapter` 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("rackspace-cdn")` is the idempotency gate. After `unregisterStorageProvider("rackspace-cdn")` or `clearStorageProviders()`, a subsequent `registerRackspaceCdnProvider()` 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 `RackspaceCdnAdapter` directly rather than via the package root barrel to avoid a circular import (the root barrel re-exports `registerRackspaceCdnProvider`).

View File

@@ -0,0 +1,71 @@
# retry-helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Coordinates retry logic for HTTP requests against Rackspace Cloud Files and the Rackspace CDN management API. Distinguishes transient outcomes (`408`/`425`/`429`/`5xx`) from fatal ones, honours `Retry-After` when the server provides it, and falls back to exponential backoff with a configurable cap. AbortSignal is plumbed through every wait so cancellation lands within milliseconds.
This is structurally similar to `@suaveplan/storage-http`'s retry helper but lives in this package so the adapter can use Rackspace-specific error codes for exhausted retries — a `STORAGE_CDN_RACKSPACE.UPSTREAM_ERROR` surfaces with `container` and `region` context that the generic helper cannot carry.
## 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 without real timers.
- Exhausted retries surface as `STORAGE_CDN_RACKSPACE.UPSTREAM_ERROR`.
## Usage: Basic
```ts
import { retryWithBackoff } from "@suaveplan/storage-cdn-rackspace";
const response = await retryWithBackoff(() => fetch("https://cdn.example.com/file.bin"));
```
## Usage: Advanced
```ts
import { retryWithBackoff } from "@suaveplan/storage-cdn-rackspace";
const response = await retryWithBackoff(
() => fetch(url, { signal, headers: { "x-auth-token": token } }),
{
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_RACKSPACE.UPSTREAM_ERROR`.
Returns the last response after exhaustion (caller decides how to surface a final transient status) or throws on synthesised exhaustion (no response, no error captured) / 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 1 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, and a generic helper would duplicate the classifier.
- 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 Rackspace error registry deliberately keeps abort under the upstream umbrella — operators monitoring "request canceled" already filter on `cause`-shape, not a separate code.

View File

@@ -0,0 +1,70 @@
# temp-url
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Computes Swift TempURL signatures for short-lived signed URLs against Rackspace Cloud Files. The OpenStack Swift TempURL middleware signs `<METHOD>\n<EXPIRES>\n<PATH>` (or `<METHOD>\n<EXPIRES>\nprefix:<PATH>` in prefix mode) with HMAC-SHA1 using a per-account or per-container `temp-url-key`. This module produces the matching signed query string `?temp_url_sig=<hex>&temp_url_expires=<unix>` (plus `&temp_url_prefix=<path>` when prefix mode is requested).
The adapter calls this from `signUrl`; consumers never invoke it directly unless they need TempURL signatures for a key path the adapter does not own.
## Features
- HMAC-SHA1 via `crypto.subtle` — runs in Node 18+, Bun, Deno, browsers, Workers.
- Lower-case hex output matching the Swift reference encoding.
- Optional `prefix` mode for prefix-scoped TempURLs (`temp-url-prefix` query parameter).
- Throws `STORAGE_CDN_RACKSPACE.TEMP_URL_KEY_MISSING` when the key is empty or missing — required guard for adapters that ship without a configured key.
- Pure async function — no state, no side effects.
## Usage: Basic
```ts
import { buildTempUrlSignature } from "@suaveplan/storage-cdn-rackspace";
const sig = await buildTempUrlSignature({
method: "GET",
path: "/v1/AUTH_account/myc/photo.jpg",
expires: Math.floor(Date.now() / 1000) + 60,
tempUrlKey: "rotated-monthly-secret",
});
// sig === "?temp_url_sig=<40hex>&temp_url_expires=<unix>"
```
## Usage: Advanced
```ts
import { buildTempUrlSignature } from "@suaveplan/storage-cdn-rackspace";
async function signedUploadUrl(path: string, ttlSeconds: number): Promise<string> {
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
const sig = await buildTempUrlSignature({
method: "PUT",
path,
expires,
tempUrlKey: await keys.current("rax-tempurl"),
prefix: true,
});
return `https://storage101.lon3.clouddrive.com${path}${sig}`;
}
```
## API Reference
### `interface TempUrlInput`
`{ method: "GET" | "PUT" | "HEAD"; path; expires; tempUrlKey; prefix? }`.
### `buildTempUrlSignature(input): Promise<string>`
Returns the query-string portion to append. Throws `STORAGE_CDN_RACKSPACE.TEMP_URL_KEY_MISSING` on empty `tempUrlKey`.
## Implementation Notes
- HMAC-SHA1 is the Swift TempURL canonical algorithm. SHA-256 variants exist in newer Swift but are not used by Rackspace's middleware.
- Output is lower-case hex without separators because Swift's TempURL implementation compares signatures byte-for-byte; mixed-case or 0x-prefixed forms fail verification.
- `expires` is Unix seconds, not milliseconds — every Swift reference uses seconds.
- Prefix mode signs the prefix path (with `prefix:` in the body) and adds `temp_url_prefix=<path>` to the query — the matching client request must use that prefix exactly.
- The function is async because `crypto.subtle.importKey` and `crypto.subtle.sign` are Promise-returning — the underlying HMAC computation is fast (≪1 ms) but the await chain is unavoidable.
- The shipped tests assert determinism (same inputs → identical output) and format-only properties; the byte-exact Swift reference vector is the responsibility of integration tests against a live Rackspace account.

View File

@@ -0,0 +1,83 @@
# adapter.types
## Purpose
Declares `RackspaceCdnAdapterOptions` — the option bag `RackspaceCdnAdapter` accepts — and the supporting `RackspaceRetryOptions` shape. The interface extends `CdnAdapterOptions` from `@suaveplan/storage-cdn` so it carries the `originBaseUrl` / `edgeBaseUrl` split every CDN provider in the family exposes, then adds Rackspace-specific fields: identity credentials, region, container, TempURL key, retry tuning, and the standard injectable hooks (`fetch`, `timers`, `now`, `headers`).
## Features
- Extends `CdnAdapterOptions` so cross-provider helpers can accept either shape.
- `username` + `apiKey` for identity v2 auth; both required.
- `region` whitelisted to the supported Rackspace regions (LON / IAD / DFW / ORD / SYD / HKG); defaults to `LON`.
- `tempUrlKey` optional — only `signUrl` requires it.
- Injectable `fetch`, `timers`, `now` for testability.
- `headers` as a static record or per-key async callback.
## Usage: Basic
```ts
import type { RackspaceCdnAdapterOptions } from "@suaveplan/storage-cdn-rackspace";
const options: RackspaceCdnAdapterOptions = {
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acc/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
username: "me",
apiKey: "secret",
container: "myc",
};
```
## Usage: Advanced
```ts
import type {
RackspaceCdnAdapterOptions,
RackspaceRetryOptions,
} from "@suaveplan/storage-cdn-rackspace";
const retry: RackspaceRetryOptions = {
maxAttempts: 5,
baseDelayMs: 200,
maxDelayMs: 10_000,
honourRetryAfter: true,
};
const options: RackspaceCdnAdapterOptions = {
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acc/",
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
authUrl: "https://lon.identity.api.rackspacecloud.com/v2.0",
username: process.env["RAX_USER"]!,
apiKey: process.env["RAX_KEY"]!,
region: "LON",
container: "media",
tempUrlKey: process.env["RAX_TEMPURL_KEY"]!,
cdnEnabled: true,
headers: async (key) => ({ "x-suaveplan-key": key }),
retry,
};
```
## API Reference
### `interface RackspaceCdnAdapterOptions extends CdnAdapterOptions`
- `username: string`, `apiKey: string` — required identity v2 credentials.
- `region?: RackspaceRegion` — defaults to `"LON"`.
- `container: string` — Cloud Files container holding the objects.
- `authUrl?: string` — defaults to `DEFAULT_AUTH_URL`.
- `tempUrlKey?: string` — required only when calling `signUrl`.
- `cdnEnabled?: boolean` — informational; defaults to `true`.
- `fetch?`, `timers?`, `now?` — injectable for tests.
- `headers?` — static record or `(key) => Record<string, string> | Promise<...>`.
- `retry?: RackspaceRetryOptions` — retry tuning.
### `interface RackspaceRetryOptions`
`{ maxAttempts?, baseDelayMs?, maxDelayMs?, honourRetryAfter? }` — defaults documented in `constants.md`.
## Implementation Notes
- `originBaseUrl` and `edgeBaseUrl` are kept optional in the Zod schema because the adapter falls back to service-catalog discovery for the origin endpoint; `edgeUrl()` still requires `edgeBaseUrl` and throws when absent.
- `region` is a strict literal union enforced at the Zod boundary; passing an unknown region throws `STORAGE_CDN_RACKSPACE.INVALID_OPTIONS` at construction time.
- `tempUrlKey` is intentionally separate from `apiKey` because the TempURL key is rotated independently in the Rackspace control panel and lives on the account or container, not the identity user.
- The `headers` callback receives the sanitised key, not the raw input, so the resolver sees the same value the URL composition uses.

View File

@@ -0,0 +1,78 @@
# auth.types
## Purpose
Declares the data shapes Rackspace identity v2 returns and the local cached form `RackspaceAuthClient` keeps in memory. Splitting these into dedicated types means the auth client's signatures stay clear, the JSON response can be typed at the parse boundary, and tests can construct fixture responses without inventing structural shapes.
## Features
- `ServiceCatalogEndpoint` / `ServiceCatalogEntry` — the documented identity-v2 catalog shape.
- `RackspaceAuthAccess` / `RackspaceAuthResponse` — the top-level shape of the auth response (`{ access: { token, serviceCatalog } }`).
- `CachedToken` — the local cache form with absolute `expiresMs` and trailing-slash-normalised endpoints.
- All types are `readonly` after construction at the API boundary; the cache form uses plain `string`/`number` so it serialises cleanly if a future consumer adds a cross-process cache.
## Usage: Basic
```ts
import type { CachedToken } from "@suaveplan/storage-cdn-rackspace";
function isExpired(token: CachedToken, now: number): boolean {
return token.expiresMs <= now;
}
```
## Usage: Advanced
```ts
import type {
CachedToken,
RackspaceAuthResponse,
ServiceCatalogEntry,
} from "@suaveplan/storage-cdn-rackspace";
function findStorage(catalog: ServiceCatalogEntry[], region: string): string | undefined {
return catalog
.find((e) => e.name === "cloudFiles")
?.endpoints.find((ep) => ep.region === region)
?.publicURL;
}
function parseAuthResponse(body: RackspaceAuthResponse): CachedToken {
return {
tokenId: body.access.token.id,
expiresMs: Date.parse(body.access.token.expires),
storageEndpoint: findStorage(body.access.serviceCatalog, "LON")!,
cdnEndpoint: findStorage(body.access.serviceCatalog, "LON")!.replace("storage", "cdn"),
region: "LON",
};
}
```
## API Reference
### `interface ServiceCatalogEndpoint`
`{ region, publicURL, internalURL?, tenantId? }`. Rackspace returns these inside `serviceCatalog[].endpoints[]`.
### `interface ServiceCatalogEntry`
`{ name, type, endpoints }`. `name` is `"cloudFiles"` for origin and `"cloudFilesCDN"` for the CDN management API.
### `interface RackspaceAuthAccess`
`{ token: { id, expires }; serviceCatalog }`. `expires` is an ISO-8601 string.
### `interface RackspaceAuthResponse`
`{ access: RackspaceAuthAccess }`. Direct deserialisation target of the v2 `POST /tokens` response.
### `interface CachedToken`
`{ tokenId, expiresMs, storageEndpoint, cdnEndpoint, region }`. Both endpoints end with `/` so `new URL(rel, endpoint)` resolves correctly.
## Implementation Notes
- The catalog shape is intentionally not narrowed to `cloudFiles`/`cloudFilesCDN` only — Rackspace returns many service entries, and the type would over-constrain consumers who add custom services later.
- `internalURL` and `tenantId` are typed but unused by the adapter; they're surfaced in the type because some downstream tooling reads them directly.
- `expiresMs` (number) is used instead of a `Date` so the cache stays trivially serialisable. The auth client converts the ISO string from the wire via `Date.parse()`.
- `region` on `CachedToken` is a `RackspaceRegion` literal, not a generic string — the catalog lookup proves the region exists before the cache entry is constructed, so the narrower type is sound.