Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# `storage`
|
||||
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `storage`
|
||||
|
||||
14 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/storage`](./storage/README.md) | Enterprise key-value storage abstractions for Suaveplan. Provides `AbstractStorage` (implementing `StorageLike`), `MemoryStorage` with TTL and zero-copy stream… |
|
||||
| [`@suaveplan/storage-browser-indexeddb`](./storage-browser-indexeddb/README.md) | Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for brows… |
|
||||
| [`@suaveplan/storage-browser-localstorage`](./storage-browser-localstorage/README.md) | Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storag… |
|
||||
| [`@suaveplan/storage-browser-opfs`](./storage-browser-opfs/README.md) | Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consum… |
|
||||
| [`@suaveplan/storage-cdn`](./storage-cdn/README.md) | Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`,… |
|
||||
| [`@suaveplan/storage-cdn-bunnycdn`](./storage-cdn-bunnycdn/README.md) | BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMe… |
|
||||
| [`@suaveplan/storage-cdn-rackspace`](./storage-cdn-rackspace/README.md) | Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`… |
|
||||
| [`@suaveplan/storage-file`](./storage-file/README.md) | Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every a… |
|
||||
| [`@suaveplan/storage-http`](./storage-http/README.md) | Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional… |
|
||||
| [`@suaveplan/storage-plugin-cache`](./storage-plugin-cache/README.md) | Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache<Uint8Array>`; hit/miss/populate o… |
|
||||
| [`@suaveplan/storage-s3`](./storage-s3/README.md) | S3-compatible object storage adapter for `@suaveplan/storage`. Extends `AbstractStorage` backed by `@aws-sdk/client-s3` with presigned URL generation, multipar… |
|
||||
| [`@suaveplan/storage-uri`](./storage-uri/README.md) | Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters. |
|
||||
| [`@suaveplan/storage-uri-plugin-bundle`](./storage-uri-plugin-bundle/README.md) | Per-package `bundle://` URI scheme for `@suaveplan/storage-uri`. |
|
||||
| [`@suaveplan/storage-uri-plugin-fallback`](./storage-uri-plugin-fallback/README.md) | Universal `fallback://` URI plugin for `@suaveplan/storage-uri`. Ships ordered multi-source resolution with cycle detection, depth cap, mirror writes, list uni… |
|
||||
<!-- end:auto-docs -->
|
||||
@@ -0,0 +1 @@
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
@@ -0,0 +1,93 @@
|
||||
# @suaveplan/storage-browser-indexeddb - Browser IndexedDB AbstractStorage adapter for @suaveplan/storage: structured-clone key-value storage via globalThis.indexedDB, AbortSignal everywhere, strict key sanitisation, TTL via expiresAt index, metadata storage, partial reads, DOMException → typed IndexedDbError mapping, full OTel surface.
|
||||
|
||||
Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for browser consumers — vault snapshots, drive chunk caches, in-app i18n catalogues, password manager records — with full `AbstractStorage` conformance, TTL via an `expiresAt` index, AbortSignal threading, strict key sanitisation, Zod-validated options, structured `IndexedDbError` boundary, DOMException → typed-code mapping, and a complete OpenTelemetry surface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - indexeddb-storage.ops — This module owns the Promis…
|
||||
- [indexeddb-storage.config](./src/indexeddb-storage/indexeddb-storage.config.md) — This module exposes the two building blocks every public method of `IndexedDbStorage` relies on: `resolveOptions` valid…
|
||||
- [indexeddb-storage.crud](./src/indexeddb-storage/indexeddb-storage.crud.md) — This module hosts the pure CRUD primitives that `IndexedDbStorage` composes its public methods from. Splitting them out…
|
||||
- [indexeddb-storage.errors](./src/indexeddb-storage/indexeddb-storage.errors.md) — This module concentrates the DOMException-to-`IndexedDbError` translation that the `IndexedDbStorage` class relies on.…
|
||||
- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - indexeddb-storage.ops — This module owns the Promis…
|
||||
- [indexeddb-storage.ops](./src/indexeddb-storage/indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [indexeddb-storage.config](./src/indexeddb-storage/indexeddb-storage.config.md) — This module exposes the two building blocks every public method of `IndexedDbStorage` relies on: `resolveOptions` valid…
|
||||
- [indexeddb-storage.crud](./src/indexeddb-storage/indexeddb-storage.crud.md) — This module hosts the pure CRUD primitives that `IndexedDbStorage` composes its public methods from. Splitting them out…
|
||||
- [indexeddb-storage.errors](./src/indexeddb-storage/indexeddb-storage.errors.md) — This module concentrates the DOMException-to-`IndexedDbError` translation that the `IndexedDbStorage` class relies on.…
|
||||
- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [indexeddb-storage.ops](./src/indexeddb-storage/indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-browser-indexeddb
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIndexedDbStorage,
|
||||
isIndexedDbAvailable,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
if (!isIndexedDbAvailable()) {
|
||||
throw new Error("This browser does not support IndexedDB");
|
||||
}
|
||||
|
||||
const storage = createIndexedDbStorage({ dbName: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
```
|
||||
|
||||
## Factory integration
|
||||
|
||||
```ts
|
||||
import { registerIndexedDbProvider } from "@suaveplan/storage-browser-indexeddb/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerIndexedDbProvider();
|
||||
const storage = createStorage({ type: "indexeddb", config: { dbName: "myapp" } });
|
||||
```
|
||||
|
||||
The package does NOT self-register at module-evaluation time — call `registerIndexedDbProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Value | Notes |
|
||||
|---|---|---|
|
||||
| `partialReads` | `true` | Via `Uint8Array.subarray(...)` on the in-memory copy of the stored row. |
|
||||
| `partialWrites` | `false` | IndexedDB has no random-access put; `setPartial` uses the `AbstractStorage` read-modify-write fallback. |
|
||||
| `nativeStreams` | `false` | IndexedDB has no native streaming surface; `createReadStream` / `createWriteStream` use the buffered fallback. |
|
||||
| `atomicWrites` | `true` | Each `objectStore.put` is a single-transaction commit. |
|
||||
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes/error-codes.ts`](./src/error-codes/error-codes.md) — `INDEXEDDB_CODES` registry + `IndexedDbError`.
|
||||
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
|
||||
- [`src/idb-adapter/idb-adapter.ts`](./src/idb-adapter/idb-adapter.md) — `defaultIndexedDB()` + `isIndexedDbAvailable()`.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.ts`](./src/indexeddb-storage/indexeddb-storage.md) — the `IndexedDbStorage` class.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.config.ts`](./src/indexeddb-storage/indexeddb-storage.config.md) — option resolver + OTel wrapper.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.crud.ts`](./src/indexeddb-storage/indexeddb-storage.crud.md) — CRUD primitives.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.errors.ts`](./src/indexeddb-storage/indexeddb-storage.errors.md) — DOMException mapping.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.ops.ts`](./src/indexeddb-storage/indexeddb-storage.ops.md) — transaction + open + cursor helpers.
|
||||
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
|
||||
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerIndexedDbProvider()`.
|
||||
- [`src/types/idb-like.types.ts`](./src/types/idb-like.types.md) — structural IndexedDB surface.
|
||||
- [`src/types/indexeddb-storage.types.ts`](./src/types/indexeddb-storage.types.md) — option boundary.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# `INDEXEDDB_CODES` + `IndexedDbError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the structured-error surface for `@suaveplan/storage-browser-indexeddb`. The frozen `INDEXEDDB_CODES` registry, declared once via `defineCodes("INDEXEDDB", […])`, is the single source of truth for every failure mode the package reports. Consumers programmatically dispatch on the typed `IndexedDbErrorCode` union without parsing message text, which makes upstream retry policies, OTel attributes, and `i18n`-driven user messages robust against wording drift. Errors thrown anywhere inside the package — at the schema boundary, in the key sanitiser, during a `transaction.commit` round-trip, or while mapping a `DOMException` — are all instances of `IndexedDbError` carrying both the registered code and an optional `key` discriminant.
|
||||
|
||||
## Features
|
||||
|
||||
- `INDEXEDDB_CODES` frozen registry: 11 codes (`NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `VERSION_ERROR`, `TRANSACTION_ABORTED`).
|
||||
- `IndexedDbError extends SuaveplanError<IndexedDbErrorCode>` — preserves `cause`, exposes the typed `code`, attaches an optional `key` discriminant for correlation with the offending storage key.
|
||||
- Static factory shape: every error constructed inside the package goes through `new IndexedDbError(code, message, { key, cause })` — no plain `Error` ever escapes the boundary.
|
||||
- Compatible with the project-wide OTel span-event adapter on `SuaveplanError`, so failures get logged with `error.code`, `error.key`, and the cause chain attached automatically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
|
||||
function rejectEmptyKey(key: string): void {
|
||||
if (key.length === 0) {
|
||||
throw new IndexedDbError(INDEXEDDB_CODES.INVALID_KEY, "key must not be empty");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
|
||||
async function safeWrite(write: () => Promise<void>, key: string): Promise<void> {
|
||||
try {
|
||||
await write();
|
||||
} catch (err) {
|
||||
// Programmatic dispatch — never parses the message.
|
||||
if (err instanceof IndexedDbError) {
|
||||
if (err.code === INDEXEDDB_CODES.QUOTA_EXCEEDED) {
|
||||
// trigger an LRU eviction pass and retry once
|
||||
return;
|
||||
}
|
||||
if (err.code === INDEXEDDB_CODES.ABORTED) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new IndexedDbError(INDEXEDDB_CODES.IO_FAILED, `write failed on ${key}`, {
|
||||
key,
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `INDEXEDDB_CODES` — `Readonly<Record<keyof, string>>` frozen by `defineCodes`. Each value is the stable string code (`"INDEXEDDB.NOT_FOUND"`, …).
|
||||
- `IndexedDbErrorCode` — union of every `INDEXEDDB_CODES[key]` literal.
|
||||
- `IndexedDbErrorOptions extends SuaveplanErrorOptions` — adds optional `key?: string` discriminant alongside the inherited `cause?`.
|
||||
- `IndexedDbError` — class. Constructor `(code: IndexedDbErrorCode, message: string, options?: IndexedDbErrorOptions)`. Exposes `code`, `key`, `cause`, `name === "IndexedDbError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 11-code surface intentionally covers DOMException-mapped failures (`QUOTA_EXCEEDED`, `PERMISSION_DENIED`, `VERSION_ERROR`, `TRANSACTION_ABORTED`), boundary failures (`INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`), lifecycle failures (`ABORTED`, `UNAVAILABLE`), and unclassified IO (`IO_FAILED`). `NOT_FOUND` is constructable but not thrown from production read paths — those return `undefined`/`false`/`null` per the `AbstractStorage` contract. Callers that need a thrown `NOT_FOUND` use the code directly (e.g. `assertExists` wrappers). Adding a code is a public-API change and requires bumping the spec change.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `defaultIndexedDB` + `isIndexedDbAvailable`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `idb-adapter` module exposes the only place inside the package that touches `globalThis.indexedDB`. Everything else — the `IndexedDbStorage` constructor, the open helper, the transaction wrapper — accepts an `IdbFactoryLike` adapter and routes every call through it. Keeping the default lookup behind a function means SSR and Node-side smoke tests can import the package safely (the `globalThis.indexedDB` lookup is lazy, performed only when a method is invoked) and unit tests can swap in a real in-memory `fake-indexeddb` factory without `vi.stubGlobal`.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultIndexedDB()` returns an `IdbFactoryLike` view of `globalThis.indexedDB` and throws `IndexedDbError(INDEXEDDB.UNAVAILABLE)` lazily — only when a method on the returned adapter is invoked — never at module evaluation time.
|
||||
- `isIndexedDbAvailable()` exposes a synchronous boolean for progressive-enhancement guards.
|
||||
- Adapter is purely structural — no class instances, no inheritance, swap-in friendly with `fake-indexeddb`'s `IDBFactory` instance.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultIndexedDB,
|
||||
isIndexedDbAvailable,
|
||||
} from "@suaveplan/storage-browser-indexeddb/idb-adapter";
|
||||
|
||||
if (!isIndexedDbAvailable()) {
|
||||
throw new Error("IndexedDB unavailable");
|
||||
}
|
||||
const factory = defaultIndexedDB();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIndexedDbStorage,
|
||||
defaultIndexedDB,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
// Production: rely on the lazy global lookup.
|
||||
const storage = createIndexedDbStorage({ dbName: "app" });
|
||||
|
||||
// Tests: inject a real fake-indexeddb factory.
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
const fake = new IDBFactory();
|
||||
const testStorage = createIndexedDbStorage({
|
||||
dbName: "app-test",
|
||||
indexedDB: { open: (n, v) => fake.open(n, v) },
|
||||
});
|
||||
|
||||
// Fallback: detect at runtime and gracefully degrade.
|
||||
const factory = defaultIndexedDB();
|
||||
// `factory.open(...)` will throw INDEXEDDB.UNAVAILABLE if indexedDB is missing.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultIndexedDB(): IdbFactoryLike` — returns an adapter that proxies `open(name, version?)` to `globalThis.indexedDB.open(name, version?)`. Throws `IndexedDbError(INDEXEDDB.UNAVAILABLE)` when invoked in an environment that does not expose `globalThis.indexedDB`.
|
||||
- `isIndexedDbAvailable(): boolean` — returns `true` when `globalThis.indexedDB` is defined; does NOT execute any IndexedDB call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy lookup pattern matches `@suaveplan/storage-browser-opfs`'s `defaultOpfs` — the package can be imported in Node test runners that lack `globalThis.indexedDB` without throwing at module load. Constructing `IndexedDbStorage` in a Node test without injecting an adapter is safe; the `UNAVAILABLE` error only surfaces at the first method call. The structural `IdbFactoryLike` interface lets a `fake-indexeddb` factory be passed directly without any cast — `IDBFactory.open` matches the `open(name, version?)` shape exactly. Calling `globalThis.indexedDB.open` (rather than capturing the factory once) ensures the runtime always reflects late assignments, which matters in test environments that install the global lazily.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Configuration + OTel wrapper
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes the two building blocks every public method of `IndexedDbStorage` relies on: `resolveOptions` validates caller-supplied options at the Zod boundary and applies defaults, and `recordedOp` wraps a per-operation closure with the canonical OTel observation pattern (assert-abort → run → record success / error / aborted). Pulling these into a sibling module keeps the storage class file under the 300-line hard limit and gives every adapter operation a single auditable entry point for observability.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveOptions(options)` — Zod-validate and apply defaults; throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` with the original Zod error attached as `cause`.
|
||||
- `recordedOp(op, key?, signal?, fn)` — generic wrapper. Asserts a pre-aborted signal as `INDEXEDDB.ABORTED` before invoking the closure; records duration + outcome to the OTel meter for every code path including success.
|
||||
- Defaults centralised in `../constants/index.ts` (`DEFAULT_DB_NAME`, `DEFAULT_STORE_NAME`, `DEFAULT_VERSION`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordedOp, resolveOptions } from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
const config = resolveOptions({ dbName: "app" });
|
||||
|
||||
await recordedOp("get", "user/1", undefined, async () => {
|
||||
// caller's work here
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordedOp,
|
||||
resolveOptions,
|
||||
type ResolvedConfig,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
function describe(config: ResolvedConfig): string {
|
||||
return `${config.dbName}/${config.storeName}@v${config.version}`;
|
||||
}
|
||||
|
||||
async function timedRead<T>(
|
||||
cfg: ResolvedConfig,
|
||||
key: string,
|
||||
signal: AbortSignal | undefined,
|
||||
body: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return recordedOp("get", key, signal, body);
|
||||
}
|
||||
|
||||
describe(resolveOptions({ dbName: "app", storeName: "main" }));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ResolvedConfig` — `{ dbName: string; storeName: string; version: number; factory: IdbFactoryLike; clock: Clock | undefined }`. The post-defaults concrete configuration.
|
||||
- `resolveOptions(options: IndexedDbStorageOptions): ResolvedConfig` — Zod-validates `options`; throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on failure with the Zod error preserved as `cause`. Fills in `DEFAULT_DB_NAME` / `DEFAULT_STORE_NAME` / `DEFAULT_VERSION` for missing fields and `defaultIndexedDB()` for the missing factory.
|
||||
- `recordedOp<T>(op, key, signal, fn): Promise<T>` — runs `fn()` inside a try/catch that emits an OTel observation on every path. Pre-aborted signals throw `INDEXEDDB.ABORTED` and increment `errors_total{outcome: "aborted"}`; thrown errors increment `errors_total{outcome: "error"}`. Successful runs increment the appropriate `{reads,writes,deletes}_total` counter via `recordOp(...)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`resolveOptions` re-checks the field shape via Zod even though TypeScript already enforces it at the call site — this is the standard boundary pattern, defending against JavaScript callers passing malformed input and against future fields added without explicit defaulting. The Zod schema declares `indexedDB` and `clock` as `z.unknown().optional()` because their structural shape is too involved to encode in Zod cleanly; the TypeScript signature catches the misuses Zod cannot. `recordedOp` does NOT clamp the operation duration when the closure throws synchronously — `Date.now() - start` is the wall-clock for the entire wrapped call regardless of outcome, which is the right semantic for an operation-latency histogram.
|
||||
@@ -0,0 +1,74 @@
|
||||
# CRUD primitives
|
||||
|
||||
## Purpose
|
||||
|
||||
This module hosts the pure CRUD primitives that `IndexedDbStorage` composes its public methods from. Splitting them out keeps the class file under the 300-line hard limit and makes each operation independently testable through the public class — every primitive flows through a `withStore(...)` transaction and surfaces typed `IndexedDbError`s. The split also documents the natural seams of the adapter so future migrations (e.g. structured-clone payload mode, batch transactions) have an obvious extension point.
|
||||
|
||||
## Features
|
||||
|
||||
- `getEntry(ctx, key)` — read a single row by primary key.
|
||||
- `putEntry(ctx, key, value, options?)` — write a row; computes `createdAt`/`modifiedAt`/`expiresAt` against the injected clock; emits the `set` write-byte sample.
|
||||
- `deleteEntry(ctx, key)` — `false` when the key was absent, `true` when it existed and was deleted; uses a read-before-write probe so callers can rely on a stable `delete()` return.
|
||||
- `clearStore(ctx)` — wipe every entry.
|
||||
- `listKeys(ctx, options?)` — `paginateKeys(...)` over a cursor walk that skips expired rows.
|
||||
- `sizeAcrossLive(ctx)` — sum `metadata.size` across live entries.
|
||||
- `valueFromEntry(entry)` / `withMetadata(entry)` / `partialSlice(entry, offset, length)` — pure helpers that materialise the public-facing return shape and emit the right OTel sample.
|
||||
- `isLive(entry, atMs)` — TTL discriminator.
|
||||
- `nowMs(clock)` — wall-clock helper that honours the injected `Clock`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getEntry,
|
||||
putEntry,
|
||||
type CrudContext,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function roundtrip(ctx: CrudContext, key: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
|
||||
await putEntry(ctx, key, bytes, undefined);
|
||||
const entry = await getEntry(ctx, key);
|
||||
return entry ? new Uint8Array(entry.data) : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
clearStore,
|
||||
deleteEntry,
|
||||
listKeys,
|
||||
sizeAcrossLive,
|
||||
type CrudContext,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function compactExpired(ctx: CrudContext): Promise<number> {
|
||||
const before = await sizeAcrossLive(ctx);
|
||||
const { keys } = await listKeys(ctx, undefined);
|
||||
for (const key of keys) {
|
||||
await deleteEntry(ctx, key);
|
||||
}
|
||||
await clearStore(ctx);
|
||||
return before;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CrudContext` — `{ db: IdbDatabaseLike; storeName: string; clock: Clock | undefined }`.
|
||||
- `isLive(entry: IndexedDbEntry, atMs: number): boolean` — `true` when no `expiresAt` is set OR `expiresAt > atMs`.
|
||||
- `getEntry(ctx, key): Promise<IndexedDbEntry | undefined>` — single `objectStore.get`.
|
||||
- `putEntry(ctx, key, value, options?): Promise<void>` — single `objectStore.put`; emits the write OTel sample.
|
||||
- `deleteEntry(ctx, key): Promise<boolean>` — probe-then-delete; returns existence.
|
||||
- `clearStore(ctx): Promise<void>` — `objectStore.clear`.
|
||||
- `listKeys(ctx, options?): Promise<StorageListResult>` — cursor walk → `paginateKeys`.
|
||||
- `sizeAcrossLive(ctx): Promise<number>` — cursor walk summing `metadata.size`.
|
||||
- `valueFromEntry(entry: IndexedDbEntry): Uint8Array` — clones the stored buffer; emits the read OTel sample.
|
||||
- `withMetadata(entry): StorageResult<Uint8Array>` — clones + projects the metadata.
|
||||
- `partialSlice(entry, offset, length): Uint8Array` — bounded `subarray`; emits the partial-read OTel sample.
|
||||
- `nowMs(clock?: Clock): number` — `clock() ?? Date.now()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`putEntry`'s metadata builder uses an `expiresAt`-spread to avoid emitting `expiresAt: undefined` in JSON-serialised contexts; the same approach is mirrored on the `IndexedDbEntry` itself for parity with the schema. `deleteEntry`'s probe step is a deliberate cost — IndexedDB's `objectStore.delete` returns `undefined` whether or not the entry existed, so a single round-trip cannot satisfy the `AbstractStorage.delete: Promise<boolean>` contract. Bundling the probe inside the same `readonly` transaction would not be safe with the subsequent `readwrite` transaction either, so we accept the two-round-trip cost for a correct `boolean`. `valueFromEntry` clones the stored buffer (`new Uint8Array(entry.data)`) so callers cannot mutate the IndexedDB-owned bytes — `fake-indexeddb` returns wrapped arrays whose mutation would corrupt subsequent reads.
|
||||
@@ -0,0 +1,69 @@
|
||||
# IndexedDB error mapping helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
This module concentrates the DOMException-to-`IndexedDbError` translation that the `IndexedDbStorage` class relies on. Concentrating it here keeps the storage class itself focused on transaction orchestration and makes the mapping table the single auditable surface for "which IDB DOMException becomes which typed code." The helpers also export the small `assertNotAborted` / `assertNonNegInt` guards used at the top of every public async method.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapIndexedDbError(err, key?)` — translates an unknown error into a typed `IndexedDbError`. Recognises `NotAllowedError` → `PERMISSION_DENIED`, `QuotaExceededError` / `NS_ERROR_DOM_QUOTA_REACHED` → `QUOTA_EXCEEDED`, `VersionError` → `VERSION_ERROR`, `AbortError` → `ABORTED`, `TransactionInactiveError` → `TRANSACTION_ABORTED`. Falls back to `IO_FAILED` with the original error attached as `cause`.
|
||||
- `isNotFoundError(err)` — discriminator used by `get` / `delete` paths to return `undefined` / `false` rather than throw.
|
||||
- `assertNotAborted(signal, key?)` — throws `INDEXEDDB.ABORTED` immediately when a pre-aborted signal is observed.
|
||||
- `assertNonNegInt(key, label, value)` — throws `INDEXEDDB.INVALID_KEY` when a partial-read offset/length is not a non-negative integer.
|
||||
- Pre-existing `IndexedDbError` instances are passed through untouched — a caller-thrown `INDEXEDDB.UNAVAILABLE` from the adapter never gets reclassified.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapIndexedDbError } from "@suaveplan/storage-browser-indexeddb";
|
||||
import { IndexedDbError } from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
|
||||
try {
|
||||
await doIdbOperation();
|
||||
} catch (raw) {
|
||||
const mapped: IndexedDbError = mapIndexedDbError(raw, "user/42");
|
||||
throw mapped;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNonNegInt,
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapIndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function safeReadSlice(
|
||||
open: () => Promise<{ value: Uint8Array } | undefined>,
|
||||
key: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array | null> {
|
||||
assertNotAborted(signal, key);
|
||||
assertNonNegInt(key, "offset", offset);
|
||||
assertNonNegInt(key, "length", length);
|
||||
try {
|
||||
const row = await open();
|
||||
if (row === undefined) return null;
|
||||
return row.value.subarray(offset, offset + length);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
throw mapIndexedDbError(err, key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapIndexedDbError(err: unknown, key?: string): IndexedDbError` — converts any error to a typed error. Existing `IndexedDbError` instances pass through unmodified. Recognised DOMException `name`s map to specific codes; unknown errors map to `IO_FAILED` preserving `cause`.
|
||||
- `isNotFoundError(err: unknown): boolean` — `true` when `err` is a `NotFoundError` DOMException OR an `IndexedDbError(INDEXEDDB.NOT_FOUND)`.
|
||||
- `assertNotAborted(signal: AbortSignal | undefined, key?: string): void` — throws `IndexedDbError(INDEXEDDB.ABORTED)` when `signal.aborted` is true.
|
||||
- `assertNonNegInt(key: string, label: string, value: number): void` — throws `IndexedDbError(INDEXEDDB.INVALID_KEY)` when `value` is not a non-negative integer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Firefox-specific quota error name `NS_ERROR_DOM_QUOTA_REACHED` is preserved alongside the spec-compliant `QuotaExceededError`. The catch-all `IO_FAILED` mapping intentionally keeps the original error's `message` in the thrown message so log readers can correlate the typed error with the underlying DOMException without rebuilding the cause chain. `assertNonNegInt` uses `Number.isInteger` (NOT `value % 1 === 0`) so floating-point representations of negative values like `-0` are rejected — `Number.isInteger(-0)` is `true`, but `-0 < 0` is `false`; we accept this edge case to align with the OPFS sibling's behavior.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `IndexedDbStorage`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - indexeddb-storage.ops — This module owns the Promis…
|
||||
- [indexeddb-storage.ops](./indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
- [indexeddb-storage.ops](./indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
|
||||
## Purpose
|
||||
|
||||
`IndexedDbStorage` is the public `AbstractStorage` adapter exposed by `@suaveplan/storage-browser-indexeddb`. It provides persistent, origin-scoped, key-value storage over the W3C IndexedDB API for browser-tier consumers — vault snapshots, drive chunk caches, in-app i18n catalogues, password manager records — with the same surface every other storage backend implements. The implementation routes every public method through the lazy database opener, transaction-aware CRUD primitives, the canonical OTel recorder, and the strict key sanitiser, so callers get observability, AbortSignal support, structured errors, and TTL on every operation without opting in.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every `AbstractStorage` abstract method (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getMetadata`, `getWithMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`).
|
||||
- AbortSignal threaded through every async surface; pre-aborted signals throw `INDEXEDDB.ABORTED` before any IDB call.
|
||||
- Configurable database name / object-store name / version with sensible defaults (`suaveplan` / `storage` / `1`).
|
||||
- TTL via `expiresAt` field + IndexedDB `expiresAt` index; expired rows are skipped on read and lazily deleted.
|
||||
- Per-record metadata (`size`, `createdAt`, `modifiedAt`, optional `expiresAt`, arbitrary `metadata` from `StorageOptions.metadata`).
|
||||
- Prefix-based listing with pagination via `paginateKeys` (sorted, offset/limit-aware).
|
||||
- Strict key sanitisation (no control chars, no `..`, no absolute paths, ≤ 1024 chars).
|
||||
- Injectable `IDBFactory` adapter and injectable `Clock` for deterministic tests.
|
||||
- Lifecycle dedup: concurrent `get`/`set`/`delete` calls share a single in-flight `open` promise.
|
||||
- DOMException → typed `IndexedDbError` mapping (`QuotaExceededError`, `NS_ERROR_DOM_QUOTA_REACHED`, `NotAllowedError`, `VersionError`, `AbortError`).
|
||||
- Idempotent `close()` — re-opens on next call.
|
||||
- Capabilities: `partialReads: true`, `partialWrites: false`, `nativeStreams: false`, `atomicWrites: true`, `contentAddressable: false`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createIndexedDbStorage } from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
const storage = createIndexedDbStorage({ dbName: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const value = await storage.get("user/42");
|
||||
console.log(new TextDecoder().decode(value));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { IndexedDbStorage } from "@suaveplan/storage-browser-indexeddb";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
const fakeFactory = new IDBFactory();
|
||||
const storage = new IndexedDbStorage({
|
||||
dbName: "app-test",
|
||||
storeName: "kv",
|
||||
version: 1,
|
||||
indexedDB: { open: (n, v) => fakeFactory.open(n, v) },
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
// TTL — entry expires 60s after the injected clock.
|
||||
await storage.set(
|
||||
"session/123",
|
||||
new Uint8Array([1, 2, 3]),
|
||||
{ ttl: 60, metadata: { userId: "u-42" } },
|
||||
);
|
||||
|
||||
const metadata = await storage.getMetadata("session/123");
|
||||
console.log(metadata?.expiresAt); // 1_700_000_060_000
|
||||
|
||||
const partial = await storage.getPartial("session/123", 1, 2);
|
||||
console.log(partial); // Uint8Array(2)[2, 3]
|
||||
|
||||
await storage.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IndexedDbStorage extends AbstractStorage` — class. Constructor `(options?: IndexedDbStorageOptions)`. Throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on malformed options. All abstract methods from `AbstractStorage` are implemented.
|
||||
- `createIndexedDbStorage(options?): IndexedDbStorage` — convenience factory.
|
||||
- `capabilities` — `{ partialReads: true; partialWrites: false; nativeStreams: false; atomicWrites: true; contentAddressable: false }`.
|
||||
- `close(): Promise<void>` — closes the underlying database connection; idempotent.
|
||||
- Every method takes a final optional `signal?: AbortSignal` that aborts the operation with `IndexedDbError(INDEXEDDB.ABORTED)` when pre-aborted.
|
||||
- `set(key, value, options?)` — `options.ttl` is seconds; `options.metadata` is a free-form record persisted under `metadata.metadata`.
|
||||
- `getPartial(key, offset, length, signal?)` — returns a `Uint8Array.subarray` view into the stored bytes (caller MUST treat the return value as read-only; mutating it does NOT update IndexedDB). Out-of-range offsets clamp to the end of the buffer; the result may be shorter than `length` when the underlying value is smaller.
|
||||
- `setPartial(key, offset, bytes, signal?)` — read-modify-write fallback from `AbstractStorage.defaultSetPartial`. IndexedDB has no random-access write API so the entire row is rewritten on every call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The database open is deduplicated via a private in-flight promise (`#openPromise`) so concurrent first-touch operations share a single `IDBOpenDBRequest`. The `expiresAt` index is declared in `onupgradeneeded` and currently used as a metadata field; a follow-up change can wire an idle-time sweep against the index to delete expired rows proactively. The TTL is evaluated against the injected `Clock` (defaults to `Date.now()`) on every read; expired rows are lazily deleted in the read transaction's wake (no `await` is necessary on the cleanup because the storage class returns `undefined`/`null` immediately). The `delete()` method probes the row first so it can return a stable `boolean` — IndexedDB's `objectStore.delete` resolves successfully whether or not the entry existed, so we accept the second round-trip in exchange for a correct AbstractStorage contract. Streams use the `AbstractStorage.defaultCreateReadStream` / `defaultCreateWriteStream` fallbacks — IndexedDB does not expose a native streaming surface, so `capabilities.nativeStreams` is reported as `false`. `setPartial` uses `defaultSetPartial` because there is no random-access write API on an object-store row.
|
||||
@@ -0,0 +1,77 @@
|
||||
# IndexedDB transaction helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separating these helpers from the storage class keeps the main implementation focused on public-method semantics — every method ends up calling `withStore(...)` with a small `run` closure rather than open-coding the transaction lifecycle. The module also owns `openDatabase` (open + upgrade with the `expiresAt` index), the cursor walker that filters expired entries, and the pure pagination helper.
|
||||
|
||||
## Features
|
||||
|
||||
- `promisifyRequest<T>(req)` — generic Promise wrapper for any `IDBRequest`-shaped value.
|
||||
- `promisifyTransaction(tx)` — awaits `oncomplete` / rejects on `onerror` / `onabort`.
|
||||
- `openDatabase({ factory, dbName, storeName, version })` — opens the DB, runs the `onupgradeneeded` migration that creates the object store with `keyPath: "key"` and the `expiresAt` index.
|
||||
- `withStore({ db, storeName, mode, run })` — opens a transaction, runs the caller's work, and awaits the transaction's commit before resolving.
|
||||
- `paginateKeys(keys, options)` — pure prefix/offset/limit pagination producing a `StorageListResult`.
|
||||
- `collectLiveEntries(store, now)` — cursor walker that drops entries whose `expiresAt` is in the past.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
openDatabase,
|
||||
withStore,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
import type { IdbFactoryLike } from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
async function readOne(factory: IdbFactoryLike, key: string): Promise<unknown> {
|
||||
const db = await openDatabase({
|
||||
factory,
|
||||
dbName: "suaveplan",
|
||||
storeName: "storage",
|
||||
version: 1,
|
||||
});
|
||||
return withStore({
|
||||
db,
|
||||
storeName: "storage",
|
||||
mode: "readonly",
|
||||
run: async (store) =>
|
||||
new Promise((resolve) => {
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(undefined);
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { collectLiveEntries, withStore } from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function listKeys(db: import("@suaveplan/storage-browser-indexeddb/types").IdbDatabaseLike): Promise<string[]> {
|
||||
return withStore({
|
||||
db,
|
||||
storeName: "storage",
|
||||
mode: "readonly",
|
||||
run: async (store) => {
|
||||
const entries = await collectLiveEntries(store, Date.now());
|
||||
return entries.map((e) => e.key);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `promisifyRequest<T>(req: IdbRequestLike): Promise<T>` — Resolves with `req.result` on `onsuccess`, rejects with `req.error` (or a synthetic `Error`) on `onerror`.
|
||||
- `promisifyTransaction(tx: IdbTransactionLike): Promise<void>` — Resolves on `oncomplete`; rejects on `onerror` / `onabort`.
|
||||
- `OpenDbInput` — `{ factory, dbName, storeName, version }`.
|
||||
- `openDatabase(input: OpenDbInput): Promise<IdbDatabaseLike>` — Opens (or creates) the database, ensures the object store + `expiresAt` index exist via `onupgradeneeded`, surfaces failures through `mapIndexedDbError`.
|
||||
- `WithStoreInput<T>` — `{ db, storeName, mode, run }`.
|
||||
- `withStore<T>(input: WithStoreInput<T>): Promise<T>` — Runs `input.run(store)` inside a transaction and awaits commit before resolving.
|
||||
- `paginateKeys(keys: string[], options?: StorageListOptions): StorageListResult` — Pure pagination over sorted, prefix-filtered keys.
|
||||
- `collectLiveEntries(store, now: number): Promise<IndexedDbEntry[]>` — Cursor walker that skips entries whose `expiresAt` ≤ `now`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withStore` awaits both the caller's work AND the transaction's `oncomplete` event because IndexedDB only guarantees durability when the transaction commits. A caller that awaited only the `run` closure could observe a successful read followed by an abort that rolled back the just-completed transaction. `collectLiveEntries` captures `now` once so a slow cursor walk doesn't span the TTL boundary mid-iteration (otherwise list could return entry A as live and entry B as expired even though both expired during the call). The `openDatabase` upgrade logic is intentionally conservative: it only creates the store and index when missing, so a future migration is a forward addition rather than a destructive rebuild — when we bump `version` and need to migrate, that work lives in the same `onupgradeneeded` callback with a `request.transaction.oldVersion` check.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `sanitizeKey`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `sanitizeKey` boundary validates every caller-supplied IndexedDB primary key before it crosses the public API surface of `IndexedDbStorage`. Strict validation up front prevents two classes of failure: silent corruption from malformed keys (control characters that confuse later debugging tooling, empty strings that masquerade as falsy values), and security issues where attacker-controlled keys could mimic path-traversal patterns even though IndexedDB itself does not interpret slashes. The sanitiser keeps the package's key contract aligned with the sibling `@suaveplan/storage-browser-opfs` `sanitizeKey`, so a caller can swap backends behind `AbstractStorage` without seeing a different validation contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Rejects non-string input, empty strings, and keys longer than 1024 characters (`KEY_TOO_LONG`).
|
||||
- Rejects keys with C0 control characters (0x00-0x1F) or DEL (0x7F).
|
||||
- Rejects POSIX-absolute keys (`/foo`) and `..` traversal segments — defensive guards for callers that later use keys as path-like identifiers.
|
||||
- Returns the validated key unchanged on success — no normalisation surprises.
|
||||
- All rejections throw `IndexedDbError` with `INDEXEDDB.INVALID_KEY` or `INDEXEDDB.KEY_TOO_LONG` and attach the offending key (truncated for `KEY_TOO_LONG`) to the error's `key` discriminant.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-indexeddb/key-sanitizer";
|
||||
|
||||
const safe = sanitizeKey("user/42/profile");
|
||||
console.log(safe); // "user/42/profile"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-indexeddb/key-sanitizer";
|
||||
|
||||
function validateBatch(keys: string[]): { ok: string[]; bad: { key: string; reason: string }[] } {
|
||||
const ok: string[] = [];
|
||||
const bad: { key: string; reason: string }[] = [];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
ok.push(sanitizeKey(key));
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError && err.code === INDEXEDDB_CODES.INVALID_KEY) {
|
||||
bad.push({ key, reason: err.message });
|
||||
} else if (err instanceof IndexedDbError && err.code === INDEXEDDB_CODES.KEY_TOO_LONG) {
|
||||
bad.push({ key, reason: "too long" });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok, bad };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sanitizeKey(key: string): string` — Returns `key` unchanged when valid. Throws `IndexedDbError(INDEXEDDB.INVALID_KEY)` for non-strings, empty strings, control characters, absolute paths, and `..` segments. Throws `IndexedDbError(INDEXEDDB.KEY_TOO_LONG)` when the key exceeds 1024 characters; the error's `key` discriminant carries the first 64 characters followed by an ellipsis so logs don't leak large payloads.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum length of 1024 characters is shared with `@suaveplan/storage-browser-opfs/key-sanitizer` and `@suaveplan/storage`'s own `AbstractStorage.validateKey` (where the limit is also 1024). Aligning the boundary across backends keeps the public contract predictable. The sanitiser does NOT normalise Windows `\` separators to POSIX `/` (the OPFS sanitiser does) because IndexedDB primary keys are opaque strings — there is no filesystem semantic to enforce — and quiet rewriting would surprise callers that compare keys later. The runtime-level `typeof` guard is defensive against JavaScript callers that pass non-strings (the TypeScript signature already excludes them at compile time); we preserve the check rather than rely on type erasure alone.
|
||||
@@ -0,0 +1,63 @@
|
||||
# OpenTelemetry instrument set
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `otel` module owns every metric and trace emitted by `@suaveplan/storage-browser-indexeddb`. Production paths inside `IndexedDbStorage` call `recordOp(...)` from this module after every public-method invocation, regardless of outcome — success, error, or aborted. Observability is built in at the boundary, not bolted on by consumers. The instrument cache is lazy so SDK consumers can install a custom `MeterProvider` / `TracerProvider` before the first operation runs.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 counters: `storage_browser_indexeddb.reads_total{op,outcome}`, `…writes_total{op,outcome}`, `…deletes_total{op,outcome}`, `…errors_total{op,outcome}`.
|
||||
- 2 histograms: `storage_browser_indexeddb.op_duration_ms{op,outcome}` (unit: `ms`) and `storage_browser_indexeddb.io_bytes{op,direction}` (unit: `By`).
|
||||
- Bounded attribute enums — `op` is one of the 14 documented method names, `outcome` is `"success" | "error" | "aborted"`, `direction` is `"read" | "write"`.
|
||||
- `setStorageBrowserIndexedDbMeterProvider(provider | undefined)` — override the global provider (tests use this with an in-memory recorder).
|
||||
- `resetInstruments()` — drop the instrument cache so the next `recordOp` rebuilds against a newly-installed provider.
|
||||
- `getInstruments(force?)` — direct access for advanced consumers that want to record bespoke samples on top of the standard surface.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserIndexedDbMeterProvider,
|
||||
} from "@suaveplan/storage-browser-indexeddb/otel";
|
||||
|
||||
setStorageBrowserIndexedDbMeterProvider(myInMemoryProvider);
|
||||
resetInstruments();
|
||||
|
||||
recordOp({ op: "set", outcome: "success", durationMs: 7, bytes: 1024, direction: "write" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { getInstruments, type RecordOpInput } from "@suaveplan/storage-browser-indexeddb/otel";
|
||||
|
||||
function recordBatched(records: RecordOpInput[]): void {
|
||||
const inst = getInstruments();
|
||||
for (const r of records) {
|
||||
inst.opDurationMs.record(r.durationMs, { op: r.op, outcome: r.outcome });
|
||||
if (r.bytes !== undefined && r.direction !== undefined) {
|
||||
inst.ioBytes.record(r.bytes, { op: r.op, direction: r.direction });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StorageBrowserIndexedDbOp` — `"get" | "set" | "delete" | "exists" | "list" | "clear" | "getMetadata" | "getWithMetadata" | "getSize" | "getPartial" | "setPartial" | "getItemSize" | "createReadStream" | "createWriteStream"`.
|
||||
- `StorageBrowserIndexedDbOpOutcome` — `"success" | "error" | "aborted"`.
|
||||
- `StorageBrowserIndexedDbInstruments` — `{ tracer, meter, readsTotal, writesTotal, deletesTotal, errorsTotal, opDurationMs, ioBytes }`.
|
||||
- `getInstruments(force?: boolean): StorageBrowserIndexedDbInstruments` — returns the cached set; pass `force: true` to rebuild against a newly-installed provider.
|
||||
- `recordOp(input: RecordOpInput): void` — emit one complete observation. Increments exactly one of the four `*_total` counters per call and always records the duration histogram. Records `io_bytes` when both `bytes` and `direction` are supplied.
|
||||
- `setStorageBrowserIndexedDbMeterProvider(provider: MeterProvider | undefined): void` — replace the meter provider; clears the cache automatically.
|
||||
- `resetInstruments(): void` — drop the cache. Tests use this between cases to isolate observations.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The recorder uses a discriminated dispatch (`op === "get" || op === "getPartial" || op === "getWithMetadata" → readsTotal`, etc.) rather than a Map lookup; the discriminator is bounded and the dispatch is cheap. The `errors_total` counter takes precedence over the read/write/delete classification — an aborted `set` increments `errorsTotal{op: "set", outcome: "aborted"}` but not `writesTotal`. The `io_bytes` histogram is only recorded when both `bytes` and `direction` are present, which lets the caller suppress an emission cheaply for ops that don't transfer bytes (e.g. `exists`, `list`).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `registerIndexedDbProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`registerIndexedDbProvider` is the explicit entry point that wires the `IndexedDbStorage` class into the `@suaveplan/storage` factory under the canonical type slot `"indexeddb"`. The package never self-registers at module evaluation — consumers call this function once at app startup before invoking `createStorage({ type: "indexeddb", ... })`. The deferred registration pattern matches `@suaveplan/storage-browser-opfs/registry` and means `sideEffects: false` bundlers tree-shake the registry module away when the consumer chooses to instantiate `IndexedDbStorage` directly instead.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent registration — repeated calls are no-ops once the slot is occupied.
|
||||
- Re-registration after `clearStorageProviders()` — the slot is freshly registered without throwing.
|
||||
- Module-augmentation of `@suaveplan/storage/factory` extends the typed `StorageProviderRegistry` and `StorageProviderConfig` interfaces so `createStorage({ type: "indexeddb", config: { ... } })` is typesafe.
|
||||
- Zero side effects on import — the file only mutates the factory when `registerIndexedDbProvider()` is called.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { registerIndexedDbProvider } from "@suaveplan/storage-browser-indexeddb/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerIndexedDbProvider();
|
||||
const storage = createStorage({ type: "indexeddb", config: { dbName: "myapp" } });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { registerIndexedDbProvider } from "@suaveplan/storage-browser-indexeddb/registry";
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
import {
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
|
||||
// Register both backends at startup; consumers pick one at runtime.
|
||||
registerOpfsProvider();
|
||||
registerIndexedDbProvider();
|
||||
|
||||
function makeStorage(prefer: "opfs" | "indexeddb") {
|
||||
if (prefer === "opfs" && hasStorageProvider("opfs")) {
|
||||
return createStorage({ type: "opfs", config: { directory: "myapp" } });
|
||||
}
|
||||
return createStorage({ type: "indexeddb", config: { dbName: "myapp" } });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerIndexedDbProvider(): void` — Registers `IndexedDbStorage` against the `@suaveplan/storage` factory under `"indexeddb"`. No-op when the slot is already occupied. Throws nothing — failures to import the storage class itself would have surfaced at the consumer's module-graph build time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module augmentation lives in this file because the factory's typed `StorageProviderRegistry` and `StorageProviderConfig` interfaces are open for extension; co-locating the augmentation with the registration keeps both halves in sync. The registration cast (`as unknown as new (config?: unknown) => IndexedDbStorage`) is necessary because `StorageProviderImplementation` in the factory uses a loose constructor shape that the strict `IndexedDbStorage` signature doesn't structurally match — the runtime call still passes the correct `IndexedDbStorageOptions` through. This deviation from the upstream `storage-browser` package (which used a side-effect import) is intentional and matches the OPFS sibling pattern; the OpenSpec proposal documents the deferred registration as a deliberate departure from the upstream behavior.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `IdbFactoryLike` + friends
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the structural surface that the IndexedDB adapter consumes from a browser-shaped `IDBFactory`. Defining a local interface — rather than depending on the global DOM `IDBFactory` type — lets the package compile cleanly under a Node-only `tsconfig.lib.json` (no DOM lib in `types`), keeps the runtime path testable with `fake-indexeddb` without forcing a global `vi.stubGlobal`, and documents exactly how much of the IndexedDB surface the adapter relies on. Callers pass a real factory at construction time, so the package never reaches for `globalThis.indexedDB` until a method that needs it is invoked.
|
||||
|
||||
## Features
|
||||
|
||||
- `IdbFactoryLike` — minimal `{ open(name, version?): IdbOpenDbRequestLike }` shape.
|
||||
- `IdbOpenDbRequestLike`, `IdbVersionChangeEventLike`, `IdbDatabaseLike`, `IdbTransactionLike`, `IdbObjectStoreLike`, `IdbRequestLike`, `IdbCursorWithValueLike` — each exposes only the members the adapter needs.
|
||||
- Compatible with both the production W3C `IDBFactory` and the `fake-indexeddb` test factory — structural typing means no nominal cast is necessary.
|
||||
- Zero runtime cost — types only; the file disappears from the emitted JS.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { IdbFactoryLike } from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
function build(factory: IdbFactoryLike): void {
|
||||
const req = factory.open("my-db", 1);
|
||||
req.onsuccess = () => {
|
||||
req.result.close();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
IdbFactoryLike,
|
||||
IdbOpenDbRequestLike,
|
||||
} from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
function awaitOpen(factory: IdbFactoryLike, name: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req: IdbOpenDbRequestLike = factory.open(name, 1);
|
||||
req.onsuccess = () => {
|
||||
req.result.close();
|
||||
resolve();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IdbFactoryLike` — `open(name, version?)` returns `IdbOpenDbRequestLike`.
|
||||
- `IdbOpenDbRequestLike` — request shape exposing `result`, `error`, and the `onsuccess` / `onerror` / `onupgradeneeded` / `onblocked` callback slots.
|
||||
- `IdbVersionChangeEventLike` — event shape passed to `onupgradeneeded` with `oldVersion` / `newVersion`.
|
||||
- `IdbDatabaseLike` — `name`, `version`, `objectStoreNames.contains(...)`, `close()`, `transaction(stores, mode)`, `createObjectStore(...)`.
|
||||
- `IdbTransactionLike` — `objectStore(name)`, `oncomplete` / `onerror` / `onabort` slots, `abort()`, `error`.
|
||||
- `IdbObjectStoreLike` — `put`, `get`, `delete`, `clear`, `count`, `openCursor`, `createIndex`, `index`.
|
||||
- `IdbRequestLike` — `result`, `error`, `onsuccess` / `onerror`.
|
||||
- `IdbCursorWithValueLike` — `key`, `value`, `continue()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface deliberately mirrors the W3C `IDBFactory` member names so callers who already know IndexedDB recognise the surface. Members not used by this adapter (transactions' `objectStoreNames`, request's `transaction` back-pointer, durability hints introduced in newer IndexedDB drafts) are intentionally omitted to keep the surface minimal and to expose intentional dependencies during reviews. If a new feature needs a new IDB API, extend this interface in the same change — never reach for the global DOM types from the adapter to avoid coupling the package's compile graph to a DOM lib that browser-tier consumers may have customised.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `IndexedDbStorageOptions` + `IndexedDbStorageOptionsSchema`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the public option surface for `IndexedDbStorage` along with the Zod schema that validates it at the constructor boundary. The split between a TypeScript interface and a Zod schema keeps the compile-time signature ergonomic while still hard-failing malformed runtime input (e.g. a `dbName: 123` slipping through a JavaScript caller). Every field is optional — the package ships sane defaults so consumers can `new IndexedDbStorage()` with no arguments in a browser tier.
|
||||
|
||||
## Features
|
||||
|
||||
- `IndexedDbStorageOptions` — public configuration with `dbName`, `storeName`, `version`, structurally-injectable `indexedDB` factory, and structurally-injectable `clock`.
|
||||
- `IndexedDbStorageOptionsSchema` — strict Zod object schema with `min(1)` on string fields and `int().positive()` on `version`.
|
||||
- Adapter fields (`indexedDB`, `clock`) declared as `z.unknown().optional()` so the runtime check accepts any structurally compatible implementation without re-implementing TypeScript's structural-typing rules.
|
||||
- `IndexedDbEntry` — the internal on-disk shape; documented here so downstream tooling (migration scripts, JSON exporters) can construct compatible records.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { IndexedDbStorageOptions } from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
const options: IndexedDbStorageOptions = { dbName: "myapp", storeName: "kv" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
IndexedDbStorageOptionsSchema,
|
||||
type IndexedDbStorageOptions,
|
||||
} from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
function parseOptions(input: unknown): IndexedDbStorageOptions {
|
||||
const parsed = IndexedDbStorageOptionsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`bad options: ${parsed.error.message}`);
|
||||
}
|
||||
// `parsed.data` is `IndexedDbStorageOptions` (Zod inferred).
|
||||
return parsed.data as IndexedDbStorageOptions;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IndexedDbEntry` — interface. `{ key: string; data: Uint8Array; metadata: StorageMetadata; expiresAt: number | undefined }`. The on-disk shape persisted in the object store.
|
||||
- `IndexedDbStorageOptions` — interface. All fields optional. Fields: `dbName?: string`, `storeName?: string`, `version?: number`, `indexedDB?: IdbFactoryLike`, `clock?: Clock`.
|
||||
- `IndexedDbStorageOptionsSchema` — Zod schema. `safeParse` returns `success: true` for valid input. The `IndexedDbStorage` constructor invokes `safeParse` and throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on failure, preserving the Zod issue list as `cause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`indexedDB` and `clock` cannot be validated structurally by Zod without re-encoding their interfaces, which would couple the schema to its consumer surface and make changes to either type a two-place edit. Using `z.unknown().optional()` is the established pattern in `@suaveplan/storage-browser-opfs/types/opfs-storage.types.ts`. The TypeScript signature on `IndexedDbStorageOptions` is what callers see in their IDE — the schema is purely a runtime guard against malformed JSON or JavaScript-side mistakes. `IndexedDbEntry` is not validated by a Zod schema because it never crosses an external boundary; it is internal to the adapter's serialization path.
|
||||
@@ -0,0 +1,101 @@
|
||||
# @suaveplan/storage-browser-localstorage - Browser localStorage AbstractStorage adapter for @suaveplan/storage: namespaced base64 JSON envelopes, TTL expiry on read, AbortSignal everywhere, strict key sanitisation, DOMException → typed LocalStorageError mapping, full OTel surface.
|
||||
|
||||
Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storage with full `AbstractStorage` conformance, AbortSignal threading, Zod-validated options, structured `LocalStorageError` boundary, lazy TTL eviction, an injectable `StorageLike` adapter for testing, and a complete OpenTelemetry surface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
|
||||
- [local-storage.errors](./src/local-storage/local-storage.errors.md) — Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise in…
|
||||
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
|
||||
- [local-storage.ops](./src/local-storage/local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./src/local-storage/local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./src/local-storage/local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The regist…
|
||||
<!-- end:auto-docs -->
|
||||
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage.errors](./src/local-storage/local-storage.errors.md) — Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise in…
|
||||
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage.ops](./src/local-storage/local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./src/local-storage/local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./src/local-storage/local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The regist…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-browser-localstorage
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createLocalStorageStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
if (!isLocalStorageAvailable()) {
|
||||
throw new Error("This browser blocks localStorage (private mode?)");
|
||||
}
|
||||
const storage = createLocalStorageStorage({ namespace: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
```
|
||||
|
||||
## Factory integration
|
||||
|
||||
```ts
|
||||
import { registerLocalStorageProvider } from "@suaveplan/storage-browser-localstorage/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerLocalStorageProvider();
|
||||
const storage = createStorage({
|
||||
type: "localstorage",
|
||||
config: { namespace: "myapp" },
|
||||
});
|
||||
```
|
||||
|
||||
The package does NOT self-register at module-evaluation time — call `registerLocalStorageProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Value | Notes |
|
||||
|---|---|---|
|
||||
| `partialReads` | `false` | `getPartial` is implemented via the `AbstractStorage` reference helper (load + slice). |
|
||||
| `partialWrites` | `false` | `setPartial` is implemented via the `AbstractStorage` reference helper (load + splice + write). |
|
||||
| `nativeStreams` | `false` | `createReadStream` / `createWriteStream` use the `AbstractStorage` default buffered implementations. |
|
||||
| `atomicWrites` | `true` | A single `setItem` is atomic at the DOM API level. |
|
||||
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes.ts`](./src/error-codes.md) — `LOCAL_STORAGE_CODES` registry + `LocalStorageError`.
|
||||
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
|
||||
- [`src/envelope/envelope.ts`](./src/envelope/envelope.md) — base64 + JSON wire format.
|
||||
- [`src/local-storage-adapter/local-storage-adapter.ts`](./src/local-storage-adapter/local-storage-adapter.md) — `defaultLocalStorage()` + `isLocalStorageAvailable()`.
|
||||
- [`src/local-storage/local-storage.ts`](./src/local-storage/local-storage.md) — the `LocalStorageStorage` class.
|
||||
- [`src/local-storage/local-storage.errors.ts`](./src/local-storage/local-storage.errors.md) — DOMException mapping.
|
||||
- [`src/local-storage/local-storage.ops.ts`](./src/local-storage/local-storage.ops.md) — namespacing + pagination helpers.
|
||||
- [`src/local-storage/local-storage.partial.ts`](./src/local-storage/local-storage.partial.md) — argument validation.
|
||||
- [`src/local-storage/local-storage.slot.ts`](./src/local-storage/local-storage.slot.md) — option resolution + envelope IO.
|
||||
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
|
||||
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerLocalStorageProvider()`.
|
||||
- [`src/in-memory-storage/in-memory-storage.ts`](./src/in-memory-storage/in-memory-storage.md) — real-implementation test double, exposed under `./in-memory-storage` for downstream test packages.
|
||||
- [`src/types/storage-like.types.ts`](./src/types/storage-like.types.md) — structural DOM Storage surface.
|
||||
- [`src/types/local-storage-storage.types.ts`](./src/types/local-storage-storage.types.md) — option boundary.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `envelope` — base64 JSON wire format
|
||||
|
||||
### Purpose
|
||||
|
||||
The `localStorage` API only stores string values, but `AbstractStorage` deals in `Uint8Array`. The envelope module encodes binary payloads as base64 inside a JSON wrapper that also carries the full `StorageMetadata` record (size, timestamps, optional caller `metadata` bag) and an optional `expiresAt` TTL deadline. Centralising the wire format prevents drift between writers and readers, keeps the serialised representation forward-compatible via the `v` field, and lets corruption be detected as a typed boundary error rather than swallowed silently as `null` (which is how the upstream implementation behaved).
|
||||
|
||||
### Features
|
||||
|
||||
- Versioned envelope schema (`v: 1` today) — version mismatches raise `LOCAL_STORAGE.CORRUPT_PAYLOAD` instead of being silently re-interpreted.
|
||||
- Standard base64 via `btoa` / `atob` for portability across browsers and Node.
|
||||
- Metadata-preserving — `size`, `createdAt`, `modifiedAt`, `expiresAt?`, and the caller's `metadata?` bag round-trip byte-exact.
|
||||
- Optional `expiresAt` millisecond Unix timestamp surfaces TTL on the wire so any reader (current or future-versioned) can prune expired rows.
|
||||
- Pure exports for callers that want byte ↔ base64 conversion without the envelope wrapper.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeEnvelope,
|
||||
encodeEnvelope,
|
||||
} from "@suaveplan/storage-browser-localstorage/envelope";
|
||||
|
||||
const raw = encodeEnvelope(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
{ size: 3, createdAt: 0, modifiedAt: 0 },
|
||||
undefined
|
||||
);
|
||||
const { value, metadata } = decodeEnvelope(raw, "k");
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
decodeEnvelope,
|
||||
encodeEnvelope,
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const bytes = new Uint8Array([0, 255, 128, 0, 1]);
|
||||
const b64 = bytesToBase64(bytes);
|
||||
const wire = encodeEnvelope(
|
||||
bytes,
|
||||
{
|
||||
size: bytes.byteLength,
|
||||
createdAt: Date.now(),
|
||||
modifiedAt: Date.now(),
|
||||
metadata: { mimeType: "application/octet-stream", tag: "demo" },
|
||||
},
|
||||
Date.now() + 60_000
|
||||
);
|
||||
|
||||
try {
|
||||
const parsed = decodeEnvelope(wire, "demo/k");
|
||||
console.log(parsed.metadata.metadata);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
) {
|
||||
// Telemetry hook: another tool wrote the slot.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `interface Envelope` — `{ v, payload, meta, expiresAt? }` shape.
|
||||
- `bytesToBase64(bytes: Uint8Array): string` — char-by-char `btoa` avoiding the `fromCharCode(...largeArray)` stack-overflow trap.
|
||||
- `base64ToBytes(encoded: string): Uint8Array` — `atob` + charcode unpack. Throws on malformed input.
|
||||
- `encodeEnvelope(bytes, metadata, expiresAt): string` — JSON-stringifies a fully-populated envelope.
|
||||
- `decodeEnvelope(raw, key): { value, metadata, expiresAt? }` — parses and validates; throws `LocalStorageError(LOCAL_STORAGE.CORRUPT_PAYLOAD)` with the offending `key` on any structural defect.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The base64 round-trip uses `btoa`/`atob` rather than `Buffer.from(...)` because the package's `browser` field targets the DOM realm — Node also exposes both globals from v18+ so the same code path runs in both environments without conditional import. Char-by-char encoding (`String.fromCharCode(bytes[i])`) is intentional: passing a large `Uint8Array` to `String.fromCharCode(...bytes)` blows the call stack on payloads over ~64 KiB in many engines. The decoder explicitly types every short-circuit failure (`v` mismatch, missing `payload`, non-object `meta`, malformed base64) as `CORRUPT_PAYLOAD` so callers downstream can distinguish "this slot was overwritten by another tool" from "infrastructure broke."
|
||||
@@ -0,0 +1,68 @@
|
||||
## `error-codes` — LocalStorage error registry
|
||||
|
||||
### Purpose
|
||||
|
||||
Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The registry uses `defineCodes("LOCAL_STORAGE", [...])` from `@suaveplan/error` so each code is namespaced (`LOCAL_STORAGE.QUOTA_EXCEEDED`, `LOCAL_STORAGE.CORRUPT_PAYLOAD`, etc.), frozen at module load, and surfaced through a typed `LocalStorageErrorCode` union. Centralising the registry keeps the boundary vocabulary tiny, prevents drift between the implementation and the spec, and lets callers pattern-match on a stable string code rather than parsing message text.
|
||||
|
||||
### Features
|
||||
|
||||
- Ten frozen codes covering every observable failure mode: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `CORRUPT_PAYLOAD`.
|
||||
- `LocalStorageError extends SuaveplanError<LocalStorageErrorCode>` with an optional `key` discriminant for log correlation.
|
||||
- `cause` chain preserved from upstream `DOMException` instances (no message-text parsing required downstream).
|
||||
- TypeScript-narrowed `LocalStorageErrorCode` union for exhaustive switches.
|
||||
- `CORRUPT_PAYLOAD` distinguishes "another tool overwrote the slot with garbage" from `IO_FAILED` / `NOT_FOUND`, allowing telemetry to separate genuine corruption from infrastructure failure.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
throw new LocalStorageError(LOCAL_STORAGE_CODES.NOT_FOUND, "missing entry", {
|
||||
key: "user/42",
|
||||
});
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
type LocalStorageErrorCode,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function handle(err: unknown): "retry" | "fail" | "ignore" {
|
||||
if (!(err instanceof LocalStorageError)) return "fail";
|
||||
const code: LocalStorageErrorCode = err.code;
|
||||
switch (code) {
|
||||
case LOCAL_STORAGE_CODES.NOT_FOUND:
|
||||
return "ignore";
|
||||
case LOCAL_STORAGE_CODES.QUOTA_EXCEEDED:
|
||||
case LOCAL_STORAGE_CODES.IO_FAILED:
|
||||
return "retry";
|
||||
case LOCAL_STORAGE_CODES.PERMISSION_DENIED:
|
||||
case LOCAL_STORAGE_CODES.UNAVAILABLE:
|
||||
return "fail";
|
||||
case LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD:
|
||||
return "fail";
|
||||
case LOCAL_STORAGE_CODES.INVALID_KEY:
|
||||
case LOCAL_STORAGE_CODES.KEY_TOO_LONG:
|
||||
case LOCAL_STORAGE_CODES.INVALID_OPTIONS:
|
||||
case LOCAL_STORAGE_CODES.ABORTED:
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `LOCAL_STORAGE_CODES` — frozen `Record<string, string>` mapping each short name to its dotted form (`"LOCAL_STORAGE.NOT_FOUND"`).
|
||||
- `LocalStorageErrorCode` — `(typeof LOCAL_STORAGE_CODES)[keyof typeof LOCAL_STORAGE_CODES]` union.
|
||||
- `class LocalStorageError extends SuaveplanError<LocalStorageErrorCode>` — adds optional `key?: string`. Constructor signature: `new LocalStorageError(code, message, options?)` where `options` may carry `cause` and `key`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The registry is constructed via `defineCodes` so the strings are validated at module load (preventing duplicate slot collisions across the project's packages). The `key` discriminant is intentionally not part of the registry — it is per-throw metadata; some failure modes (`UNAVAILABLE`, `INVALID_OPTIONS`) have no key to attach.
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `in-memory-storage` — real-implementation StorageLike fixture
|
||||
|
||||
### Purpose
|
||||
|
||||
`InMemoryStorage` is a real, dependency-free implementation of the DOM `Storage` interface (narrowed to `StorageLike`) used by the package's own test suite and exported under `./in-memory-storage` so downstream test packages can run against the same fixture without re-implementing the (subtle) DOM semantics. the test suite bans `vi.mock` / `vi.stubGlobal` — every test must run against a real implementation; this module is that real implementation. It also supports optional quota enforcement (to drive the `QUOTA_EXCEEDED` path) and `SecurityError` injection (to drive the `PERMISSION_DENIED` path) without resorting to monkey-patching the global.
|
||||
|
||||
### Features
|
||||
|
||||
- `length`, `key(i)`, `getItem`, `setItem`, `removeItem`, `clear` with DOM-compatible semantics.
|
||||
- Insertion-order iteration matching the WHATWG `Storage` contract.
|
||||
- Optional `quotaBytes` cap — when set, `setItem` throws `QuotaExceededDomException` (`name === "QuotaExceededError"`, `code === 22`) once the projected byte sum would exceed the cap.
|
||||
- Optional `securityError` flag — every `setItem` throws `SecurityErrorDomException` (`name === "SecurityError"`, `code === 18`), mimicking Safari private mode.
|
||||
- Side-effect free constructor; safe to instantiate multiple times concurrently.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("k", "v");
|
||||
console.log(storage.getItem("k")); // "v"
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryLocalStorage,
|
||||
QuotaExceededDomException,
|
||||
SecurityErrorDomException,
|
||||
} from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const cramped = createInMemoryLocalStorage({ quotaBytes: 16 });
|
||||
try {
|
||||
cramped.setItem("longish-key", "x".repeat(50));
|
||||
} catch (err) {
|
||||
console.log(err instanceof QuotaExceededDomException); // true
|
||||
}
|
||||
|
||||
const locked = createInMemoryLocalStorage({ securityError: true });
|
||||
try {
|
||||
locked.setItem("k", "v");
|
||||
} catch (err) {
|
||||
console.log(err instanceof SecurityErrorDomException); // true
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `createInMemoryLocalStorage(options?): InMemoryStorage` — preferred entry point.
|
||||
- `class InMemoryStorage implements StorageLike` — Map-backed with insertion-order semantics.
|
||||
- `class QuotaExceededDomException extends Error` — `name = "QuotaExceededError"`, `code = 22`.
|
||||
- `class SecurityErrorDomException extends Error` — `name = "SecurityError"`, `code = 18`.
|
||||
- `interface InMemoryStorageOptions` — `{ quotaBytes?, securityError? }`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The fixture uses a `Map<string, string>` for storage so `length`, `key(i)`, and iteration order all match DOM `Storage` insertion-order semantics. The quota probe is intentionally pessimistic: it recomputes the full byte sum on each `setItem` rather than maintaining a running counter, so the test is robust against `delete`-then-`set` interleaving without complex bookkeeping. `code === 22` (`QuotaExceededError`) is the modern WebKit/Chromium value; Firefox uses `code === 1014` for the same condition — the production error mapper checks both. The `key(i)` method's final `return null` after the loop is unreachable because the earlier `index >= size` short-circuit covers the out-of-range case, and the loop completes by returning the matching key — it is marked with a `c8 ignore` comment because Istanbul can't statically prove the unreachability.
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `key-sanitizer` — strict caller-key boundary
|
||||
|
||||
### Purpose
|
||||
|
||||
`sanitizeKey(key)` is the single validation funnel through which every caller-supplied key passes before any `localStorage` API call is issued. It enforces the storage-browser-localstorage boundary contract: keys must be non-empty strings of bounded length, must not contain control characters or path-traversal segments, must not be absolute, and must use POSIX separators internally. Centralising this guard prevents adapter callers from accidentally exfiltrating data outside the configured namespace or constructing wire keys that collide with sibling tools sharing the origin's `localStorage`.
|
||||
|
||||
### Features
|
||||
|
||||
- Rejects empty strings, non-string input, keys over 256 chars, control chars (0x00-0x1F, 0x7F), absolute POSIX paths, and `..` traversal segments.
|
||||
- Normalises Windows `\` separators to POSIX `/` so cross-platform callers see consistent layout.
|
||||
- Throws typed `LocalStorageError(LOCAL_STORAGE.INVALID_KEY | LOCAL_STORAGE.KEY_TOO_LONG)` with the offending key captured for log correlation.
|
||||
- Pure / synchronous / dependency-free — safe to call in hot paths.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-localstorage/key-sanitizer";
|
||||
|
||||
const safe = sanitizeKey("orders/2026/05/12.json");
|
||||
// → "orders/2026/05/12.json"
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
sanitizeKey,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function safeKeyOrNull(input: unknown): string | null {
|
||||
if (typeof input !== "string") return null;
|
||||
try {
|
||||
return sanitizeKey(input);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof LocalStorageError &&
|
||||
(err.code === LOCAL_STORAGE_CODES.INVALID_KEY ||
|
||||
err.code === LOCAL_STORAGE_CODES.KEY_TOO_LONG)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
`sanitizeKey(key: string): string`
|
||||
|
||||
- **Throws** `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` for empty / non-string / control-char / absolute / traversal-segment input.
|
||||
- **Throws** `LocalStorageError(LOCAL_STORAGE.KEY_TOO_LONG)` for keys with `length > 256`.
|
||||
- **Returns** the normalised POSIX-separator key on success.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The control-char scan is a single linear pass over `charCodeAt(i)` — it costs less than a regex compile for typical key sizes. The traversal check splits on `/` after backslash normalisation to ensure forms like `a\..\b` are rejected, not just `a/../b`. The 256-char cap is tighter than the OPFS adapter's 1024: `localStorage` enumeration cost is O(n) over all slot keys, and tighter caps reduce worst-case `list()` scan time when collisions are deliberately crafted by a noisy tab.
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `local-storage-adapter` — global resolver + availability probe
|
||||
|
||||
### Purpose
|
||||
|
||||
`defaultLocalStorage()` returns a `LocalStorageAdapter` that lazily resolves `globalThis.localStorage` on every public-method call. Lazy resolution is critical because the package is published as universal/browser-tier and `globalThis.localStorage` is absent in Node, Workers without a storage shim, and ServiceWorker realms — eagerly reading the global at module load would either throw a `TypeError` on missing-property access or capture an undefined snapshot and freeze it for the lifetime of the import. `isLocalStorageAvailable()` is the companion feature detector that also accounts for Safari private mode, where `localStorage` exists on the global but every `setItem` throws `SecurityError` — the only reliable detection is a sentinel write/remove probe.
|
||||
|
||||
### Features
|
||||
|
||||
- Lazy `read()` — `globalThis.localStorage` is looked up at first method call, never at import.
|
||||
- Typed `LOCAL_STORAGE.UNAVAILABLE` error when the global is missing.
|
||||
- `isLocalStorageAvailable(storage?)` sentinel probe — writes + removes a probe key (`__suaveplan_localstorage_probe__`) to detect Safari private mode.
|
||||
- The probe restores the storage to its prior state on success (no side effects observable to other consumers).
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage-adapter";
|
||||
|
||||
if (isLocalStorageAvailable()) {
|
||||
const adapter = defaultLocalStorage();
|
||||
adapter.read().setItem("hello", "world");
|
||||
}
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryLocalStorage,
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
LocalStorageError,
|
||||
LOCAL_STORAGE_CODES,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
// Force-test the unavailable path under Node:
|
||||
try {
|
||||
defaultLocalStorage().read();
|
||||
} catch (err) {
|
||||
console.log(
|
||||
err instanceof LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_CODES.UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
// Probe a custom StorageLike — useful when wrapping platform shims:
|
||||
const fixture = createInMemoryLocalStorage({ securityError: true });
|
||||
console.log(isLocalStorageAvailable(fixture)); // false
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `defaultLocalStorage(): LocalStorageAdapter` — returns `{ read() }`; each `read()` throws `LocalStorageError(LOCAL_STORAGE.UNAVAILABLE)` if `globalThis.localStorage` is missing.
|
||||
- `isLocalStorageAvailable(storage?: StorageLike): boolean` — returns `true` only when storage is present AND sentinel write succeeds; defaults to `globalThis.localStorage` when no argument is provided.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The sentinel key is namespaced with a leading + trailing double-underscore (`__suaveplan_localstorage_probe__`) so it cannot collide with the package's own namespaced keys (`suaveplan:storage:...`) or any reasonable application key. The probe deliberately swallows ALL exceptions — Safari private mode throws `SecurityError`, but quota-exceeded realms throw `QuotaExceededError`, and some test-runner shims throw `TypeError` for "not implemented." All three should report "unavailable" because the adapter cannot meaningfully use the storage. The lazy resolver pattern matches the OPFS adapter sibling — both packages defer global lookups until first method call so universal consumers can `import` them safely.
|
||||
@@ -0,0 +1,66 @@
|
||||
## `local-storage.errors` — DOMException mapping
|
||||
|
||||
### Purpose
|
||||
|
||||
Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise inconsistent error shapes for the same logical failure — Safari WebKit uses `QuotaExceededError` (name) + `code === 22`, Firefox uses `code === 1014`, and Safari private mode raises `SecurityError` for what is conceptually "the storage is locked." Centralising the translation here lets `LocalStorageStorage` raise a single error class with stable codes, while preserving the underlying `cause` for telemetry.
|
||||
|
||||
### Features
|
||||
|
||||
- `isQuotaExceeded(err)` — `true` for any of the three browser variants.
|
||||
- `mapLocalStorageError(err, key?)` — translates to `LocalStorageError` with code routing: quota → `QUOTA_EXCEEDED`, security/not-allowed → `PERMISSION_DENIED`, anything else → `IO_FAILED`.
|
||||
- `assertNotAborted(signal, key?)` — pre-aborted signal guard raising `LOCAL_STORAGE.ABORTED`.
|
||||
- Pre-existing `LocalStorageError` instances pass through untouched (so `defaultLocalStorage()`'s `UNAVAILABLE` survives the wrap).
|
||||
- Optional `key` discriminant carried into the resulting error for log correlation.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapLocalStorageError } from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
try {
|
||||
throw Object.assign(new Error("full"), { name: "QuotaExceededError" });
|
||||
} catch (err) {
|
||||
throw mapLocalStorageError(err, "user/42");
|
||||
}
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNotAborted,
|
||||
isQuotaExceeded,
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
mapLocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function safeWrite(
|
||||
storage: { setItem(k: string, v: string): void },
|
||||
key: string,
|
||||
value: string,
|
||||
signal?: AbortSignal
|
||||
): void {
|
||||
assertNotAborted(signal, key);
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch (err) {
|
||||
const mapped = mapLocalStorageError(err, key);
|
||||
if (mapped.code === LOCAL_STORAGE_CODES.QUOTA_EXCEEDED) {
|
||||
// Caller may now LRU-evict and retry.
|
||||
}
|
||||
throw mapped;
|
||||
}
|
||||
}
|
||||
console.log(isQuotaExceeded(Object.assign(new Error(), { code: 1014 })));
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `isQuotaExceeded(err: unknown): boolean` — checks `name === "QuotaExceededError"` AND legacy `code === 22` AND Firefox `code === 1014`.
|
||||
- `mapLocalStorageError(err: unknown, key: string | undefined): LocalStorageError` — pass-through for `LocalStorageError`; quota → `QUOTA_EXCEEDED`; security/not-allowed → `PERMISSION_DENIED`; otherwise → `IO_FAILED` preserving `cause`.
|
||||
- `assertNotAborted(signal: AbortSignal | undefined, key: string | undefined): void` — throws `LOCAL_STORAGE.ABORTED` when `signal.aborted === true`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The dual `code === 22 || code === 1014` check is critical — Firefox-only consumers reported "quota errors map to generic IO failures" before this branch existed in the upstream library; the migration captures both at the boundary. The `name === "NotAllowedError"` branch covers OPFS-flavored permission-denied errors that some test harnesses produce; mapping them to `PERMISSION_DENIED` keeps the storage adapters consistent with their OPFS sibling. The unknown-error branch uses the `err.message` (when `err instanceof Error`) inside the wrapped message so logs surface both the package code AND the original exception text without forcing dashboards to unwrap `cause`.
|
||||
@@ -0,0 +1,88 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
|
||||
- [local-storage.ops](./local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
- [local-storage.ops](./local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
|
||||
## `local-storage` — LocalStorageStorage adapter
|
||||
|
||||
### Purpose
|
||||
|
||||
`LocalStorageStorage` is the `AbstractStorage` implementation that backs onto the synchronous DOM `Storage` API. The DOM `Storage` interface is whole-value, string-only, and synchronous; this class wraps it in the async `Uint8Array` contract that the rest of the storage family expects, while also adding TTL expiry, namespaced keyspace isolation, AbortSignal threading, structured errors, and full OpenTelemetry instrumentation. It is the smallest viable storage backend — every browser supports it, no quota arbitration is needed, and the round-trip envelope keeps user data intact through `set`/`get` mutations.
|
||||
|
||||
### Features
|
||||
|
||||
- Full `AbstractStorage` conformance — every abstract method implemented.
|
||||
- Capabilities matrix: `partialReads: false`, `partialWrites: false`, `nativeStreams: false`, `atomicWrites: true`, `contentAddressable: false`.
|
||||
- Namespaced wire keys (`${namespace}:${key}`) — sibling tools on the same origin cannot collide.
|
||||
- Lazy TTL eviction on every `get`/`exists`/`getMetadata`/`getWithMetadata`/`list` path.
|
||||
- AbortSignal pre-abort check on every public method (raises `LOCAL_STORAGE.ABORTED`).
|
||||
- Injectable `Storage` adapter (`LocalStorageAdapter.read()`) — production points at `globalThis.localStorage`, tests inject `createInMemoryLocalStorage()`.
|
||||
- Injectable `Clock` for deterministic TTL math.
|
||||
- Quota errors mapped from WebKit (`code 22`) AND Firefox (`code 1014`) AND modern `name === "QuotaExceededError"`.
|
||||
- `set` preserves `createdAt` across updates while bumping `modifiedAt`.
|
||||
- Full OTel surface — every method routes through `recordOp` for `op_duration_ms` + `*_total` counters + `io_bytes` histogram.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const storage = createLocalStorageStorage();
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const value = await storage.get("user/42");
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const inMemory = createInMemoryLocalStorage({ quotaBytes: 8192 });
|
||||
const clock = (() => {
|
||||
let now = 1700000000000;
|
||||
return () => now;
|
||||
})();
|
||||
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "myapp:v2",
|
||||
storage: { read: () => inMemory },
|
||||
clock,
|
||||
});
|
||||
|
||||
await storage.set(
|
||||
"doc/intro",
|
||||
new TextEncoder().encode("hello world"),
|
||||
{ ttl: 60, metadata: { mimeType: "text/plain", author: "me" } }
|
||||
);
|
||||
|
||||
const result = await storage.getWithMetadata("doc/intro");
|
||||
console.log(result?.metadata.metadata?.mimeType, result?.metadata.metadata?.author);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await storage.get("doc/intro", ac.signal).catch((err) => err.code);
|
||||
// "LOCAL_STORAGE.ABORTED"
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `class LocalStorageStorage extends AbstractStorage` — implements `get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`.
|
||||
- `new LocalStorageStorage(options?)` — accepts `LocalStorageStorageOptions` (`{ namespace?, storage?, clock? }`); throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
|
||||
- `createLocalStorageStorage(options?): LocalStorageStorage` — factory equivalent.
|
||||
|
||||
Method-level behaviour:
|
||||
|
||||
- `set` accepts `StorageOptions` — `ttl` (seconds, optional) is stamped into the envelope as `expiresAt`; the caller's `metadata` bag is preserved verbatim on the resulting `StorageMetadata.metadata` field.
|
||||
- `getPartial` / `setPartial` use the `AbstractStorage` reference helpers; `getPartial` returns an empty `Uint8Array` when `offset` is past EOF (matching the AbstractStorage contract).
|
||||
- `createReadStream` / `createWriteStream` use the `AbstractStorage` defaults — single-chunk emit for read, buffered close-on-commit for write.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The `#wrap` helper centralises the start-timer/record-outcome pattern so every public method emits exactly one duration histogram sample. `recordBytes` is a sibling helper for the success-path byte-count records on `get`/`set`/`getPartial` — those go through a separate `recordOp` call after the wrap because the wrap measures end-to-end latency, while `recordBytes` measures payload size and runs inside the success branch. Lazy TTL eviction lives in `readEnvelope` (in the slot module) — every read-flavour method funnels through it, so expired rows are also gone from `list()` results in a single pass without a separate sweep. The `setPartial` path uses `AbstractStorage.defaultSetPartial` (read-modify-write) because the underlying `Storage` API has no random-access write — the capabilities matrix correctly advertises `partialWrites: false` so consumers can choose a different backend when partial writes are hot.
|
||||
@@ -0,0 +1,60 @@
|
||||
## `local-storage.ops` — namespacing + pagination helpers
|
||||
|
||||
### Purpose
|
||||
|
||||
`localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` partitions that space using a configurable namespace prefix so a sibling library, browser extension, or another app on the same origin never collides with the adapter's entries. This module owns the two operations that depend on the namespace boundary: building/parsing wire keys, and collecting + paginating the caller-key view exposed through `AbstractStorage.list()`.
|
||||
|
||||
### Features
|
||||
|
||||
- `buildWireKey(namespace, key)` — the canonical `${namespace}:${key}` join.
|
||||
- `isOwnedWireKey(wire, namespace)` — strict prefix match that rejects bare namespace-equal entries.
|
||||
- `stripNamespace(wire, namespace)` — unconditional `slice` once ownership is verified.
|
||||
- `collectOwnedKeys(storage, namespace)` — single O(n) walk of `localStorage.key(i)`.
|
||||
- `paginateKeys(keys, options?)` — applies `prefix` / `offset` / `limit` and returns a `StorageListResult` with accurate `total` + `hasMore`.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildWireKey,
|
||||
paginateKeys,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
const wire = buildWireKey("suaveplan:storage", "user/42");
|
||||
// "suaveplan:storage:user/42"
|
||||
|
||||
const page = paginateKeys(["a", "b", "c", "d"], { offset: 1, limit: 2 });
|
||||
// { keys: ["b", "c"], total: 4, hasMore: true }
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
collectOwnedKeys,
|
||||
isOwnedWireKey,
|
||||
paginateKeys,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
fixture.setItem("suaveplan:storage:a", "...");
|
||||
fixture.setItem("suaveplan:storage:user/42", "...");
|
||||
fixture.setItem("other-tool", "...");
|
||||
|
||||
const owned = collectOwnedKeys(fixture, "suaveplan:storage");
|
||||
const result = paginateKeys(owned, { prefix: "user/", limit: 10 });
|
||||
console.log(isOwnedWireKey("other-tool", "suaveplan:storage")); // false
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `buildWireKey(namespace: string, key: string): string` — emits `${namespace}:${key}`.
|
||||
- `isOwnedWireKey(wire: string, namespace: string): boolean` — true only when the wire key starts with `${namespace}:` AND the suffix is non-empty (rejecting `suaveplan:storage` written bare by an unrelated tool).
|
||||
- `stripNamespace(wire: string, namespace: string): string` — slice past `${namespace}:`.
|
||||
- `collectOwnedKeys(storage: StorageLike, namespace: string): string[]` — snapshot of caller keys in DOM insertion order; pure (no mutation of the storage).
|
||||
- `paginateKeys(keys: string[], options?: StorageListOptions): StorageListResult` — Sort + filter + slice. `hasMore` is `true` only when `limit` was provided AND there are more items beyond the slice.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
`isOwnedWireKey` requires the suffix length to be strictly positive (`wire.length > namespace.length + 1`) so a sibling tool that happens to write a key equal to the namespace string (`suaveplan:storage`) is not mis-attributed to the adapter. Walking `localStorage.key(i)` once and snapshotting into a `string[]` decouples the caller from the underlying iteration semantics — DOM `Storage` mutations during iteration are implementation-defined in many browsers, so we read once and operate on the snapshot. `paginateKeys` sorts the filtered subset rather than the full snapshot so prefix-heavy queries pay only the cost of the filtered window.
|
||||
@@ -0,0 +1,56 @@
|
||||
## `local-storage.partial` — argument validation helpers
|
||||
|
||||
### Purpose
|
||||
|
||||
`localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorage` therefore implements `getPartial` and `setPartial` on top of `AbstractStorage`'s reference helpers (load-the-whole-value, splice, write-it-back). The one validation we own at this layer is the boundary check on `offset` and `length` integer arguments; this module centralises the rule so the diagnostics are consistent across both `getPartial` and `setPartial` call sites.
|
||||
|
||||
### Features
|
||||
|
||||
- `assertNonNegInt(key, label, value)` — single rule, single error code (`LOCAL_STORAGE.INVALID_KEY`).
|
||||
- Carries the offending `key` AND the parameter `label` into the error message for log clarity.
|
||||
- Synchronous, dependency-free, safe to call in hot paths.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { assertNonNegInt } from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
assertNonNegInt("user/42", "offset", 0); // OK
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNonNegInt,
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function safeRange(key: string, offset: number, length: number): void {
|
||||
try {
|
||||
assertNonNegInt(key, "offset", offset);
|
||||
assertNonNegInt(key, "length", length);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
) {
|
||||
console.warn("rejecting bad range", err.message);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
`assertNonNegInt(key: string, label: string, value: number): void`
|
||||
|
||||
- **Throws** `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` when `value` is not a non-negative integer.
|
||||
- **Returns** nothing on success.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
Using `LOCAL_STORAGE.INVALID_KEY` for offset/length argument failures is deliberate — the boundary class is "caller passed a value that does not satisfy the adapter's contract"; adding a dedicated `INVALID_OFFSET` code would proliferate the registry without giving callers a more useful discriminant. Tests rely on `error-codes.ts` not growing this code; if a future refactor breaks that assumption, the spec-conformance test will fail.
|
||||
@@ -0,0 +1,59 @@
|
||||
## `local-storage.slot` — option resolution + envelope IO helpers
|
||||
|
||||
### Purpose
|
||||
|
||||
The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside the class body: option resolution against the Zod schema, metadata construction from caller input, and the two flavours of envelope read (`readEnvelope` with lazy TTL eviction, `peekMetadata` without). Factoring these out keeps `local-storage.ts` below the 300-line hard limit, lets each helper be tested independently against in-memory fixtures, and reduces the cognitive load on readers walking the main class.
|
||||
|
||||
### Features
|
||||
|
||||
- `resolveOptions` — Zod-validating boundary that throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
|
||||
- `buildMetadata` — composes `StorageMetadata` preserving the caller's `metadata` bag verbatim.
|
||||
- `readEnvelope` — decodes a slot, lazily removes expired entries, surfaces `CORRUPT_PAYLOAD` errors.
|
||||
- `peekMetadata` — non-destructive metadata-only read that swallows decode errors (used to preserve `createdAt` on update).
|
||||
- Pure functions — no class, no `this` capture, no global state.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { resolveOptions } from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
const cfg = resolveOptions({ namespace: "myapp" });
|
||||
// { namespace: "myapp", adapter: ..., clock: ... }
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildMetadata,
|
||||
peekMetadata,
|
||||
readEnvelope,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
import { encodeEnvelope } from "@suaveplan/storage-browser-localstorage/envelope";
|
||||
|
||||
const storage = createInMemoryLocalStorage();
|
||||
const meta = buildMetadata(10, 100, 100, { mimeType: "text/plain" });
|
||||
storage.setItem(
|
||||
"suaveplan:storage:k",
|
||||
encodeEnvelope(new Uint8Array(10), meta, undefined)
|
||||
);
|
||||
|
||||
const peeked = peekMetadata(storage, "suaveplan:storage", "k");
|
||||
const decoded = readEnvelope(storage, "suaveplan:storage", "k", 999);
|
||||
console.log(peeked?.size === 10, decoded?.value.length === 10);
|
||||
console.log(peeked?.metadata?.mimeType); // "text/plain"
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `interface ResolvedConfig` — `{ namespace, adapter, clock }`.
|
||||
- `interface DecodedEntry` — `{ value, metadata, expiresAt? }`.
|
||||
- `resolveOptions(options): ResolvedConfig` — Zod-validates, applies defaults.
|
||||
- `buildMetadata(size, createdAt, modifiedAt, callerMeta?): StorageMetadata` — composes metadata; when `callerMeta` is supplied it is preserved verbatim on the resulting record's `metadata` field.
|
||||
- `readEnvelope(storage, namespace, key, now): DecodedEntry | undefined` — returns the decoded entry, lazily deleting expired slots; throws `CORRUPT_PAYLOAD` on malformed envelopes.
|
||||
- `peekMetadata(storage, namespace, key): StorageMetadata | undefined` — non-destructive metadata-only peek; swallows decode errors.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
`readEnvelope` accepts `now` as a parameter rather than calling the clock internally so the caller controls the clock-read boundary (important for race-free `set` paths that already captured `clock()` once). The "lazy cleanup on expiry" path uses a bare `try { removeItem } catch {}` because failing to delete an expired row is non-fatal — the next read also tries, and the row remains correctly hidden from the public API regardless. `buildMetadata` always assigns `metadata: callerMeta` when `callerMeta` is supplied, so callers can round-trip arbitrary bags. A common convention is to carry `mimeType` inside `callerMeta`; this module deliberately does NOT hoist it to a typed field because the upstream `StorageMetadata` schema does not declare one — keeping the bag flat avoids divergence with the storage contract.
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `otel` — OpenTelemetry instrument set
|
||||
|
||||
### Purpose
|
||||
|
||||
Defines and lazily caches the OpenTelemetry instrument bundle for `@suaveplan/storage-browser-localstorage`. Lazy construction matters because the global `MeterProvider` is typically installed by the application's bootstrap code AFTER the storage package has been imported — eagerly creating instruments against the no-op default provider would freeze them on the wrong meter for the lifetime of the process. The override hook (`setStorageBrowserLocalStorageMeterProvider`) lets tests install a recording provider, drive the production code path under real instrumentation, and inspect the recorded counters and histograms — no mocks required.
|
||||
|
||||
### Features
|
||||
|
||||
- Four counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`) and two histograms (`op_duration_ms`, `io_bytes`).
|
||||
- All instrument names prefixed with `storage_browser_localstorage.` for unambiguous dashboards.
|
||||
- `recordOp` is the single recording funnel; production code only ever calls this helper.
|
||||
- Provider override + cache reset for deterministic test isolation.
|
||||
- Counter routing: `get` / `getPartial` / `getWithMetadata` → reads, `set` / `setPartial` → writes, `delete` / `clear` → deletes, `error` / `aborted` → errors.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
} from "@suaveplan/storage-browser-localstorage/otel";
|
||||
|
||||
recordOp({
|
||||
op: "set",
|
||||
outcome: "success",
|
||||
durationMs: 1.2,
|
||||
bytes: 42,
|
||||
direction: "write",
|
||||
});
|
||||
const inst = getInstruments();
|
||||
console.log(typeof inst.writesTotal.add);
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserLocalStorageMeterProvider,
|
||||
} from "@suaveplan/storage-browser-localstorage/otel";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
setStorageBrowserLocalStorageMeterProvider(provider);
|
||||
resetInstruments();
|
||||
|
||||
try {
|
||||
recordOp({ op: "get", outcome: "success", durationMs: 0.5 });
|
||||
// Drain `provider` here via your reader of choice.
|
||||
} finally {
|
||||
setStorageBrowserLocalStorageMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `getInstruments(force?): StorageBrowserLocalStorageInstruments` — returns the cached instrument bundle, rebuilding it when `force === true`.
|
||||
- `recordOp(input): void` — records a single op's duration + counter + optional io-bytes histogram.
|
||||
- `setStorageBrowserLocalStorageMeterProvider(provider | undefined): void` — installs an override provider; pass `undefined` to fall back to the global.
|
||||
- `resetInstruments(): void` — clears the cache so the next `getInstruments` rebuilds against the current (possibly newly installed) provider.
|
||||
- Types: `StorageBrowserLocalStorageOp`, `StorageBrowserLocalStorageOpOutcome`, `StorageBrowserLocalStorageInstruments`, `RecordOpInput`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The `op_duration_ms` histogram records both success and failure samples — the `outcome` attribute distinguishes them, so dashboards can compute success-only p95s and failure-only error rates from a single histogram. The `io_bytes` histogram is only recorded when `bytes` AND `direction` are both supplied, so list/clear/delete operations don't pollute it with zero-byte samples. Counter routing uses a strict if/else-if cascade rather than a lookup table because the discrete set of ops is small and the cascade compiles to a tight switch in V8.
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `registry` — explicit factory registration
|
||||
|
||||
### Purpose
|
||||
|
||||
The `@suaveplan/storage` package owns a small string-keyed factory that lets consumers pick a backend at config time (`createStorage({ type: "localstorage", config: { ... } })`). suaveplan storage adapters MUST opt-in via an explicit registration call rather than via module-load side effects — that way `sideEffects: false` bundlers can tree-shake the factory glue when the consumer never goes through the factory path. This module exposes the single function (`registerLocalStorageProvider`) that performs the registration.
|
||||
|
||||
### Features
|
||||
|
||||
- Single-call registration under the `"localstorage"` factory slot.
|
||||
- Idempotent — repeated calls are no-ops once the slot is occupied.
|
||||
- Re-registers cleanly after a downstream `clearStorageProviders()` call.
|
||||
- Uses TypeScript's module augmentation so `createStorage({ type: "localstorage", ... })` is typed end-to-end after the import.
|
||||
- Zero side effects on module load — the act of importing this file does NOT register anything; only calling the function does.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
import { registerLocalStorageProvider } from "@suaveplan/storage-browser-localstorage/registry";
|
||||
|
||||
registerLocalStorageProvider();
|
||||
const storage = createStorage({ type: "localstorage", config: {} });
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
import {
|
||||
LocalStorageStorage,
|
||||
registerLocalStorageProvider,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
registerLocalStorageProvider();
|
||||
const inMemory = createInMemoryLocalStorage();
|
||||
const storage = createStorage({
|
||||
type: "localstorage",
|
||||
config: {
|
||||
namespace: "myapp",
|
||||
storage: { read: () => inMemory },
|
||||
},
|
||||
});
|
||||
console.log(storage instanceof LocalStorageStorage); // true
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
`registerLocalStorageProvider(): void`
|
||||
|
||||
- Registers `LocalStorageStorage` under the factory slot `"localstorage"`.
|
||||
- Idempotent — second and subsequent calls are no-ops while the slot is occupied.
|
||||
- Returns nothing; throws nothing under normal conditions.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The function casts `LocalStorageStorage` through `as unknown as new (config?: unknown) => LocalStorageStorage` because the factory's `ProviderCtor` type accepts `unknown` for the config slot, while `LocalStorageStorage`'s constructor accepts the more specific `LocalStorageStorageOptions`. The double-cast is a deliberate, narrowly-scoped escape hatch needed because the factory cannot generically express "the config you registered with" without invasive higher-kinded plumbing — every storage adapter in the family uses the same pattern. The TypeScript `declare module "@suaveplan/storage/factory"` block adds the `"localstorage"` slot to the factory's strict-typed `StorageProviderRegistry` / `StorageProviderConfig` interfaces so call-site type inference flows through correctly without a cast.
|
||||
@@ -0,0 +1,48 @@
|
||||
## `LocalStorageStorageOptions` — adapter option boundary
|
||||
|
||||
### Purpose
|
||||
|
||||
Defines the option shape accepted by `LocalStorageStorage`'s constructor and `createLocalStorageStorage` factory. Pairing the TypeScript type with a Zod schema (`LocalStorageStorageOptionsSchema`) lets the constructor validate user input at the boundary and raise `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` for malformed payloads, while keeping the structural `LocalStorageAdapter` and `Clock` interfaces flexible enough to accept any compliant implementation.
|
||||
|
||||
### Features
|
||||
|
||||
- Three knobs: `namespace` (wire-key prefix), `storage` (DOM-Storage-shaped engine), `clock` (deterministic time).
|
||||
- Strict Zod schema — extra keys are rejected per the strict-object default.
|
||||
- `storage` and `clock` are passed through as `unknown` on the Zod side; TypeScript narrows their shape at the call site via `LocalStorageAdapter` / `Clock`.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const storage = createLocalStorageStorage();
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const inMemory = createInMemoryLocalStorage();
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "myapp",
|
||||
storage: { read: () => inMemory },
|
||||
clock: () => 1700000000000,
|
||||
});
|
||||
await storage.set("k", new Uint8Array([1]), { ttl: 60 });
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `interface LocalStorageStorageOptions` — `{ namespace?, storage?, clock? }`.
|
||||
- `LocalStorageStorageOptionsSchema: ZodObject` — runtime validator used by the `LocalStorageStorage` constructor. Throws on the boundary.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
`namespace` is a string with min-length 1 — empty namespaces would silently collide with any caller-side data stored at the bare keys. The schema is strict (`zod`'s default strict-object) so passing `{ directory: "..." }` (a typo of OPFS' option key) is caught at the boundary instead of silently ignored. Validation throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` so callers can distinguish boundary-rejection errors from runtime IO errors without parsing message text.
|
||||
|
||||
The `storage` field accepts a `LocalStorageAdapter` (the `{ read(): StorageLike }` shape) rather than a bare `StorageLike` so production code can defer the global-`localStorage` lookup until the first method call. Tests typically construct a `LocalStorageAdapter` over `createInMemoryLocalStorage()` and supply it here; the adapter indirection costs one extra function call per public method but completely decouples the package from the global object.
|
||||
|
||||
The `clock` field accepts a `Clock` from `@suaveplan/types` (a `() => number` returning a millisecond Unix timestamp). Defaulting to `Date.now` keeps the production code path zero-allocation; injecting a `FakeClock` from `@suaveplan/testing` makes TTL math deterministic in tests so behaviours like lazy eviction can be asserted without `setTimeout`.
|
||||
@@ -0,0 +1,63 @@
|
||||
## `StorageLike` — structural DOM Storage surface
|
||||
|
||||
### Purpose
|
||||
|
||||
`StorageLike` declares the minimum structural surface the `@suaveplan/storage-browser-localstorage` adapter requires from the underlying engine. The DOM `Storage` interface is large, mostly historical, and varies between browser versions; we narrow it down to six methods + one property so test doubles can implement the contract without dragging in DOM lib augmentation. `LocalStorageAdapter` is the resolver indirection that lets the production code path read the global at first call rather than at module-evaluation time, so universal bundlers that lack `globalThis.localStorage` (Node, Workers without storage shims) never trip the global at import.
|
||||
|
||||
### Features
|
||||
|
||||
- Six-member subset of `lib.dom`'s `Storage`: `length`, `key(i)`, `getItem`, `setItem`, `removeItem`, `clear`.
|
||||
- DOM-compatible: `globalThis.localStorage` satisfies `StorageLike` structurally without casts.
|
||||
- `LocalStorageAdapter.read()` lets callers inject either the real `localStorage`, an in-memory fixture, or a quota-limited test variant.
|
||||
- Zero runtime cost — types only.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { StorageLike } from "@suaveplan/storage-browser-localstorage/types";
|
||||
|
||||
declare const real: Storage;
|
||||
const compatible: StorageLike = real; // structural compatibility, no cast.
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LocalStorageAdapter,
|
||||
StorageLike,
|
||||
} from "@suaveplan/storage-browser-localstorage/types";
|
||||
|
||||
const map = new Map<string, string>();
|
||||
const stub: StorageLike = {
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
key(i: number) {
|
||||
return [...map.keys()][i] ?? null;
|
||||
},
|
||||
getItem(k: string) {
|
||||
return map.get(k) ?? null;
|
||||
},
|
||||
setItem(k: string, v: string) {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem(k: string) {
|
||||
map.delete(k);
|
||||
},
|
||||
clear() {
|
||||
map.clear();
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: LocalStorageAdapter = { read: () => stub };
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `interface StorageLike` — six-member subset of DOM `Storage`. All members synchronous.
|
||||
- `interface LocalStorageAdapter` — `{ read(): StorageLike }`. Called by `LocalStorageStorage` on each public method invocation; consumers may return a different `StorageLike` on each call if they want per-realm isolation.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
`StorageLike` deliberately omits `Storage[key: string]` index access — typed string indexing is a DOM convenience that obscures the actual API contract and creates ambiguity between "this is a typed property" and "this is a key lookup." Callers MUST go through `getItem`/`setItem`. The resolver indirection (`LocalStorageAdapter.read`) costs one extra function call per public method but lets the production code path defer `globalThis.localStorage` lookup until first use, which is the difference between "throws at import time in Node" and "throws at first use with a typed `LOCAL_STORAGE.UNAVAILABLE` error."
|
||||
@@ -0,0 +1,95 @@
|
||||
# @suaveplan/storage-browser-opfs - Browser OPFS AbstractStorage adapter for @suaveplan/storage: origin-scoped persistent file storage via navigator.storage.getDirectory(), AbortSignal everywhere, strict key sanitisation, native Web Streams, native partial reads + partial writes, DOMException → typed OpfsError mapping, full OTel surface.
|
||||
|
||||
Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consumers (vault snapshots, drive chunk caches, in-app i18n catalogues, SQLite-WASM backing files), with full `AbstractStorage` conformance, native Web Streams, native partial reads + partial writes, AbortSignal threading, Zod-validated options, structured `OpfsError` boundary, and a complete OpenTelemetry surface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
|
||||
- [opfs-storage.errors](./src/opfs-storage/opfs-storage.errors.md) — Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes th…
|
||||
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
|
||||
- [opfs-storage.ops](./src/opfs-storage/opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./src/opfs-storage/opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses…
|
||||
<!-- end:auto-docs -->
|
||||
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-storage.errors](./src/opfs-storage/opfs-storage.errors.md) — Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes th…
|
||||
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-storage.ops](./src/opfs-storage/opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./src/opfs-storage/opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-browser-opfs
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOpfsStorage,
|
||||
isOpfsAvailable,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
if (!isOpfsAvailable()) {
|
||||
throw new Error("This browser does not support OPFS");
|
||||
}
|
||||
const storage = createOpfsStorage({ directory: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
```
|
||||
|
||||
## Factory integration
|
||||
|
||||
```ts
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerOpfsProvider();
|
||||
const storage = createStorage({ type: "opfs", config: { directory: "myapp" } });
|
||||
```
|
||||
|
||||
The package does NOT self-register at module-evaluation time — call `registerOpfsProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Value | Notes |
|
||||
|---|---|---|
|
||||
| `partialReads` | `true` | Via `File.slice(...).arrayBuffer()`. |
|
||||
| `partialWrites` | `true` | Via `FileSystemWritableFileStream.seek()`. |
|
||||
| `nativeStreams` | `true` | `File.stream()` for read; buffered close-on-commit for write. |
|
||||
| `atomicWrites` | `false` | Close-as-commit semantics within a single realm; no cross-realm locking. |
|
||||
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes.ts`](./src/error-codes.md) — `OPFS_CODES` registry + `OpfsError`.
|
||||
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
|
||||
- [`src/filename-codec/filename-codec.ts`](./src/filename-codec/filename-codec.md) — caller-key ↔ OPFS-filename round-trip.
|
||||
- [`src/opfs-adapter/opfs-adapter.ts`](./src/opfs-adapter/opfs-adapter.md) — `defaultOpfs()` + `isOpfsAvailable()`.
|
||||
- [`src/opfs-storage/opfs-storage.ts`](./src/opfs-storage/opfs-storage.md) — the `OpfsStorage` class.
|
||||
- [`src/opfs-storage/opfs-storage.errors.ts`](./src/opfs-storage/opfs-storage.errors.md) — DOMException mapping.
|
||||
- [`src/opfs-storage/opfs-storage.ops.ts`](./src/opfs-storage/opfs-storage.ops.md) — internal helpers.
|
||||
- [`src/opfs-storage/opfs-storage.streams.ts`](./src/opfs-storage/opfs-storage.streams.md) — `ReadableStream`/`WritableStream` builders.
|
||||
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
|
||||
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerOpfsProvider()`.
|
||||
- [`src/in-memory-opfs/in-memory-opfs.ts`](./src/in-memory-opfs/in-memory-opfs.md) — real-implementation test double, exposed under `./in-memory-opfs` for downstream test packages.
|
||||
- [`src/types/opfs-like.types.ts`](./src/types/opfs-like.types.md) — structural OPFS surface.
|
||||
- [`src/types/opfs-storage.types.ts`](./src/types/opfs-storage.types.md) — option boundary.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# `error-codes` — OPFS error registry
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses `defineCodes("OPFS", [...])` from `@suaveplan/error` so each code is namespaced (`OPFS.NOT_FOUND`, `OPFS.QUOTA_EXCEEDED`, etc.), frozen at module load, and surfaced via a typed `OpfsErrorCode` union. Centralising the registry keeps the boundary error vocabulary tiny, prevents drift between the implementation and the spec, and lets callers pattern-match on a stable string code rather than parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine frozen codes covering every observable failure mode: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`.
|
||||
- `OpfsError` extends `SuaveplanError<OpfsErrorCode>` with an optional `key` discriminant.
|
||||
- `cause` chain preserved from upstream `DOMException` instances.
|
||||
- TypeScript-narrowed `OpfsErrorCode` union for exhaustive switches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
throw new OpfsError(OPFS_CODES.NOT_FOUND, "missing entry", { key: "user/42" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
type OpfsErrorCode,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
function handle(err: unknown): "retry" | "fail" | "ignore" {
|
||||
if (!(err instanceof OpfsError)) return "fail";
|
||||
const code: OpfsErrorCode = err.code;
|
||||
switch (code) {
|
||||
case OPFS_CODES.NOT_FOUND:
|
||||
return "ignore";
|
||||
case OPFS_CODES.QUOTA_EXCEEDED:
|
||||
case OPFS_CODES.IO_FAILED:
|
||||
return "retry";
|
||||
default:
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OPFS_CODES` — frozen `Readonly<Record<CodeName, "OPFS.CodeName">>` produced by `defineCodes`.
|
||||
- `OpfsErrorCode` — the union type of every member of `OPFS_CODES`.
|
||||
- `OpfsError extends SuaveplanError<OpfsErrorCode>` — constructor `(code, message, options?)` where `options.key` carries the offending key for correlation and `options.cause` chains the original error.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is frozen at module load — adding a new code requires changing this file, which forces the spec delta to be updated in lockstep. `OpfsError.name` is set to `"OpfsError"` so devtools display useful labels. The `key` discriminant is the only optional field on the error class because no other piece of error context is universal across the nine paths; callers needing more should consult `cause`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `filename-codec` — caller key ↔ OPFS filename
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OPFS filenames inherit filesystem-level constraints that caller keys must not (case-insensitivity on some platforms, no embedded `/` separator within a single entry name, byte-encoding ambiguity across browsers). This module wraps each key in RFC 4648 base64url over its UTF-8 byte representation, producing OPFS-safe filenames that round-trip every caller key — including unicode, mixed scripts, slashes, and any byte the caller threw at us. Storing the codec as a separate module keeps the algorithm independently testable (property-based round-trip suite) and replaceable (a future hash-addressed mode can plug in without touching `OpfsStorage`).
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 4648 base64url with `+`→`-`, `/`→`_`, padding stripped.
|
||||
- UTF-8 fatal decode rejects malformed byte sequences with a typed `OpfsError`.
|
||||
- Round-trip invariant proven by a `fast-check` property test (200 random inputs).
|
||||
- Output regex: `^[A-Za-z0-9_-]*$` — accepted by every OPFS implementation.
|
||||
- Pure / synchronous / zero workspace dependencies.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFilename,
|
||||
encodeFilename,
|
||||
} from "@suaveplan/storage-browser-opfs/filename-codec";
|
||||
|
||||
const filename = encodeFilename("orders/2026/05.json"); // "b3JkZXJzLzIwMjYvMDUuanNvbg"
|
||||
const original = decodeFilename(filename); // "orders/2026/05.json"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFilename,
|
||||
encodeFilename,
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
function listKeysSafely(entries: { name: string }[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
out.push(decodeFilename(entry.name));
|
||||
} catch (err) {
|
||||
// Skip entries that aren't ours — a foreign tool may have written
|
||||
// something to the OPFS directory we share.
|
||||
if (
|
||||
err instanceof OpfsError &&
|
||||
err.code === OPFS_CODES.INVALID_KEY
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeFilename(key: string): string` — converts to base64url-without-padding.
|
||||
- `decodeFilename(filename: string): string` — throws `OpfsError(OPFS.INVALID_KEY)` for malformed base64 or invalid UTF-8.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codec is deliberately byte-identical to the WHATWG spec for base64url to keep filenames stable across runtimes — the same OPFS directory written from Chrome must round-trip from Safari and Firefox. `btoa`/`atob` are universally available in browsers and in Node.js 20+, so no polyfill is needed at runtime. The fatal `TextDecoder` makes the codec self-healing: a foreign tool that writes non-base64 entries to the OPFS directory will be skipped by `decodeFilename`-using callers rather than corrupting their key view.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `in-memory-opfs` — real `OpfsLike` test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A faithful in-memory implementation of the `OpfsLike` structural interface and its companion directory / file / writable-stream surfaces. The double exists because the test suite for this package — and for any downstream consumer of `OpfsStorage` — needs to exercise the full OPFS API without booting a real browser. CLAUDE.md forbids `vi.mock` / `vi.stubGlobal`, so the test double is a real implementation that the test code constructs and injects via the `opfs` option. The implementation preserves bytes verbatim, honours per-file quotas, supports the seek-then-write pattern `OpfsStorage.setPartial` exercises, and emits the same `DOMException`-shaped errors (`NotFoundError`, `QuotaExceededError`, `NotAllowedError`) the real browser throws.
|
||||
|
||||
Exposing the double under the `./in-memory-opfs` subpath lets downstream packages (e.g. test fixtures, contract conformance suites) reuse it without re-implementing the (subtle) WHATWG semantics around `keepExistingData`, `seek`, and stream lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Full coverage of every `OpfsLike` / `OpfsDirectoryHandle` / `OpfsFileHandle` / `OpfsFile` / `OpfsWritableStream` method that `OpfsStorage` calls.
|
||||
- Faithful `seek()` semantics: position cursor on the underlying buffer, growable on overflow, zero-fill on sparse extension.
|
||||
- `keepExistingData: true` honoured for the seek-then-patch flow.
|
||||
- Configurable per-file quota; over-quota writes throw `QuotaExceededDomException` matching the real browser's error name.
|
||||
- Injectable clock for deterministic `lastModified` in tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createInMemoryOpfs } from "@suaveplan/storage-browser-opfs/in-memory-opfs";
|
||||
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
const opfs = createInMemoryOpfs();
|
||||
const storage = new OpfsStorage({ opfs });
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryOpfs,
|
||||
QuotaExceededDomException,
|
||||
} from "@suaveplan/storage-browser-opfs/in-memory-opfs";
|
||||
import { OpfsStorage, OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
const opfs = createInMemoryOpfs({ quotaBytes: 16, now: () => 1_700_000_000_000 });
|
||||
const storage = new OpfsStorage({ opfs });
|
||||
try {
|
||||
await storage.set("k", new Uint8Array(32));
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) {
|
||||
// err.code === OPFS_CODES.QUOTA_EXCEEDED, err.cause instanceof QuotaExceededDomException
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInMemoryOpfs(options?: InMemoryOpfsOptions): OpfsLike` — `options.quotaBytes` (default 100 MiB), `options.now` (default `Date.now`).
|
||||
- `InMemoryOpfsDirectoryHandle` — the concrete root-directory class.
|
||||
- `NotFoundDomException`, `QuotaExceededDomException`, `NotAllowedDomException` — `Error` subclasses with the real browser's `.name` strings.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The double's buffer grows on demand (`Uint8Array` reallocation) to support sparse writes from `seek` past the current end-of-file — the gap is implicitly zero-filled because `Uint8Array` defaults to zero. The writable stream wraps a real `WritableStream<Uint8Array>` so callers get genuine back-pressure and abort semantics; `seek` mutates the cursor state shared between the writer closure and the wrapper class. Per-file quota is enforced at write time so over-quota writes fail synchronously, matching the real browser. `entries()` yields children in insertion order — the WHATWG spec doesn't guarantee an order, but stable iteration keeps the test suite deterministic.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `key-sanitizer` — strict caller-key boundary
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`sanitizeKey(key)` is the single validation funnel through which every caller-supplied key passes before any OPFS call is issued. It enforces the storage-browser-opfs boundary contract: keys must be non-empty strings of bounded length, must not contain control characters or path-traversal segments, must not be absolute, and must use POSIX separators internally. Centralising this guard prevents adapter callers from accidentally exfiltrating data outside the configured OPFS sub-directory or constructing filenames the OPFS API will silently misinterpret.
|
||||
|
||||
## Features
|
||||
|
||||
- Rejects empty strings, non-string input, keys over 1024 chars, control chars (0x00-0x1F, 0x7F), absolute POSIX paths, and `..` traversal segments.
|
||||
- Normalises Windows `\` separators to POSIX `/` so cross-platform callers see consistent layout.
|
||||
- Throws typed `OpfsError(OPFS.INVALID_KEY | OPFS.KEY_TOO_LONG)` with the offending key captured for log correlation.
|
||||
- Pure / synchronous / dependency-free — safe to call in hot paths.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-opfs/key-sanitizer";
|
||||
|
||||
const safe = sanitizeKey("orders/2026/05/12.json");
|
||||
// → "orders/2026/05/12.json"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
sanitizeKey,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
function safeKeyOrNull(input: unknown): string | null {
|
||||
if (typeof input !== "string") return null;
|
||||
try {
|
||||
return sanitizeKey(input);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof OpfsError &&
|
||||
(err.code === OPFS_CODES.INVALID_KEY ||
|
||||
err.code === OPFS_CODES.KEY_TOO_LONG)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`sanitizeKey(key: string): string`
|
||||
|
||||
- **Throws** `OpfsError(OPFS.INVALID_KEY)` for empty / non-string / control-char / absolute / traversal-segment input.
|
||||
- **Throws** `OpfsError(OPFS.KEY_TOO_LONG)` for keys with `length > 1024`.
|
||||
- **Returns** the normalised POSIX-separator key on success.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The control-char scan is a single linear pass over `charCodeAt(i)` — it costs less than a regex compile for typical key sizes. The traversal check splits on `/` after backslash normalisation to ensure forms like `a\..\b` are rejected, not just `a/../b`. Windows drive-letter prefixes (`C:\…`) are NOT rejected here because OPFS has no notion of drive letters and the leading `:` is harmless to OPFS; `storage-file` does reject them because the Node.js fs layer would interpret them.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `opfs-adapter` — default `OpfsLike` and feature detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the production binding of the `OpfsLike` structural interface to the browser's `navigator.storage.getDirectory()` API, plus a side-effect-free feature detector consumers call before constructing an `OpfsStorage`. Keeping this in its own module — rather than inside `OpfsStorage` — lets the storage class take an injected `OpfsLike` and stay environment-agnostic, while the production path resolves the real browser API lazily on first `getDirectory()`.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultOpfs()` returns an `OpfsLike` whose `getDirectory()` calls the live browser API.
|
||||
- Missing API surfaces as `OpfsError(OPFS.UNAVAILABLE)` rather than `TypeError` from a missing-property access.
|
||||
- `isOpfsAvailable()` is synchronous, has no side effects, and never throws — safe to call during render.
|
||||
- Resolves `navigator` from `globalThis` so the module loads cleanly in Node, Bun, and worker environments.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultOpfs,
|
||||
isOpfsAvailable,
|
||||
OpfsStorage,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
if (!isOpfsAvailable()) {
|
||||
// Fall back to MemoryStorage or another adapter.
|
||||
}
|
||||
const storage = new OpfsStorage({ opfs: defaultOpfs() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultOpfs,
|
||||
isOpfsAvailable,
|
||||
MemoryStorage,
|
||||
OpfsStorage,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
import type { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
|
||||
export function pickStorage(): AbstractStorage {
|
||||
if (isOpfsAvailable()) return new OpfsStorage({ opfs: defaultOpfs() });
|
||||
return new MemoryStorage();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultOpfs(): OpfsLike` — `getDirectory()` throws `OpfsError(OPFS.UNAVAILABLE)` when missing.
|
||||
- `isOpfsAvailable(): boolean` — pure feature detector.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defaultOpfs()` returns a fresh `OpfsLike` object on every call so callers can hold different references; the underlying `getDirectory` getter is read at each invocation so consumers that polyfill `navigator.storage` after this module loads still benefit. The Boolean detector deliberately checks `typeof getDirectory === "function"` rather than `"getDirectory" in storage` to weed out polyfills that stub the property without a callable.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `opfs-storage.errors` — DOMException → OpfsError boundary
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes the storage class surfaces. Centralising the mapping keeps the main `OpfsStorage` class focused on the happy path and prevents the boundary contract from drifting as new error kinds emerge. The helpers also handle the `AbortSignal` short-circuit so every method invocation can call a single `assertNotAborted` guard at the top.
|
||||
|
||||
## Features
|
||||
|
||||
- `isNotFoundError(err)` — detects browser `NotFoundError` (including from the in-memory test double).
|
||||
- `mapOpfsError(err, key)` — maps `NotAllowedError → OPFS.PERMISSION_DENIED`, `QuotaExceededError → OPFS.QUOTA_EXCEEDED`, anything else → `OPFS.IO_FAILED` with `cause` preserved. Pre-existing `OpfsError` instances pass through unchanged so callee-thrown codes (e.g. `OPFS.UNAVAILABLE` from `defaultOpfs()`) reach the boundary.
|
||||
- `assertNotAborted(signal, key)` — throws `OPFS.ABORTED` when the signal is already aborted before any OPFS call is issued.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapOpfsError,
|
||||
} from "./opfs-storage.errors.js";
|
||||
|
||||
async function readBytes(handle: OpfsFileHandle, signal?: AbortSignal) {
|
||||
assertNotAborted(signal, "k");
|
||||
try {
|
||||
return new Uint8Array(await (await handle.getFile()).arrayBuffer());
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return undefined;
|
||||
throw mapOpfsError(err, "k");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
|
||||
import { mapOpfsError } from "./opfs-storage.errors.js";
|
||||
|
||||
try {
|
||||
await someOpfsOperation();
|
||||
} catch (err) {
|
||||
const mapped = mapOpfsError(err, "user/42");
|
||||
if (mapped.code === OPFS_CODES.QUOTA_EXCEEDED) {
|
||||
// Surface a user-facing "you're out of space" message.
|
||||
}
|
||||
throw mapped;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `isNotFoundError(err: unknown): boolean`.
|
||||
- `mapOpfsError(err: unknown, key: string | undefined): OpfsError`.
|
||||
- `assertNotAborted(signal: AbortSignal | undefined, key: string | undefined): void` — throws `OpfsError(OPFS.ABORTED)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The detection branch tests `err.name === "NotFoundError"` rather than `err instanceof DOMException` because the in-memory test double cannot extend `DOMException` (the global isn't constructable in every runtime). Using `.name` lets the mapping work uniformly against both real `DOMException` instances and the test double's `Error` subclasses. `mapOpfsError` always preserves the underlying error via `cause` so callers can inspect the original `DOMException` when needed.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `opfs-storage` — `OpfsStorage` AbstractStorage adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
|
||||
- [opfs-storage.ops](./opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
- [opfs-storage.ops](./opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
|
||||
## Purpose
|
||||
|
||||
`OpfsStorage` is the browser-tier implementation of `AbstractStorage` from `@suaveplan/storage`, backed by the Origin Private File System (OPFS) — the standard origin-scoped persistent file storage primitive accessed via `navigator.storage.getDirectory()`. It exposes the full abstract surface (`get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`), threads `AbortSignal` through every async path, validates every caller key against `sanitizeKey`, maps `DOMException`s to typed `OpfsError` codes at the boundary, and records OTel metrics for every operation.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `AbstractStorage` byte-for-byte — `instanceof AbstractStorage` is `true` and every abstract method is implemented natively.
|
||||
- `capabilities: { partialReads: true, partialWrites: true, nativeStreams: true, atomicWrites: false, contentAddressable: false }`.
|
||||
- Lazy directory handle: `navigator.storage.getDirectory()` and `getDirectoryHandle(directory, { create: true })` run only on the first method call.
|
||||
- `defaultOpfs()` adapter resolves the real browser API; tests inject `createInMemoryOpfs()`.
|
||||
- Zod-validated `OpfsStorageOptions` at the constructor boundary throws `OPFS.INVALID_OPTIONS` on malformed input.
|
||||
- DOMException mapping: `NotFoundError → undefined/false`, `NotAllowedError → OPFS.PERMISSION_DENIED`, `QuotaExceededError → OPFS.QUOTA_EXCEEDED`, anything else → `OPFS.IO_FAILED` with `cause`.
|
||||
- OTel: per-op span via `getInstruments().tracer`, duration histogram, op-kind counter, IO-bytes histogram for read/write paths.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOpfsStorage,
|
||||
isOpfsAvailable,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
if (isOpfsAvailable()) {
|
||||
const storage = createOpfsStorage({ directory: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
console.log(new TextDecoder().decode(back));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOpfsStorage,
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
import { createInMemoryOpfs } from "@suaveplan/storage-browser-opfs/in-memory-opfs";
|
||||
|
||||
const opfs = createInMemoryOpfs({ quotaBytes: 1 << 20 });
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const storage = createOpfsStorage({ directory: "test", opfs, clock });
|
||||
|
||||
// Streaming write
|
||||
const ac = new AbortController();
|
||||
const writer = storage.createWriteStream("blob", ac.signal).getWriter();
|
||||
await writer.write(new Uint8Array(32_000));
|
||||
await writer.close();
|
||||
|
||||
// Partial read of bytes [10..20) without loading the full value
|
||||
const slice = await storage.getPartial("blob", 10, 10);
|
||||
|
||||
try {
|
||||
await storage.set("big", new Uint8Array(2 << 20)); // exceeds quota
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError && err.code === OPFS_CODES.QUOTA_EXCEEDED) {
|
||||
// surface a "you're out of space" message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class OpfsStorage extends AbstractStorage` — constructor `(options?: OpfsStorageOptions)`.
|
||||
- `createOpfsStorage(options?: OpfsStorageOptions): OpfsStorage`.
|
||||
- Every abstract method from `AbstractStorage`. All accept `signal?: AbortSignal`.
|
||||
- `capabilities`: literal-typed `StorageCapabilities` (see Features).
|
||||
- Throws `OpfsError` with one of the 9 frozen `OPFS_CODES` at any boundary.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The directory handle is cached after the first resolution to amortise the cost of `navigator.storage.getDirectory()` (the WHATWG spec doesn't require it to be cheap; some browsers do disk IO). The cache is per-instance, so multiple `OpfsStorage` instances over the same directory each resolve independently — sharing is the caller's responsibility. `set` performs a single `createWritable` + `write` + `close` so the write commits atomically per spec; we set `capabilities.atomicWrites: false` anyway because the abstract contract's "atomic" means "atomic with respect to concurrent readers within the same realm" and OPFS does not provide cross-realm locking. `setPartial` opens with `keepExistingData: true` so the seek-then-write doesn't truncate. `createReadStream`/`createWriteStream` are delegated to `opfs-storage.streams` to keep the class file within budget.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `opfs-storage.ops` — internal helpers for OpfsStorage
|
||||
|
||||
## Purpose
|
||||
|
||||
Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard budget. Extracting them keeps the class focused on lifecycle orchestration and the helpers individually testable in isolation. Each helper has a single responsibility — listing keys, paginating a list result, building a metadata record, validating numeric arguments, or feature-gating `getFileHandle` to a non-throwing variant.
|
||||
|
||||
## Features
|
||||
|
||||
- `listAllKeys(dir)` — enumerates file entries and decodes their filenames back to caller keys, silently skipping foreign entries.
|
||||
- `paginateKeys(keys, options)` — applies `prefix`/`offset`/`limit` and produces a `StorageListResult` with stable sort order.
|
||||
- `metadataFromFile(file, clock?)` — builds the `StorageMetadata` shape required by `AbstractStorage.getMetadata`.
|
||||
- `assertNonNegInt(key, label, value)` — boundary guard for partial-read/write offset and length.
|
||||
- `tryGetFileHandle(dir, filename, key)` — non-throwing `getFileHandle` that returns `undefined` for missing entries and maps other errors.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
listAllKeys,
|
||||
paginateKeys,
|
||||
metadataFromFile,
|
||||
} from "./opfs-storage.ops.js";
|
||||
|
||||
const all = await listAllKeys(dir);
|
||||
const page = paginateKeys(all.map((e) => e.key), { prefix: "user/" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNonNegInt,
|
||||
metadataFromFile,
|
||||
tryGetFileHandle,
|
||||
} from "./opfs-storage.ops.js";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
async function safeStat(dir: OpfsDirectoryHandle, key: string, fname: string, clock: Clock) {
|
||||
const handle = await tryGetFileHandle(dir, fname, key);
|
||||
if (!handle) return null;
|
||||
const file = await handle.getFile();
|
||||
return metadataFromFile(file, clock);
|
||||
}
|
||||
|
||||
assertNonNegInt("user/42", "offset", 0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `listAllKeys(dir): Promise<{ key, handle }[]>` — silently skips entries whose filename isn't valid base64url.
|
||||
- `paginateKeys(keys, options?): StorageListResult`.
|
||||
- `metadataFromFile(file, clock?): StorageMetadata`.
|
||||
- `assertNonNegInt(key, label, value): void` — throws `OpfsError(OPFS.INVALID_KEY)`.
|
||||
- `tryGetFileHandle(dir, filename, key): Promise<OpfsFileHandle | undefined>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`paginateKeys` sorts the filtered slice before paginating because `OpfsDirectoryHandle.entries()` makes no ordering guarantees. Sorting ensures `list` is deterministic across repeated calls without forcing the storage class to memoise. `metadataFromFile` falls back to `clock?.now()` only when the file's `lastModified` is missing (zero) — the in-memory test double always returns a valid stamp, so this fallback exists for unusual real-browser cases. `tryGetFileHandle` is preferred over a direct `try/catch` at every call site because the error-mapping logic deserves a single home.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `opfs-storage.streams` — Web Streams over OPFS
|
||||
|
||||
## Purpose
|
||||
|
||||
Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays under the 300-line file budget and the stream contract is independently testable. Both builders honour `AbortSignal`, map underlying errors through `mapOpfsError`, and preserve the close-as-commit semantic of the WHATWG File System Access spec (the writable stream buffers chunks and writes them in `close()`, so a half-written file is never observable).
|
||||
|
||||
## Features
|
||||
|
||||
- `buildReadStream({ key, signal, getDir })` — returns a `ReadableStream<Uint8Array>` over `File.stream()`; missing entries close cleanly instead of erroring.
|
||||
- `buildWriteStream({ key, signal, getDir })` — returns a `WritableStream<Uint8Array>` that buffers caller chunks and commits them atomically on `close()`.
|
||||
- `AbortSignal` honoured before each chunk read/write — already-aborted signals throw `OPFS.ABORTED` synchronously.
|
||||
- `mapOpfsError` invoked at every error boundary so callers receive typed `OpfsError` instances.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildReadStream } from "./opfs-storage.streams.js";
|
||||
|
||||
const stream = buildReadStream({ key: "a", signal: undefined, getDir });
|
||||
const reader = stream.getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
// process value
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildWriteStream } from "./opfs-storage.streams.js";
|
||||
|
||||
const ac = new AbortController();
|
||||
const stream = buildWriteStream({ key: "a", signal: ac.signal, getDir });
|
||||
const writer = stream.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3]));
|
||||
await writer.write(new Uint8Array([4, 5]));
|
||||
await writer.close(); // single OPFS write happens here
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamArgs { key, signal, getDir }`.
|
||||
- `buildReadStream(args): ReadableStream<Uint8Array>`.
|
||||
- `buildWriteStream(args): WritableStream<Uint8Array>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildWriteStream` collects chunks in a plain array instead of streaming them straight to OPFS because OPFS's `FileSystemWritableFileStream` does NOT expose a public byte-pipe-through-WHATWG surface in every browser yet — Safari historically required `keepExistingData`-or-`truncate` decisions up front. Buffering is consistent across browsers, and the memory cost is bounded by the caller's writer back-pressure. For very large writes consumers should use `set()` with a pre-built `Uint8Array` or `setPartial()` for streaming updates.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `otel` — OpenTelemetry instrumentation for OPFS storage
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises every metric, histogram, and span emitted by `OpfsStorage` behind a single lazy instrument factory. Lazy construction lets consumers install a custom global `MeterProvider` / `TracerProvider` before the storage path executes, and lets tests pin a real in-memory `MeterProvider` against which the recorded attribute set is asserted. The module exposes a small `recordOp({op, outcome, durationMs, bytes?, direction?})` helper that every operation in the storage class calls once.
|
||||
|
||||
## Features
|
||||
|
||||
- Six instruments: 4 counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`) and 2 histograms (`op_duration_ms`, `io_bytes`).
|
||||
- Stable label space: `{op, outcome}` on every counter / duration, `{op, direction}` on `io_bytes`.
|
||||
- Aborted operations count toward `errors_total` with `outcome: "aborted"` so SLO dashboards can split them out.
|
||||
- `setStorageBrowserOpfsMeterProvider(provider)` for runtime provider injection (used by `@suaveplan/opentelemetry`).
|
||||
- `resetInstruments()` rebuilds the cache between test blocks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordOp } from "@suaveplan/storage-browser-opfs/otel";
|
||||
|
||||
recordOp({ op: "set", outcome: "success", durationMs: 12, bytes: 100, direction: "write" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
getInstruments,
|
||||
setStorageBrowserOpfsMeterProvider,
|
||||
resetInstruments,
|
||||
} from "@suaveplan/storage-browser-opfs/otel";
|
||||
|
||||
// In a Bun/Node test:
|
||||
class CapturingMeterProvider { /* real in-memory MeterProvider */ }
|
||||
const provider = new CapturingMeterProvider();
|
||||
setStorageBrowserOpfsMeterProvider(provider);
|
||||
resetInstruments();
|
||||
const { writesTotal } = getInstruments(true);
|
||||
writesTotal.add(1, { op: "set", outcome: "success" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `setStorageBrowserOpfsMeterProvider(provider | undefined): void`.
|
||||
- `getInstruments(force?: boolean): StorageBrowserOpfsInstruments` — cached unless `force` is `true`.
|
||||
- `recordOp(input: RecordOpInput): void`.
|
||||
- `resetInstruments(): void`.
|
||||
- Types: `StorageBrowserOpfsOp` (14 op names), `StorageBrowserOpfsOpOutcome` (`success | error | aborted`), `RecordOpInput`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dispatch in `recordOp` is intentionally a flat `if/else` chain rather than a lookup table: TypeScript can statically verify exhaustiveness over `StorageBrowserOpfsOp`, and the cost of a 14-arm chain is negligible compared to the underlying counter add. `aborted` outcomes are routed to `errors_total` (not a separate counter) on purpose — operationally they're failures from the caller's perspective even though they're not exceptions on the server side.
|
||||
@@ -0,0 +1,50 @@
|
||||
# `registry` — factory registration for `OpfsStorage`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hooks `OpfsStorage` into `@suaveplan/storage`'s factory registry via module augmentation so callers can construct the adapter through `createStorage({ type: "opfs", config })`. Registration is gated behind an explicit `registerOpfsProvider()` function instead of an unconditional side-effect import: `sideEffects: false` bundlers must be able to tree-shake the registry path when a consumer holds a direct `new OpfsStorage(...)` reference but never goes through the factory.
|
||||
|
||||
## Features
|
||||
|
||||
- `declare module "@suaveplan/storage/factory"` adds typed slots `opfs: OpfsStorage` and `opfs: OpfsStorageOptions`.
|
||||
- `registerOpfsProvider()` is idempotent — repeat calls are no-ops.
|
||||
- Re-registers cleanly after `clearStorageProviders()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerOpfsProvider();
|
||||
const storage = createStorage({ type: "opfs", config: { directory: "myapp" } });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
clearStorageProviders,
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
|
||||
// In a fresh test isolate:
|
||||
clearStorageProviders();
|
||||
registerOpfsProvider();
|
||||
console.log(hasStorageProvider("opfs")); // true
|
||||
const storage = createStorage({ type: "opfs", config: { directory: "test" } });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerOpfsProvider(): void` — idempotent registration.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module augmentation is in the same file as the registration function so consumers get the type slot the moment they `import` either symbol. Idempotency is enforced by a `hasStorageProvider("opfs")` short-circuit before re-registering — the underlying factory map handles ties safely either way, but the short-circuit makes the contract explicit and observable.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `opfs-like.types` — structural view of the OPFS API surface
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares a minimal structural interface (`OpfsLike`, `OpfsDirectoryHandle`, `OpfsFileHandle`, `OpfsFile`, `OpfsWritableStream`) that `OpfsStorage` consumes. Working against a local interface — rather than `lib.dom`'s globals directly — keeps the adapter consumable from packages that compile without DOM lib, lets tests substitute a real in-memory implementation, and pins the contract to the subset of methods we actually call (so future browser additions don't ripple through this package as breakages).
|
||||
|
||||
## Features
|
||||
|
||||
- One interface per OPFS surface: directory handle, file handle, file, writable stream.
|
||||
- Every method is a strict subset of the corresponding WHATWG File System Access API method, so the browser globals satisfy our interface without `unknown`-casts.
|
||||
- `OpfsLike` is the smallest possible root: a single `getDirectory()` method, lazy by design.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { OpfsLike } from "@suaveplan/storage-browser-opfs/types";
|
||||
|
||||
declare const myOpfs: OpfsLike;
|
||||
const root = await myOpfs.getDirectory();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsLike,
|
||||
} from "@suaveplan/storage-browser-opfs/types";
|
||||
|
||||
class StaticDirOpfs implements OpfsLike {
|
||||
public constructor(private readonly handle: OpfsDirectoryHandle) {}
|
||||
public getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
return Promise.resolve(this.handle);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OpfsLike.getDirectory(): Promise<OpfsDirectoryHandle>` — resolves the origin's OPFS root.
|
||||
- `OpfsDirectoryHandle.getFileHandle(name, { create? })` / `getDirectoryHandle(name, { create? })` / `removeEntry(name, { recursive? })` / `entries()`.
|
||||
- `OpfsFileHandle.getFile()` / `createWritable({ keepExistingData? })`.
|
||||
- `OpfsFile.size`, `lastModified`, `arrayBuffer()`, `slice(start?, end?)`, `stream()`.
|
||||
- `OpfsWritableStream extends WritableStream<Uint8Array>` with `seek(position)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface intentionally omits any method `OpfsStorage` does not call. Adding new operations (e.g. `move`, `remove`) requires updating both this interface and the in-memory test double in lockstep. The native browser types are nominally compatible at the structural level — assignability flows from `FileSystemDirectoryHandle` to `OpfsDirectoryHandle` automatically.
|
||||
@@ -0,0 +1,46 @@
|
||||
# `opfs-storage.types` — option boundary for OpfsStorage
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the constructor option shape `OpfsStorageOptions` and the matching `OpfsStorageOptionsSchema` Zod schema. The schema is the runtime gate at the constructor boundary — malformed input becomes `OpfsError(OPFS.INVALID_OPTIONS)` with the underlying `ZodError` preserved as `cause`. This keeps invalid configuration from corrupting the OPFS layer with hard-to-diagnose downstream failures.
|
||||
|
||||
## Features
|
||||
|
||||
- `directory`: caller-overridable sub-directory under OPFS root (default `"storage"`).
|
||||
- `opfs`: injectable `OpfsLike` adapter for tests and progressive enhancement.
|
||||
- `clock`: injectable `Clock` so metadata timestamps are deterministic in tests.
|
||||
- Zod schema accepts structural adapter shapes as `unknown` (Zod cannot fully model interfaces), letting TypeScript enforce the contract at call sites while still validating the literal-typed fields.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OpfsStorage,
|
||||
type OpfsStorageOptions,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
const options: OpfsStorageOptions = { directory: "myapp" };
|
||||
const storage = new OpfsStorage(options);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { OpfsStorageOptionsSchema } from "@suaveplan/storage-browser-opfs/types";
|
||||
|
||||
function loadOpfsConfigFromEnv(): OpfsStorageOptions {
|
||||
const raw = JSON.parse(process.env.OPFS_CONFIG ?? "{}");
|
||||
const parsed = OpfsStorageOptionsSchema.safeParse(raw);
|
||||
if (!parsed.success) throw parsed.error;
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface OpfsStorageOptions { directory?: string; opfs?: OpfsLike; clock?: Clock }`.
|
||||
- `OpfsStorageOptionsSchema: ZodObject` — `directory: string().min(1).optional()`, structural adapters loose-typed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema-level looseness on `opfs`/`clock` is intentional: Zod cannot fully describe the structural shape of an interface like `OpfsLike` without losing the link to the TypeScript type, and runtime checks of every adapter method would impose a per-call cost without catching anything TypeScript doesn't already catch. The `directory.min(1)` rule is the one boundary that needs runtime enforcement because OPFS itself accepts the empty string and returns the root, which would silently break key isolation.
|
||||
@@ -0,0 +1,112 @@
|
||||
# @suaveplan/storage-cdn-bunnycdn - BunnyCDN Storage Zone + Pull Zone provider for @suaveplan/storage-cdn. AccessKey-header CRUD against regional Storage Zones (de/ny/la/sg/syd/uk), single + prefix purge via the Bunny.net management API, HMAC-SHA256 Token Authentication signed URLs, Range reads, native streaming, full AbortSignal threading, Retry-After honoured, OTel surface with provider+storage_region attributes.
|
||||
|
||||
BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMetadata`/`getPartial`/`createReadStream`/`createWriteStream` against the regional Storage Zone with `AccessKey` header auth, plus `purge`/`purgePrefix`/`signUrl`/`edgeUrl` against the public Pull Zone. HMAC-SHA256 Token Authentication (URL-safe base64, no padding) for signed URLs. Single + wildcard prefix purge via the Bunny.net management API. Transient-status retry honouring `Retry-After`. Full OTel surface with `provider="bunnycdn"` and `storage_region` attributes.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [bunny-cdn-adapter](./src/bunny-cdn-adapter/bunny-cdn-adapter.md) — `BunnyCdnAdapter` binds `@suaveplan/storage`'s `AbstractStorage` contract to BunnyCDN Storage Zones for storage operati…
|
||||
- [bunny-cdn-adapter.base](./src/bunny-cdn-adapter/bunny-cdn-adapter.base.md) — `BunnyCdnAdapterBase` carries the shared internal machinery every `BunnyCdnAdapter` instance needs: the resolved config…
|
||||
- [bunny-cdn-adapter.helpers](./src/bunny-cdn-adapter/bunny-cdn-adapter.helpers.md) — Internal helpers used by `BunnyCdnAdapter` and its sibling streams module. Splits boundary concerns (option parsing, he…
|
||||
- [bunny-cdn-adapter](./src/bunny-cdn-adapter/bunny-cdn-adapter.md) — `BunnyCdnAdapter` binds `@suaveplan/storage`'s `AbstractStorage` contract to BunnyCDN Storage Zones for storage operati…
|
||||
- [bunny-cdn-adapter.streams](./src/bunny-cdn-adapter/bunny-cdn-adapter.streams.md) — Builds the `ReadableStream` and `WritableStream` instances `BunnyCdnAdapter` returns from `createReadStream` / `createW…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN_BUNNYCDN` error namespace used by every module in this package. Codes are produced…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN_BUNNYCDN` error namespace used by every module in this package. Codes are produced…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before any fetch is issued against BunnyCDN Storage, and provides…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before any fetch is issued against BunnyCDN Storage, and provides…
|
||||
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-bunnycdn`: counters (`reads_total`, `writes_total`, `…
|
||||
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-bunnycdn`: counters (`reads_total`, `writes_total`, `…
|
||||
- [pull-zone-client](./src/pull-zone-client/pull-zone-client.md) — Owns the Bunny.net management-API purge operation. `purgeUrl(ctx, edgeUrl, signal?)` issues `POST…
|
||||
- [pull-zone-client](./src/pull-zone-client/pull-zone-client.md) — Owns the Bunny.net management-API purge operation. `purgeUrl(ctx, edgeUrl, signal?)` issues `POST…
|
||||
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"bunny…
|
||||
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"bunny…
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests against BunnyCDN Storage Zones and the Bunny.net management API. Distinguishe…
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests against BunnyCDN Storage Zones and the Bunny.net management API. Distinguishe…
|
||||
- [storage-client](./src/storage-client/storage-client.md) — Owns every Storage Zone HTTP operation `BunnyCdnAdapter` needs: PUT, GET, HEAD, DELETE, Range read, and streaming GET.…
|
||||
- [storage-client](./src/storage-client/storage-client.md) — Owns every Storage Zone HTTP operation `BunnyCdnAdapter` needs: PUT, GET, HEAD, DELETE, Range read, and streaming GET.…
|
||||
- [token-auth](./src/token-auth/token-auth.md) — Computes BunnyCDN Pull Zone Token Authentication query strings — `?token=<urlSafeBase64(sha256(key + path + expires))>&…
|
||||
- [token-auth](./src/token-auth/token-auth.md) — Computes BunnyCDN Pull Zone Token Authentication query strings — `?token=<urlSafeBase64(sha256(key + path + expires))>&…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-cdn-bunnycdn
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { BunnyCdnAdapter } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const storage = new BunnyCdnAdapter({
|
||||
storageZone: "myz",
|
||||
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
|
||||
pullZoneHostname: "mycdn.b-cdn.net",
|
||||
storageRegion: "de",
|
||||
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
|
||||
purgeApiKey: process.env["BUNNY_PURGE_KEY"]!,
|
||||
});
|
||||
|
||||
await storage.set("photos/a.jpg", bytes);
|
||||
await storage.purge("photos/a.jpg");
|
||||
await storage.purgePrefix("photos/");
|
||||
const signed = await storage.signUrl("photos/a.jpg", { ttlSeconds: 300 });
|
||||
```
|
||||
|
||||
## Factory registration
|
||||
|
||||
```ts
|
||||
import { registerBunnyCdnProvider } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerBunnyCdnProvider();
|
||||
const storage = createStorage({ type: "bunnycdn", config });
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [error-codes](./src/error-codes.md) — `STORAGE_CDN_BUNNYCDN_CODES` registry + `StorageCdnBunnycdnError`.
|
||||
- [constants](./src/constants/constants.md) — region whitelist, default hosts, retry defaults, `storageHostFor` helper.
|
||||
- [storage-client](./src/storage-client/storage-client.md) — PUT/GET/DELETE/HEAD/Range/streaming against the regional Storage Zone.
|
||||
- [pull-zone-client](./src/pull-zone-client/pull-zone-client.md) — `purgeUrl` for single + wildcard purge via `api.bunny.net/purge`.
|
||||
- [token-auth](./src/token-auth/token-auth.md) — HMAC-SHA256 Token Authentication query builder.
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — strict key validation + per-segment encoder.
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — transient classifier + `Retry-After` honour.
|
||||
- [otel](./src/otel/otel.md) — meters, histograms, counters, tracer.
|
||||
- [bunny-cdn-adapter](./src/bunny-cdn-adapter/bunny-cdn-adapter.md) — main `BunnyCdnAdapter` class.
|
||||
- [registry](./src/registry/registry.md) — module-augmentation slot for `createStorage({ type: "bunnycdn", ... })`.
|
||||
|
||||
## Capabilities matrix
|
||||
|
||||
```ts
|
||||
{
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
cdn: {
|
||||
cdnPurge: true,
|
||||
cdnPrefixPurge: true,
|
||||
cdnSignedUrls: true,
|
||||
cdnOriginUploads: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal purity** — no `node:*` or DOM-only imports.
|
||||
- **Full Storage Zone surface** — origin PUT/GET/DELETE/HEAD with Range, native streaming, AbortSignal.
|
||||
- **CDN integration** — single + prefix purge, signed (Token Authentication) URLs, edge URL composition.
|
||||
- **Region-aware host selection** — `de` uses the unprefixed host (`storage.bunnycdn.com`); other regions prepend the code (`ny.storage.bunnycdn.com`, …).
|
||||
- **Two-key model** — per-zone `storageAccessKey` for CRUD vs. account-level `purgeApiKey` for invalidation; rotated independently.
|
||||
- **Retry on transient outcomes** — `408`/`425`/`429`/`5xx` retried with `Retry-After` honoured.
|
||||
- **OTel built-in** — `storage_cdn_bunnycdn.*` instruments with `provider="bunnycdn"` and `storage_region` attributes for cross-provider dashboards.
|
||||
- **100 / 100 / 100 / 100 Istanbul coverage**.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE` at the repo root.
|
||||
|
||||
@@ -0,0 +1,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,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,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,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,69 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the canonical `STORAGE_CDN_BUNNYCDN` error namespace used by every module in this package. Codes are produced via `@suaveplan/error.defineCodes` so the resulting string values are guaranteed-unique within the workspace. The companion `StorageCdnBunnycdnError` class extends `SuaveplanError` and adds four diagnostic discriminants — `key`, `zone`, `region`, `httpStatus` — so consumers can correlate failures with the offending BunnyCDN operation without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable error codes covering storage auth, purge auth, zone-not-found, missing keys, region/options validation, and upstream failures.
|
||||
- `StorageCdnBunnycdnError` subclass with `key`/`zone`/`region`/`httpStatus` carry fields.
|
||||
- Cause-chain preservation via `SuaveplanError`'s `cause` option.
|
||||
- Type-safe `StorageCdnBunnycdnErrorCode` discriminant inferred from the `defineCodes` tuple.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { STORAGE_CDN_BUNNYCDN_CODES, StorageCdnBunnycdnError } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
throw new StorageCdnBunnycdnError(
|
||||
STORAGE_CDN_BUNNYCDN_CODES.STORAGE_AUTH_FAILED,
|
||||
"Storage Zone refused the AccessKey",
|
||||
{ zone: "myz", region: "de", httpStatus: 401 },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { STORAGE_CDN_BUNNYCDN_CODES, StorageCdnBunnycdnError } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
try {
|
||||
await storage.purge("a/b.bin");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageCdnBunnycdnError && err.code === STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED) {
|
||||
operatorLog.warn({ zone: err.zone, region: err.region }, "Purge AccessKey rotated?");
|
||||
await rotateAndRetry();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `STORAGE_CDN_BUNNYCDN_CODES`
|
||||
|
||||
Frozen record. Codes: `STORAGE_AUTH_FAILED`, `PURGE_AUTH_FAILED`, `ZONE_NOT_FOUND`, `TOKEN_AUTH_KEY_MISSING`, `PURGE_API_KEY_MISSING`, `INVALID_REGION`, `INVALID_OPTIONS`, `UPSTREAM_ERROR`.
|
||||
|
||||
### `StorageCdnBunnycdnErrorCode`
|
||||
|
||||
Union of the namespaced code string values.
|
||||
|
||||
### `class StorageCdnBunnycdnError extends SuaveplanError<StorageCdnBunnycdnErrorCode>`
|
||||
|
||||
- `constructor(code, message, options?)` — `options` accepts `cause`, `details`, `key`, `zone`, `region`, `httpStatus`.
|
||||
- Carry-fields exposed as `readonly` properties.
|
||||
- `name = "StorageCdnBunnycdnError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `STORAGE_AUTH_FAILED` covers `401`/`403` from the Storage Zone — bad `AccessKey` or insufficient permissions on the zone.
|
||||
- `PURGE_AUTH_FAILED` is a separate code from `STORAGE_AUTH_FAILED` because the purge API uses a different account-level key; operators rotate them independently.
|
||||
- `ZONE_NOT_FOUND` is reserved for callers that wrap this package and produce the error externally — the adapter itself maps `404` on a known key to `undefined`/`null` per the `AbstractStorage` contract.
|
||||
- `TOKEN_AUTH_KEY_MISSING` and `PURGE_API_KEY_MISSING` are caller-configuration errors raised by `signUrl` and `purge` respectively when their dependent secrets weren't supplied.
|
||||
- `INVALID_REGION` is distinct from `INVALID_OPTIONS` so dashboards can alert on region-typo configuration mistakes without sweeping every other validation failure into the same rule.
|
||||
- The `key` / `zone` / `region` carry-fields are all optional so unit-test fixtures can construct minimal errors without inventing fake provider identifiers.
|
||||
@@ -0,0 +1,62 @@
|
||||
# key-sanitizer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates and normalises caller-supplied storage keys before any fetch is issued against BunnyCDN Storage, and provides the per-segment URL encoder used to build absolute object URLs. Keys must be POSIX-form relative paths — no `..`, no leading `/`, no Windows drive prefixes, no control characters, no values longer than `MAX_KEY_LENGTH` (2048 chars). Rejections throw `StorageCdnBunnycdnError` with code `STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS`.
|
||||
|
||||
Mirrors the Rackspace sibling's approach — one error code for every rejection because Bunny operators don't need to distinguish "key too long" from "key has a control char" at runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict validation: empty, non-string, control char, leading `/`, drive-letter prefix, `..` traversal, overlong (>2048).
|
||||
- Normalisation: Windows `\` → POSIX `/` (idempotent on already-POSIX input).
|
||||
- Per-segment URL encoder (`encodeKey`) applies `encodeURIComponent` to each `/`-separated segment, preserving slashes.
|
||||
- All rejections share one canonical error code for uniform operator alerting.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { encodeKey, sanitizeKey } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const safe = sanitizeKey("photos/family/picnic 2025.jpg");
|
||||
const encoded = encodeKey(safe); // → "photos/family/picnic%202025.jpg"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { encodeKey, sanitizeKey, StorageCdnBunnycdnError } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
function composePullZoneUrl(hostname: string, key: string): string {
|
||||
const safe = sanitizeKey(key);
|
||||
return `https://${hostname}/${encodeKey(safe)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
composePullZoneUrl("mycdn.b-cdn.net", "../etc/passwd");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageCdnBunnycdnError) operatorLog.warn({ err }, "Caller bug — bad key");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `sanitizeKey(key: string): string`
|
||||
|
||||
Throws `StorageCdnBunnycdnError(STORAGE_CDN_BUNNYCDN.INVALID_OPTIONS)` for any rejection reason. Returns the input with Windows `\` replaced by `/`.
|
||||
|
||||
### `encodeKey(key: string): string`
|
||||
|
||||
Splits on `/`, applies `encodeURIComponent` per segment, re-joins.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Combining key-length and key-content rejections into one code is a deliberate UX choice — Bunny operators report on a single rule, and the `details` field on the error preserves the message text for diagnostic logs.
|
||||
- The traversal check runs after `\` → `/` normalisation so `a\..\b` is rejected (post-normalisation it has a `..` segment).
|
||||
- Control-character detection is a single O(n) pass scanning every code unit; it costs negligible time relative to the subsequent `fetch`.
|
||||
- `encodeKey` is separate from `sanitizeKey` because URL encoding is a transport concern — callers that compose paths for non-fetch destinations (Token Authentication signing paths, log messages) want the sanitised but unencoded form.
|
||||
- The `MAX_KEY_LENGTH = 2048` cap matches `@suaveplan/storage-http` and `@suaveplan/storage-cdn-rackspace`; BunnyCDN's documented maximum is well below that, so the limit is conservatively wide.
|
||||
@@ -0,0 +1,81 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instrument set for `@suaveplan/storage-cdn-bunnycdn`: counters (`reads_total`, `writes_total`, `deletes_total`, `purges_total`, `signed_urls_total`, `errors_total`, `range_fallback_total`), histograms (`op_duration_ms`, `io_bytes`), and a tracer scoped to `@suaveplan/storage-cdn-bunnycdn`. Every measurement carries `provider="bunnycdn"` and the configured `storage_region` so dashboards can break BunnyCDN traffic down by region and aggregate across providers without losing detail.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument construction so consumers can install a custom `MeterProvider` before the first operation.
|
||||
- `setBunnyMeterProvider(provider)` override hook with auto-rebuild on next access.
|
||||
- `recordOp({ op, outcome, durationMs, storageRegion, bytes?, direction? })` emits the full observation set.
|
||||
- `recordRangeFallback(storageRegion)` — surfaces origins that ignore Range.
|
||||
- `resetInstruments()` for test isolation.
|
||||
- Dedicated counters for CDN-specific operations (`purges_total`, `signed_urls_total`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordOp } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
recordOp({ op: "purge", outcome: "ok", durationMs: 42, storageRegion: "de" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
resetInstruments,
|
||||
setBunnyMeterProvider,
|
||||
recordOp,
|
||||
} from "@suaveplan/storage-cdn-bunnycdn";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const provider = new MeterProvider({ readers: [periodicReader] });
|
||||
setBunnyMeterProvider(provider);
|
||||
resetInstruments();
|
||||
recordOp({ op: "signUrl", outcome: "ok", durationMs: 1, storageRegion: "de" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `setBunnyMeterProvider(provider?): void`
|
||||
|
||||
Override or restore the meter provider; invalidates the cached instrument set.
|
||||
|
||||
### `getInstruments(force?): BunnyInstruments`
|
||||
|
||||
Returns the cached bundle. `force = true` rebuilds.
|
||||
|
||||
### `recordOp(input): void`
|
||||
|
||||
`input.outcome` decides which counter increments:
|
||||
|
||||
- `"ok"` + read-ish op → `reads_total`.
|
||||
- `"ok"` + `set`/`setPartial` → `writes_total`.
|
||||
- `"ok"` + `delete`/`clear` → `deletes_total`.
|
||||
- `"ok"` + `purge`/`purgePrefix` → `purges_total`.
|
||||
- `"ok"` + `signUrl` → `signed_urls_total`.
|
||||
- anything else → `errors_total`.
|
||||
|
||||
Always records `op_duration_ms{op, outcome, provider, storage_region}`. When `bytes` and `direction` are both provided, also records `io_bytes{op, direction, provider, storage_region}`.
|
||||
|
||||
### `recordRangeFallback(storageRegion): void`
|
||||
|
||||
Increments `range_fallback_total{outcome="ok", provider="bunnycdn", storage_region}`.
|
||||
|
||||
### `resetInstruments(): void`
|
||||
|
||||
Drops the cached bundle without changing the meter provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cache is critical for hot-path performance; `setBunnyMeterProvider` is the one path that intentionally invalidates it.
|
||||
- `provider="bunnycdn"` is hard-coded — every measurement from this package belongs to BunnyCDN by definition. Cross-provider dashboards aggregate on the `provider` attribute, so dropping it would defeat family-wide observability.
|
||||
- `storage_region` is sourced from the resolved configuration, not the operation argument, because operations don't take a region — the adapter is bound to one region per instance.
|
||||
- The tracer is acquired separately from the meter; no caching is required because `trace.getTracer` is idempotent.
|
||||
- `range_fallback_total` carries `outcome="ok"` only — fallback succeeds (just less efficiently); upstream failures during fallback surface via `errors_total`.
|
||||
@@ -0,0 +1,74 @@
|
||||
# pull-zone-client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the Bunny.net management-API purge operation. `purgeUrl(ctx, edgeUrl, signal?)` issues `POST https://api.bunny.net/purge?url=<encodedEdgeUrl>` with the account-level `AccessKey` header. The caller composes the absolute edge URL (e.g. `https://<pullZoneHostname>/<key>` or the same URL with a trailing `*` for prefix purge) — keeping URL composition out of this module means the same helper handles single-key and wildcard purges without branching.
|
||||
|
||||
The function `purgeApiKey` from the bundle is **required**; missing it throws `STORAGE_CDN_BUNNYCDN.PURGE_API_KEY_MISSING` before any fetch happens.
|
||||
|
||||
## Features
|
||||
|
||||
- Single function with a tight contract — no class state.
|
||||
- `purgeApiKey` enforced at the top; missing or empty key → `PURGE_API_KEY_MISSING` (no fetch issued).
|
||||
- 401/403 → `PURGE_AUTH_FAILED`, other non-ok → `UPSTREAM_ERROR`.
|
||||
- Errors carry `zone`, `region`, and `httpStatus` for diagnostics.
|
||||
- AbortSignal threaded into the fetch init.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { purgeUrl } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
await purgeUrl(
|
||||
{ fetch, purgeApiKey: "key-1234", pullZoneHostname: "mycdn.b-cdn.net", zone: "myz", region: "de" },
|
||||
"https://mycdn.b-cdn.net/a/b.bin",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { purgeUrl } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
async function purgePrefix(ctx, prefix: string) {
|
||||
// BunnyCDN's purge endpoint treats `*` as a wildcard suffix.
|
||||
await purgeUrl(ctx, `https://${ctx.pullZoneHostname}/${encodeURI(prefix)}*`);
|
||||
}
|
||||
|
||||
async function purgeSafelyWithKeyRotation(ctx, edgeUrl: string) {
|
||||
try {
|
||||
await purgeUrl(ctx, edgeUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof StorageCdnBunnycdnError && err.code === STORAGE_CDN_BUNNYCDN_CODES.PURGE_AUTH_FAILED) {
|
||||
const refreshed = await rotateAccessKey();
|
||||
await purgeUrl({ ...ctx, purgeApiKey: refreshed }, edgeUrl);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface PullZoneCtx`
|
||||
|
||||
`{ fetch, purgeApiKey, pullZoneHostname, zone, region }`. `purgeApiKey` is `string | undefined`; absent value triggers `PURGE_API_KEY_MISSING`.
|
||||
|
||||
### `purgeUrl(ctx, edgeUrl, signal?): Promise<void>`
|
||||
|
||||
- `2xx` → resolves.
|
||||
- `401`/`403` → `STORAGE_CDN_BUNNYCDN.PURGE_AUTH_FAILED`.
|
||||
- other non-ok → `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
|
||||
- Missing `purgeApiKey` → `STORAGE_CDN_BUNNYCDN.PURGE_API_KEY_MISSING` (no fetch issued).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Purge URL composition is the caller's responsibility because the same endpoint handles both single keys and prefix purges (caller appends `*` for prefix mode). Pushing composition into the helper would force every caller through an enum or a flag.
|
||||
- The `AccessKey` header is the account-level key from the Bunny.net dashboard, NOT the per-zone storage key. They're rotated independently and have different scopes; conflating them is the typical "purge stopped working after I rotated the storage key" bug.
|
||||
- 404 on purge is treated as success at the *caller* level (we don't see it here because the BunnyCDN API returns `200 OK` even when the object was already evicted) — operators relying on a 404 signal should switch to the management API's status endpoint.
|
||||
- The `pullZoneHostname` field is unused inside this function but is part of the bundle so callers can build edge URLs without a second context object.
|
||||
@@ -0,0 +1,78 @@
|
||||
# registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `"bunnycdn"` slot, and exposes `registerBunnyCdnProvider()` to bind `BunnyCdnAdapter` as the constructor for that slot. Consumers who prefer factory-style instantiation (`createStorage({ type: "bunnycdn", config })`) call `registerBunnyCdnProvider()` once at boot and never reference the class directly.
|
||||
|
||||
Registration is idempotent — duplicate calls are no-ops, so wiring this into multiple bootstrappers (test fixtures + production startup) is safe.
|
||||
|
||||
## Features
|
||||
|
||||
- TypeScript module augmentation — `createStorage<"bunnycdn">` now type-checks with `BunnyCdnAdapterOptions`.
|
||||
- Idempotent `registerBunnyCdnProvider()` — safe to call from multiple bootstrap paths.
|
||||
- Zero runtime dependencies beyond `@suaveplan/storage/factory` and the local `BunnyCdnAdapter` class.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { registerBunnyCdnProvider } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerBunnyCdnProvider();
|
||||
const storage = createStorage({
|
||||
type: "bunnycdn",
|
||||
config: {
|
||||
storageZone: "myz",
|
||||
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
|
||||
pullZoneHostname: "mycdn.b-cdn.net",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { registerBunnyCdnProvider } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerBunnyCdnProvider();
|
||||
|
||||
function tenantStorage(tenant: { zone: string; region: "de" | "ny" | "uk" }) {
|
||||
return createStorage({
|
||||
type: "bunnycdn",
|
||||
config: {
|
||||
...baseConfig,
|
||||
storageZone: tenant.zone,
|
||||
storageRegion: tenant.region,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `registerBunnyCdnProvider(): void`
|
||||
|
||||
Registers `BunnyCdnAdapter` against `StorageFactory` under the `"bunnycdn"` slot when not already registered. Calling it twice is a no-op.
|
||||
|
||||
### Module augmentation
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/storage/factory" {
|
||||
interface StorageProviderRegistry { bunnycdn: BunnyCdnAdapter }
|
||||
interface StorageProviderConfig { bunnycdn: BunnyCdnAdapterOptions }
|
||||
}
|
||||
```
|
||||
|
||||
This means `createStorage<"bunnycdn">(...)` returns `BunnyCdnAdapter` and the `config` parameter is typed as `BunnyCdnAdapterOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cast `BunnyCdnAdapter as unknown as new (config?: unknown) => BunnyCdnAdapter` is required because `StorageFactory`'s `ProviderCtor` is intentionally loose (`config?: unknown`) — adapter constructors take typed options that don't satisfy the registry's structural shape without the cast.
|
||||
- `hasStorageProvider("bunnycdn")` is the idempotency gate. After `unregisterStorageProvider("bunnycdn")` or `clearStorageProviders()`, a subsequent `registerBunnyCdnProvider()` call cleanly re-registers.
|
||||
- The augmentation is pure type-level — there is no runtime cost beyond the one-time `Map.set` in the factory.
|
||||
- This module imports `BunnyCdnAdapter` directly rather than via the package root barrel to avoid a circular import (the root barrel re-exports `registerBunnyCdnProvider`).
|
||||
@@ -0,0 +1,71 @@
|
||||
# retry-helper
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Coordinates retry logic for HTTP requests against BunnyCDN Storage Zones and the Bunny.net management API. Distinguishes transient outcomes (`408`/`425`/`429`/`5xx`) from fatal ones, honours `Retry-After` when the server provides it, falls back to exponential backoff with a configurable cap, and surfaces exhausted retries as `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR` with `zone`/`region` context.
|
||||
|
||||
Structurally similar to the sibling `storage-cdn-rackspace` retry helper but lives in this package so error codes carry BunnyCDN-specific diagnostics.
|
||||
|
||||
## Features
|
||||
|
||||
- `isTransientStatus(status)` — `408`, `425`, `429`, or any `5xx`.
|
||||
- `parseRetryAfter(value, now)` — delta-seconds and HTTP-date forms; capped at `RETRY_AFTER_MAX_DELAY_MS` (60s).
|
||||
- `retryWithBackoff(fn, options?)` — runs `fn` up to `maxAttempts`, sleeps between transient outcomes, supports cancellation.
|
||||
- Injectable `TimersLike` so tests pin behaviour under a `FakeClock`.
|
||||
- Exhausted retries surface as `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { retryWithBackoff } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const response = await retryWithBackoff(() => fetch("https://storage.bunnycdn.com/myz/k"));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { retryWithBackoff } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const response = await retryWithBackoff(
|
||||
() => fetch(url, { signal, headers: { AccessKey: process.env["BUNNY_KEY"]! } }),
|
||||
{
|
||||
retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10_000, honourRetryAfter: true },
|
||||
timers: fakeTimers,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `retryWithBackoff(fn, options?): Promise<Response>`
|
||||
|
||||
- `fn: () => Promise<Response>` — attempts the request.
|
||||
- `options.retry?` — `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `honourRetryAfter`.
|
||||
- `options.timers?` — defaults to `globalThis.setTimeout`/`clearTimeout`.
|
||||
- `options.signal?` — cancels the wait between attempts; pre-aborted signal short-circuits with `STORAGE_CDN_BUNNYCDN.UPSTREAM_ERROR`.
|
||||
|
||||
Returns the last response after exhaustion or throws on synthesised exhaustion / abort.
|
||||
|
||||
### `isTransientStatus(status: number): boolean`
|
||||
|
||||
True for `408`, `425`, `429`, or any value in `[500, 599]`.
|
||||
|
||||
### `parseRetryAfter(value: string | null, now: number): number | undefined`
|
||||
|
||||
- Numeric value → delta-seconds × 1000 (capped at `RETRY_AFTER_MAX_DELAY_MS`).
|
||||
- HTTP-date → `parseDate − now` (clamped to 0 if in the past, capped on the high side).
|
||||
- Missing / empty / unparseable → `undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Retry-After ceiling is **distinct from** `maxDelayMs`; the latter governs exponential backoff, the former specifically defends against the "stall an hour" abuse pattern.
|
||||
- `sleep` listens to the abort event with `{ once: true }` and tears down both the abort listener and the underlying timer on either resolution path — no listener leaks.
|
||||
- The helper is `fetch`-shaped (returns a `Response`) rather than generic — HTTP semantics drive every retry decision.
|
||||
- Synthesised exhaustion (no response, no error) is only reachable when `maxAttempts === 0`; the schema validates `> 0` at the boundary so the path is defensive.
|
||||
- Mid-wait abort throws `UPSTREAM_ERROR` (with `cause = signal.reason`) rather than a separate `ABORTED` code because the BunnyCDN error registry deliberately keeps abort under the upstream umbrella — operators monitoring "request canceled" already filter on `cause`-shape, not a separate code.
|
||||
@@ -0,0 +1,79 @@
|
||||
# storage-client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns every Storage Zone HTTP operation `BunnyCdnAdapter` needs: PUT, GET, HEAD, DELETE, Range read, and streaming GET. Each function takes a `StorageClientCtx` bundle (fetch + AccessKey + zone + region + retry options + signal + headers) and composes the regional Storage Zone URL via `buildStorageUrl`. The adapter constructs the bundle once per operation and never threads the fields by hand.
|
||||
|
||||
The split between this module and the public `BunnyCdnAdapter` keeps the HTTP semantics testable in isolation and gives provider-internal callers a clean entry point if they need to drive Storage Zone IO outside the adapter's `AbstractStorage` shape.
|
||||
|
||||
## Features
|
||||
|
||||
- `storageGet` — full-body GET; 404 → `undefined`; 401/403 → `STORAGE_AUTH_FAILED`; transient → retried.
|
||||
- `storageHead` — HEAD with retry; returns the raw response.
|
||||
- `storagePut` — PUT with `AccessKey`, `Content-Type`, `Content-Length`; non-ok → `errorForStatus`.
|
||||
- `storageDelete` — DELETE; 200 → `true`, 404 → `false`, other → throw.
|
||||
- `storagePartial` — Range read; delegates 206/200/416 disambiguation to `@suaveplan/storage-http`'s `consumeRangeResponse`; increments range-fallback counter on 200.
|
||||
- `storageStream` — single GET without retry; returns the raw `Response` for the caller to pump.
|
||||
- `buildStorageUrl` — composes `https://<region-host>/<zone>/<encodedKey>` using `storageHostFor`.
|
||||
- AbortSignal threaded into every fetch via `withSignal`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { storageGet } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const bytes = await storageGet(
|
||||
{ fetch, accessKey: "ak", zone: "myz", region: "de", retry: {}, signal: undefined, headers: {} },
|
||||
"a/b.bin",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildStorageUrl,
|
||||
storagePartial,
|
||||
storagePut,
|
||||
type StorageClientCtx,
|
||||
} from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
async function copyRange(src: StorageClientCtx, dst: StorageClientCtx, key: string, off: number, len: number) {
|
||||
const bytes = await storagePartial(src, key, off, len);
|
||||
if (bytes === null) return;
|
||||
await storagePut(dst, key, bytes, "application/octet-stream");
|
||||
return buildStorageUrl(dst, key);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface StorageClientCtx`
|
||||
|
||||
`{ fetch, accessKey, zone, region, retry, signal, headers }`.
|
||||
|
||||
### `buildStorageUrl(ctx, key): string`
|
||||
|
||||
`https://<storageHostFor(region)>/<encodeURIComponent(zone)>/<encodeKey(key)>`.
|
||||
|
||||
### `storageGet`, `storageHead`, `storagePut`, `storageDelete`, `storagePartial`, `storageStream`
|
||||
|
||||
See the corresponding TS signatures in `storage-client.ts`. Status → code mapping:
|
||||
|
||||
| status | code |
|
||||
|---|---|
|
||||
| 401, 403 | `STORAGE_AUTH_FAILED` |
|
||||
| 404 (GET/DELETE) | `undefined` / `false` (no throw) |
|
||||
| other non-ok | `UPSTREAM_ERROR` after retry exhaustion |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `AccessKey` header is added by `baseHeaders` *after* the caller's custom headers, so a caller's `headers` callback cannot accidentally override it with a different value.
|
||||
- Range reads bypass the auth-check for non-401/403 statuses because `consumeRangeResponse` understands `206`/`200`/`416` natively — duplicating the logic would mask the dedicated range fallback metric.
|
||||
- `storageStream` skips `retryWithBackoff` because streaming responses can't be retried mid-stream without buffering, and the retry classifier is response-shape rather than stream-shape.
|
||||
- All errors carry `zone`, `region`, and `httpStatus` so monitoring dashboards can break failures down by tenant.
|
||||
- The function-level split rather than a class is intentional — every function is stateless and the per-operation bundle is the only piece worth caching.
|
||||
@@ -0,0 +1,69 @@
|
||||
# token-auth
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Computes BunnyCDN Pull Zone Token Authentication query strings — `?token=<urlSafeBase64(sha256(key + path + expires))>&expires=<unix>`, optionally with `&token_path=<prefix>` for path-scoped tokens. The signing scheme is BunnyCDN's documented Pull Zone protection: concatenate the per-Pull-Zone signing key with the path and expiry, SHA-256 hash, and URL-safe-base64 encode (no padding).
|
||||
|
||||
The adapter calls this from `signUrl`; consumers never invoke it directly unless they need to sign a URL outside the adapter's standard `<pullZoneHostname>/<key>` pattern.
|
||||
|
||||
## Features
|
||||
|
||||
- SHA-256 via `crypto.subtle` — runs in Node 18+, Bun, Deno, browsers, Workers.
|
||||
- URL-safe base64 output: `+` → `-`, `/` → `_`, no `=` padding (matches BunnyCDN's verifier).
|
||||
- Optional `tokenPath` query parameter for prefix-scoped tokens.
|
||||
- Throws `STORAGE_CDN_BUNNYCDN.TOKEN_AUTH_KEY_MISSING` when the key is empty or missing.
|
||||
- Pure async function — no state, no side effects.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildTokenAuthQuery } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const query = await buildTokenAuthQuery({
|
||||
tokenAuthKey: "per-zone-secret",
|
||||
path: "/photos/a.jpg",
|
||||
expires: Math.floor(Date.now() / 1000) + 60,
|
||||
});
|
||||
// query === "?token=<urlSafeBase64>&expires=<unix>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildTokenAuthQuery } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
async function signedFolderUrl(hostname: string, folder: string, ttl: number): Promise<string> {
|
||||
const expires = Math.floor(Date.now() / 1000) + ttl;
|
||||
const tokenPath = `/${folder}/`;
|
||||
const query = await buildTokenAuthQuery({
|
||||
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
|
||||
path: tokenPath,
|
||||
expires,
|
||||
tokenPath,
|
||||
});
|
||||
return `https://${hostname}${tokenPath}${query}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface TokenAuthInput`
|
||||
|
||||
`{ tokenAuthKey, path, expires, tokenPath? }`. `path` is the absolute path on the Pull Zone (with leading `/`). `expires` is Unix seconds. `tokenPath` enables prefix-scoped tokens (the token authorises every URL under the prefix until expiry).
|
||||
|
||||
### `buildTokenAuthQuery(input): Promise<string>`
|
||||
|
||||
Returns the `?token=...&expires=...` query string to append to the absolute edge URL. Throws `STORAGE_CDN_BUNNYCDN.TOKEN_AUTH_KEY_MISSING` on empty `tokenAuthKey`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- BunnyCDN's signing scheme is SHA-256 (not HMAC). The secret is concatenated directly with `path + expires` and hashed; the URL-safe-base64 encoding makes the result safe for query-parameter inclusion.
|
||||
- Padding is stripped because BunnyCDN's verifier rejects tokens with `=` characters; this is a documented quirk of their implementation.
|
||||
- `expires` is Unix seconds — BunnyCDN's standard everywhere. Passing milliseconds produces verifier rejections that read like "token expired" but are actually "token format wrong".
|
||||
- The function is async because `crypto.subtle.digest` is Promise-returning. The underlying SHA-256 is fast (≪1ms).
|
||||
- The shipped tests assert determinism (same inputs → identical output), format correctness, and difference under varied inputs; byte-exact reference vectors are the responsibility of integration tests against a live BunnyCDN account.
|
||||
- `btoa` is part of the universal runtime baseline (Node 18+, Bun, Deno, browsers, Workers); a Node-only `Buffer` fallback is not needed.
|
||||
@@ -0,0 +1,79 @@
|
||||
# adapter.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `BunnyCdnAdapterOptions` — the option bag `BunnyCdnAdapter` accepts — and the supporting `BunnyRetryOptions` shape. The interface extends `CdnAdapterOptions` from `@suaveplan/storage-cdn` so it carries the `originBaseUrl` / `edgeBaseUrl` slots every CDN provider in the family exposes, then adds BunnyCDN-specific fields: storage zone + access key, pull zone hostname, region, optional Token Authentication key, optional purge API key, and the standard injectable hooks (`fetch`, `timers`, `now`, `headers`).
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `CdnAdapterOptions` so cross-provider helpers can accept either shape.
|
||||
- `storageZone` + `storageAccessKey` for Storage Zone CRUD; both required.
|
||||
- `pullZoneHostname` is a bare host (no scheme, no trailing slash) — validated by the Zod schema.
|
||||
- `storageRegion` whitelisted to BunnyCDN's documented regions (`de`/`ny`/`la`/`sg`/`syd`/`uk`); defaults to `de` (Falkenstein, unprefixed).
|
||||
- `tokenAuthKey` optional — required only for `signUrl`.
|
||||
- `purgeApiKey` optional — required only for `purge`/`purgePrefix`.
|
||||
- Injectable `fetch`, `timers`, `now`, and per-key `headers` resolver for testability.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { BunnyCdnAdapterOptions } from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const options: BunnyCdnAdapterOptions = {
|
||||
storageZone: "myz",
|
||||
storageAccessKey: "ak",
|
||||
pullZoneHostname: "mycdn.b-cdn.net",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BunnyCdnAdapterOptions,
|
||||
BunnyRetryOptions,
|
||||
} from "@suaveplan/storage-cdn-bunnycdn";
|
||||
|
||||
const retry: BunnyRetryOptions = {
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 200,
|
||||
maxDelayMs: 10_000,
|
||||
honourRetryAfter: true,
|
||||
};
|
||||
|
||||
const options: BunnyCdnAdapterOptions = {
|
||||
storageZone: "myz",
|
||||
storageAccessKey: process.env["BUNNY_STORAGE_KEY"]!,
|
||||
pullZoneHostname: "mycdn.b-cdn.net",
|
||||
storageRegion: "ny",
|
||||
tokenAuthKey: process.env["BUNNY_TOKEN_KEY"]!,
|
||||
purgeApiKey: process.env["BUNNY_PURGE_KEY"]!,
|
||||
headers: async (key) => ({ "x-suaveplan-key": key }),
|
||||
retry,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface BunnyCdnAdapterOptions extends CdnAdapterOptions`
|
||||
|
||||
- `storageZone: string` — required Storage Zone name.
|
||||
- `storageAccessKey: string` — required per-zone storage password.
|
||||
- `pullZoneHostname: string` — bare host of the Pull Zone, e.g. `mycdn.b-cdn.net`.
|
||||
- `storageRegion?: BunnyCdnRegion` — defaults to `"de"`.
|
||||
- `tokenAuthKey?: string` — required only when calling `signUrl`.
|
||||
- `purgeApiKey?: string` — required only when calling `purge`/`purgePrefix`.
|
||||
- `fetch?`, `timers?`, `now?` — injectable for tests.
|
||||
- `headers?` — static record or `(key) => Record<string, string> | Promise<...>`.
|
||||
- `retry?: BunnyRetryOptions` — retry tuning.
|
||||
|
||||
### `interface BunnyRetryOptions`
|
||||
|
||||
`{ maxAttempts?, baseDelayMs?, maxDelayMs?, honourRetryAfter? }` — defaults documented in `constants.md`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `pullZoneHostname` validation rejects scheme prefixes and trailing slashes at the Zod boundary — the most common configuration mistake is pasting the full URL the Bunny dashboard displays, which would silently produce broken edge URLs.
|
||||
- `tokenAuthKey` and `purgeApiKey` are intentionally separate fields because they're different secrets with different scopes — Token Authentication is a per-Pull-Zone signing key, while the purge API key is account-level.
|
||||
- `storageRegion` is a strict literal union enforced at the Zod boundary; passing an unknown region surfaces as `STORAGE_CDN_BUNNYCDN.INVALID_REGION` at construction time.
|
||||
- `originBaseUrl` / `edgeBaseUrl` (inherited from `CdnAdapterOptions`) are optional and unused by this adapter's logic — `pullZoneHostname` is the authoritative public edge — but they're accepted (and trailing-slash-validated when present) for cross-provider helpers that work against either base type.
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# @suaveplan/storage-cdn - Types-only foundation for CDN-flavoured @suaveplan/storage adapters: CdnAdapter abstract base extending AbstractStorage with purge/signUrl/edgeUrl, CdnCapabilities matrix, Zod-validated SignUrlOptions, STORAGE_CDN error registry, type-guard helpers, and a module-augmentation slot on @suaveplan/storage's capabilities map. Provider packages (storage-cdn-rackspace, storage-cdn-bunnycdn, …) implement against this contract.
|
||||
|
||||
Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`, `signUrl`, and `edgeUrl`), the `CdnCapabilities` boolean matrix, Zod-validated `SignUrlOptions`, the `STORAGE_CDN` error registry, type-guard helpers, and the module augmentation that adds an optional `cdn?: CdnCapabilities` slot to `@suaveplan/storage`'s capabilities map. Provider packages (`storage-cdn-rackspace`, `storage-cdn-bunnycdn`, …) implement against this contract.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [cdn-adapter](./src/cdn-adapter/cdn-adapter.md) — Declares `CdnAdapter`, the abstract base class every CDN-flavoured storage adapter in the family extends. Layers three…
|
||||
- [cdn-adapter](./src/cdn-adapter/cdn-adapter.md) — Declares `CdnAdapter`, the abstract base class every CDN-flavoured storage adapter in the family extends. Layers three…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN` error namespace shared by every CDN provider package in the family — `storage-cdn-…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_CDN` error namespace shared by every CDN provider package in the family — `storage-cdn-…
|
||||
- [guards](./src/guards/guards.md) — Ships two type-guards for `CdnAdapter`: the non-throwing `isCdnAdapter` predicate and the throwing `assertCdnAdapter` a…
|
||||
- [guards](./src/guards/guards.md) — Ships two type-guards for `CdnAdapter`: the non-throwing `isCdnAdapter` predicate and the throwing `assertCdnAdapter` a…
|
||||
- [sign-url](./src/sign-url/sign-url.md) — Owns the boundary validation for `CdnAdapter.signUrl` callers. Wraps the Zod `SignUrlOptionsSchema` parse and converts…
|
||||
- [sign-url](./src/sign-url/sign-url.md) — Owns the boundary validation for `CdnAdapter.signUrl` callers. Wraps the Zod `SignUrlOptionsSchema` parse and converts…
|
||||
- [storage-augment](./src/storage-augment.md) — Owns the TypeScript module augmentation that adds an optional `cdn?: CdnCapabilities` slot to `@suaveplan/storage`'s `S…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-cdn
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CdnAdapter, parseSignUrlOptions } from "@suaveplan/storage-cdn";
|
||||
import { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
|
||||
class MyCdn extends CdnAdapter {
|
||||
// ... AbstractStorage methods ...
|
||||
public override async purge(key: string): Promise<void> {
|
||||
await this.client.invalidate(key);
|
||||
}
|
||||
public override async signUrl(key: string, options: SignUrlOptions): Promise<string> {
|
||||
const opts = parseSignUrlOptions(options);
|
||||
return await this.client.presign(key, opts);
|
||||
}
|
||||
public override edgeUrl(key: string): string {
|
||||
return `https://edge.example.com/${key}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Consumers that just need to test whether a `StorageLike` supports CDN features use the guards:
|
||||
|
||||
```ts
|
||||
import { isCdnAdapter } from "@suaveplan/storage-cdn";
|
||||
|
||||
if (isCdnAdapter(storage)) {
|
||||
await storage.purge("a/b.bin");
|
||||
const signed = await storage.signUrl("a/b.bin", { ttlSeconds: 60 });
|
||||
}
|
||||
```
|
||||
|
||||
## Family role
|
||||
|
||||
```text
|
||||
@suaveplan/storage AbstractStorage core (key/value, in-memory)
|
||||
└─ @suaveplan/storage-http Generic prefix+key reader (no purge/signing)
|
||||
└─ @suaveplan/storage-cdn CdnAdapter contract (THIS PACKAGE)
|
||||
├─ @suaveplan/storage-cdn-rackspace Cloud Files origin + Rackspace CDN
|
||||
└─ @suaveplan/storage-cdn-bunnycdn BunnyCDN Storage origin + Pull Zone
|
||||
```
|
||||
|
||||
A consumer that only needs read access to a CDN-fronted static site uses `storage-http`. A consumer that needs purge and signed URLs uses a `storage-cdn-*` provider. The two abstractions stay separable so neither pulls weight the other does not need.
|
||||
|
||||
## Modules
|
||||
|
||||
- [error-codes](./src/error-codes.md) — `STORAGE_CDN_CODES` registry + `StorageCdnError`.
|
||||
- [cdn-adapter](./src/cdn-adapter/cdn-adapter.md) — abstract `CdnAdapter` base + `defaultPurgeNotSupported` / `defaultSignUrlNotSupported` / `defaultPrefixPurgeNotSupported` helpers.
|
||||
- [sign-url](./src/sign-url/sign-url.md) — `parseSignUrlOptions` boundary validator.
|
||||
- [guards](./src/guards/guards.md) — `isCdnAdapter` / `assertCdnAdapter`.
|
||||
- [storage-augment](./src/storage-augment.md) — module augmentation for `capabilities.cdn`.
|
||||
- [types/cdn-adapter.types](./src/types/cdn-adapter.types.md) — `CdnAdapterOptions` base interface.
|
||||
- [types/cdn-capabilities.types](./src/types/cdn-capabilities.types.md) — `CdnCapabilities` + `DEFAULT_CDN_CAPABILITIES`.
|
||||
- [types/sign-url.types](./src/types/sign-url.types.md) — `SignUrlOptions` + `SignUrlMethod`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal purity** — no `node:*` or DOM-only imports. Types and Zod schemas only — no network, no I/O.
|
||||
- **Abstract `CdnAdapter` base** — three abstract methods (`purge`, `signUrl`, `edgeUrl`) every provider implements.
|
||||
- **Tightened capabilities** — `CdnAdapter.capabilities` requires a nested `cdn: CdnCapabilities` matrix; base `AbstractStorage` keeps it optional.
|
||||
- **Canonical error registry** — 8 stable codes (`PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`) shared by every provider.
|
||||
- **Boundary validation** — `parseSignUrlOptions` maps Zod issues to the canonical codes so every provider's diagnostic surface is identical.
|
||||
- **Type guards** — `isCdnAdapter` / `assertCdnAdapter` for safe narrowing from `StorageLike`.
|
||||
- **100 / 100 / 100 / 100 Istanbul coverage**.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE` at the repo root.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# cdn-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `CdnAdapter`, the abstract base class every CDN-flavoured storage adapter in the family extends. Layers three CDN-specific abstract methods (`purge`, `signUrl`, `edgeUrl`) on top of `AbstractStorage` and tightens `capabilities` to require a nested `cdn: CdnCapabilities` matrix. Also ships the canonical "not supported" reference implementations (`defaultPurgeNotSupported`, `defaultSignUrlNotSupported`, `defaultPrefixPurgeNotSupported`) so every provider's "this feature is off" surface looks identical to callers and to operator dashboards.
|
||||
|
||||
The class sits between `AbstractStorage` (the generic key-value contract) and concrete provider packages (`storage-cdn-rackspace`, `storage-cdn-bunnycdn`, …). Consumers that need provider-agnostic CDN behaviour code against `CdnAdapter` directly; consumers that need a specific provider's quirks (Rackspace TempURL key rotation, BunnyCDN Pull Zone hostname) reach for the provider package.
|
||||
|
||||
## Features
|
||||
|
||||
- Three abstract CDN methods every subclass MUST implement: `purge`, `signUrl`, `edgeUrl`.
|
||||
- `edgeUrl` is synchronous (pure URL composition) — every provider supports it because it is just URL math.
|
||||
- `purge` and `signUrl` are async to accommodate providers whose signing or invalidation requires an upstream call (R2 STS, Rackspace identity token refresh).
|
||||
- Helper functions raise `STORAGE_CDN.PURGE_NOT_SUPPORTED`, `STORAGE_CDN.SIGN_URL_NOT_SUPPORTED`, `STORAGE_CDN.PREFIX_PURGE_NOT_SUPPORTED` so every provider's "not supported" surface is uniform.
|
||||
- Optional `provider` attribute on the thrown errors so operator dashboards can group failures by upstream.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CdnAdapter, defaultPurgeNotSupported } from "@suaveplan/storage-cdn";
|
||||
|
||||
class ReadOnlyCdn extends CdnAdapter {
|
||||
// ... AbstractStorage methods ...
|
||||
public purge(): Promise<void> {
|
||||
defaultPurgeNotSupported("read-only");
|
||||
}
|
||||
public signUrl(): Promise<string> {
|
||||
return Promise.resolve("https://edge.example.com/x");
|
||||
}
|
||||
public edgeUrl(key: string): string {
|
||||
return `https://edge.example.com/${key}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CdnAdapter, defaultPurgeNotSupported, defaultPrefixPurgeNotSupported } from "@suaveplan/storage-cdn";
|
||||
|
||||
class MyProviderCdn extends CdnAdapter {
|
||||
public override readonly capabilities = {
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
cdn: {
|
||||
cdnPurge: true,
|
||||
cdnPrefixPurge: false,
|
||||
cdnSignedUrls: true,
|
||||
cdnOriginUploads: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
public override async purge(key: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.client.invalidate(key, signal);
|
||||
}
|
||||
|
||||
public override purgePrefix(): Promise<void> {
|
||||
return defaultPrefixPurgeNotSupported("my-provider");
|
||||
}
|
||||
// ... rest of impl ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `abstract class CdnAdapter extends AbstractStorage`
|
||||
|
||||
Adds three abstract methods plus a tightened `capabilities` shape.
|
||||
|
||||
- `abstract purge(key: string, signal?: AbortSignal): Promise<void>` — invalidate a single key.
|
||||
- `abstract signUrl(key, options: SignUrlOptions, signal?): Promise<string>` — short-lived signed URL.
|
||||
- `abstract edgeUrl(key: string): string` — pure synchronous URL composition.
|
||||
- `capabilities: StorageCapabilities & { cdn: CdnCapabilities }` — tighter than the base; subclasses MUST populate the nested matrix.
|
||||
|
||||
### `defaultPurgeNotSupported(provider?: string): never`
|
||||
|
||||
Throws `StorageCdnError(STORAGE_CDN.PURGE_NOT_SUPPORTED)`. `provider` attached when supplied.
|
||||
|
||||
### `defaultSignUrlNotSupported(provider?: string): never`
|
||||
|
||||
Throws `StorageCdnError(STORAGE_CDN.SIGN_URL_NOT_SUPPORTED)`.
|
||||
|
||||
### `defaultPrefixPurgeNotSupported(provider?: string): never`
|
||||
|
||||
Throws `StorageCdnError(STORAGE_CDN.PREFIX_PURGE_NOT_SUPPORTED)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `edgeUrl` is intentionally synchronous because URL composition never fails — forcing it async would push every consumer through an unnecessary `await`. `signUrl` and `purge` are async because real providers do make upstream calls (STS, identity, purge API).
|
||||
- The `capabilities` type intersects `StorageCapabilities` with a required `{ cdn: CdnCapabilities }`. The module augmentation in `storage-augment.ts` makes `cdn` optional on the base type — required on `CdnAdapter` but absent on plain `AbstractStorage` adapters.
|
||||
- "Not supported" helpers are thrown synchronously even though the abstract methods are async. Callers see a rejected promise either way; the helper is simpler when it just `throws`.
|
||||
- Multi-key prefix purge is intentionally a separate flag (`cdnPrefixPurge`) and a separate "not supported" helper because many providers (Rackspace) support single-key purge but not prefix purge. Forcing them to be one flag would either mislead callers (claiming prefix when only per-key works) or force providers to declare neither.
|
||||
@@ -0,0 +1,75 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the canonical `STORAGE_CDN` error namespace shared by every CDN provider package in the family — `storage-cdn-rackspace`, `storage-cdn-bunnycdn`, and any future entrants. Codes are produced via `@suaveplan/error.defineCodes` so the resulting string values are guaranteed-unique within the workspace (`"STORAGE_CDN.PURGE_FAILED"`, `"STORAGE_CDN.SIGN_URL_NOT_SUPPORTED"`, …). The companion `StorageCdnError` class extends `SuaveplanError` and adds three diagnostic discriminants — `key`, `provider`, and `purgeId` — so consumers can correlate failures with the offending operation without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 stable error codes covering every CDN-specific failure mode.
|
||||
- `StorageCdnError` subclass with `key`, `provider`, and `purgeId` carry fields.
|
||||
- Cause-chain preservation via `SuaveplanError`'s `cause` option.
|
||||
- Type-safe `StorageCdnErrorCode` discriminant inferred from the `defineCodes` tuple.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { STORAGE_CDN_CODES, StorageCdnError } from "@suaveplan/storage-cdn";
|
||||
|
||||
throw new StorageCdnError(
|
||||
STORAGE_CDN_CODES.PURGE_FAILED,
|
||||
"Edge purge timed out",
|
||||
{ key: "a/b.bin", provider: "rackspace", purgeId: "req-1234" },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { STORAGE_CDN_CODES, StorageCdnError } from "@suaveplan/storage-cdn";
|
||||
|
||||
try {
|
||||
await storage.purge("a/b.bin");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageCdnError && err.code === STORAGE_CDN_CODES.PURGE_FAILED) {
|
||||
operatorLog.error(
|
||||
{ key: err.key, provider: err.provider, purgeId: err.purgeId },
|
||||
"CDN purge failed — alerting on-call",
|
||||
);
|
||||
await pagerDuty.alert({ summary: `Purge failed: ${err.message}` });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `STORAGE_CDN_CODES`
|
||||
|
||||
Frozen record produced by `defineCodes("STORAGE_CDN", [...] as const)`. The string values are namespaced — `STORAGE_CDN_CODES.PURGE_FAILED === "STORAGE_CDN.PURGE_FAILED"`.
|
||||
|
||||
Codes: `PURGE_FAILED`, `SIGN_URL_NOT_SUPPORTED`, `PURGE_NOT_SUPPORTED`, `PREFIX_PURGE_NOT_SUPPORTED`, `INVALID_TTL`, `INVALID_OPTIONS`, `EDGE_ORIGIN_MISMATCH`, `CREDENTIAL_EXPIRED`.
|
||||
|
||||
### `StorageCdnErrorCode`
|
||||
|
||||
Union of the namespaced code string values.
|
||||
|
||||
### `class StorageCdnError extends SuaveplanError<StorageCdnErrorCode>`
|
||||
|
||||
- `constructor(code, message, options?)` — `options` accepts `cause`, `details`, `key`, `provider`, `purgeId`.
|
||||
- `key: string | undefined` — sanitised key on which the failure occurred.
|
||||
- `provider: string | undefined` — provider identifier (`"rackspace"`, `"bunnycdn"`, …).
|
||||
- `purgeId: string | undefined` — provider-issued purge request id when available.
|
||||
- `name = "StorageCdnError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `STORAGE_CDN.SIGN_URL_NOT_SUPPORTED` and `STORAGE_CDN.PURGE_NOT_SUPPORTED` are produced by the shipped `defaultSignUrlNotSupported()` / `defaultPurgeNotSupported()` helpers so every provider's "not supported" surface is identical.
|
||||
- `STORAGE_CDN.PREFIX_PURGE_NOT_SUPPORTED` exists separately from `PURGE_NOT_SUPPORTED` because providers commonly support per-key purge but not prefix purge (Rackspace is the canonical example).
|
||||
- `STORAGE_CDN.EDGE_ORIGIN_MISMATCH` is the configuration guard raised when a provider adapter detects that its `originBaseUrl` and `edgeBaseUrl` reference different containers / zones — a common foot-gun that produces mysterious 404s at runtime.
|
||||
- `STORAGE_CDN.CREDENTIAL_EXPIRED` is provider-agnostic — Rackspace tokens, BunnyCDN purge keys, signed-URL signing keys all eventually expire and share the same code so monitoring rules generalise across providers.
|
||||
- The `key`, `provider`, and `purgeId` carry-fields are all optional so unit-test fixtures can construct minimal errors without inventing fake provider identifiers.
|
||||
@@ -0,0 +1,89 @@
|
||||
# guards
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Ships two type-guards for `CdnAdapter`: the non-throwing `isCdnAdapter`
|
||||
predicate and the throwing `assertCdnAdapter` assertion. Consumers that
|
||||
accept a structural `StorageLike` and need to opt into CDN features at
|
||||
the call site use these to narrow safely. The throwing variant raises a
|
||||
canonical `StorageCdnError` so the rejection has the same diagnostic
|
||||
surface as every other CDN error.
|
||||
|
||||
Without these helpers each consumer would re-implement
|
||||
`value instanceof CdnAdapter` checks and the assertion variant would
|
||||
either silently degrade or throw a plain `Error` — both bad outcomes for
|
||||
operator dashboards.
|
||||
|
||||
## Features
|
||||
|
||||
- Two functions, both tiny — `isCdnAdapter` and `assertCdnAdapter`.
|
||||
- Predicate form returns `value is CdnAdapter` so TypeScript narrows
|
||||
cleanly inside the `if` branch.
|
||||
- Assertion form uses TypeScript `asserts` syntax so the caller's flow
|
||||
is narrowed after the call without needing an explicit `if`.
|
||||
- Both rely on `instanceof CdnAdapter` — no structural duck typing, so
|
||||
third-party look-alikes don't sneak through.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isCdnAdapter } from "@suaveplan/storage-cdn";
|
||||
|
||||
if (isCdnAdapter(storage)) {
|
||||
await storage.purge("a/b.bin");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { assertCdnAdapter, STORAGE_CDN_CODES, StorageCdnError } from "@suaveplan/storage-cdn";
|
||||
|
||||
function purgeAfterWrite(storage: unknown, key: string): Promise<void> {
|
||||
assertCdnAdapter(storage); // throws if not a CdnAdapter
|
||||
return storage.purge(key);
|
||||
}
|
||||
|
||||
try {
|
||||
await purgeAfterWrite(new MemoryStorage(), "k");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageCdnError && err.code === STORAGE_CDN_CODES.INVALID_OPTIONS) {
|
||||
// caller passed the wrong storage type — surface a clear message.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isCdnAdapter(value: unknown): value is CdnAdapter`
|
||||
|
||||
Returns `true` when `value instanceof CdnAdapter`. Inside the `if`
|
||||
branch TypeScript narrows `value` to `CdnAdapter`. Safe to call with
|
||||
`null`, `undefined`, primitives, or arbitrary objects — all yield
|
||||
`false`.
|
||||
|
||||
### `assertCdnAdapter(value: unknown): asserts value is CdnAdapter`
|
||||
|
||||
Throws `StorageCdnError(STORAGE_CDN.INVALID_OPTIONS)` when `value` is
|
||||
not a `CdnAdapter`. After the call, TypeScript treats `value` as
|
||||
`CdnAdapter`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Both functions rely strictly on `instanceof`. Structural duck typing
|
||||
would let any object with `purge`, `signUrl`, `edgeUrl` methods pass —
|
||||
acceptable for many type guards, but the CdnAdapter contract includes
|
||||
the full `AbstractStorage` surface and the capabilities matrix, so
|
||||
instanceof is the right floor.
|
||||
- The assertion uses the canonical `STORAGE_CDN.INVALID_OPTIONS` code
|
||||
rather than inventing a new "not a CdnAdapter" code — the configuration
|
||||
mistake is the caller passing the wrong type, which is exactly what
|
||||
that code is for.
|
||||
- The functions intentionally don't call `Object.getPrototypeOf` or
|
||||
similar; the `instanceof` check is sufficient and avoids dragging in
|
||||
prototype-chain edge cases (cross-realm objects, etc.) which provider
|
||||
packages would have to defend against anyway.
|
||||
@@ -0,0 +1,91 @@
|
||||
# sign-url
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the boundary validation for `CdnAdapter.signUrl` callers. Wraps the Zod
|
||||
`SignUrlOptionsSchema` parse and converts schema failures into the canonical
|
||||
`StorageCdnError` codes — `STORAGE_CDN.INVALID_TTL` when `ttlSeconds <= 0`
|
||||
or non-integer, `STORAGE_CDN.INVALID_OPTIONS` for any other schema failure.
|
||||
Provider adapters invoke `parseSignUrlOptions` at the top of their `signUrl`
|
||||
implementations so every provider returns the same diagnostic surface on
|
||||
invalid input.
|
||||
|
||||
Without this module, every provider would either replicate the Zod parse
|
||||
boilerplate or invent its own error mapping — neither outcome is good for
|
||||
operators trying to write provider-agnostic alerting rules.
|
||||
|
||||
## Features
|
||||
|
||||
- One exported function (`parseSignUrlOptions`) — keeps the public surface
|
||||
trivial.
|
||||
- Distinct error codes for `ttlSeconds` failures vs. everything else, so
|
||||
monitoring rules can alert separately on "TTL out of range" vs. "caller
|
||||
is misconfigured".
|
||||
- Returns a clean `SignUrlOptions` value with optional fields strictly
|
||||
omitted (not `undefined`) to satisfy `exactOptionalPropertyTypes`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseSignUrlOptions } from "@suaveplan/storage-cdn";
|
||||
|
||||
const parsed = parseSignUrlOptions({ ttlSeconds: 60, method: "GET" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CdnAdapter,
|
||||
parseSignUrlOptions,
|
||||
STORAGE_CDN_CODES,
|
||||
StorageCdnError,
|
||||
} from "@suaveplan/storage-cdn";
|
||||
|
||||
class MyCdn extends CdnAdapter {
|
||||
public override async signUrl(key: string, options: SignUrlOptions): Promise<string> {
|
||||
const opts = parseSignUrlOptions(options); // throws on bad input
|
||||
const url = await this.client.presign(key, opts.method ?? "GET", opts.ttlSeconds);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await myCdn.signUrl("a/b.bin", { ttlSeconds: 0 });
|
||||
} catch (err) {
|
||||
if (err instanceof StorageCdnError && err.code === STORAGE_CDN_CODES.INVALID_TTL) {
|
||||
// distinct from INVALID_OPTIONS — alert pipeline can fire a specific rule.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseSignUrlOptions(options: SignUrlOptions): SignUrlOptions`
|
||||
|
||||
Validates `options` against `SignUrlOptionsSchema`. On success, returns a
|
||||
fresh `SignUrlOptions` value with optional fields strictly omitted (not
|
||||
set to `undefined`). On failure, throws:
|
||||
|
||||
- `StorageCdnError(STORAGE_CDN.INVALID_TTL)` when the `ttlSeconds` issue
|
||||
is present.
|
||||
- `StorageCdnError(STORAGE_CDN.INVALID_OPTIONS)` otherwise (e.g. method
|
||||
outside whitelist, empty `responseContentType`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Zod parse runs once per call — the schema is module-level so there
|
||||
is no per-call construction overhead.
|
||||
- The `ttlSeconds` detection scans the issues array for any issue whose
|
||||
path begins with `ttlSeconds`. This catches both "out of range" and
|
||||
"not an integer" — both surface as `INVALID_TTL` because they are
|
||||
the same caller mistake (passing a bad TTL).
|
||||
- Optional-field reconstruction explicitly skips fields the caller
|
||||
omitted; this preserves the `exactOptionalPropertyTypes` invariant
|
||||
downstream (some providers' API clients hard-fail on `method: undefined`).
|
||||
- The function is intentionally narrow — it does not sign anything. Real
|
||||
signing is provider-specific and lives in the provider package.
|
||||
@@ -0,0 +1,88 @@
|
||||
# storage-augment
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the TypeScript module augmentation that adds an optional
|
||||
`cdn?: CdnCapabilities` slot to `@suaveplan/storage`'s `StorageCapabilities`
|
||||
interface. Importing this module is a side-effect — there is no runtime
|
||||
export — but it teaches every downstream consumer's TypeScript program
|
||||
about the new optional field so `storage.capabilities.cdn` typechecks
|
||||
everywhere.
|
||||
|
||||
Without this file, provider packages would each have to redeclare the
|
||||
augmentation, and every consumer reading `capabilities.cdn` would either
|
||||
fail to compile or rely on a structural cast.
|
||||
|
||||
## Features
|
||||
|
||||
- One module augmentation, declared once.
|
||||
- Additive — base storage adapters that do not opt in continue to
|
||||
typecheck unchanged.
|
||||
- No runtime overhead — `declare module` is erased after type-checking.
|
||||
- Side-effect import pulled from the package root barrel so consumers
|
||||
who `import "@suaveplan/storage-cdn"` automatically receive the
|
||||
augmentation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/storage-cdn"; // pulls in the augmentation
|
||||
import type { StorageCapabilities } from "@suaveplan/storage/types";
|
||||
|
||||
const cap: StorageCapabilities = {
|
||||
partialReads: false,
|
||||
partialWrites: false,
|
||||
nativeStreams: false,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
};
|
||||
// cap.cdn is now legal (optional).
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import "@suaveplan/storage-cdn";
|
||||
import type { StorageCapabilities } from "@suaveplan/storage/types";
|
||||
import type { CdnCapabilities } from "@suaveplan/storage-cdn";
|
||||
|
||||
function describeStorage(cap: StorageCapabilities): string {
|
||||
if (cap.cdn !== undefined) {
|
||||
const cdn: CdnCapabilities = cap.cdn;
|
||||
return `CDN: purge=${cdn.cdnPurge}, signed=${cdn.cdnSignedUrls}`;
|
||||
}
|
||||
return "no CDN features";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Module augmentation
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/storage/types" {
|
||||
interface StorageCapabilities {
|
||||
readonly cdn?: CdnCapabilities;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No runtime exports — the file's `export {}` is required only to make
|
||||
TypeScript treat the file as a module rather than a script.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The augmentation makes `cdn` optional, NOT required, on the base type.
|
||||
Subclasses of `CdnAdapter` tighten the type to require it via the
|
||||
intersection `StorageCapabilities & { cdn: CdnCapabilities }` on
|
||||
`CdnAdapter.capabilities`. Plain `AbstractStorage` subclasses (e.g.
|
||||
`MemoryStorage`, `NodeFsStorage`, `HttpStorage`) leave it `undefined`.
|
||||
- The file is imported for side-effects at the root barrel
|
||||
(`src/index.ts`). Subpath imports of `./cdn-adapter` or `./guards`
|
||||
alone may not trigger the augmentation, but every package that depends
|
||||
on `@suaveplan/storage-cdn` declares the dep and imports through the
|
||||
root barrel at least once during bootstrap, so the augmentation is
|
||||
loaded before any read of `capabilities.cdn` happens at runtime.
|
||||
- Pure TypeScript erasure — there is zero JavaScript output that
|
||||
touches `StorageCapabilities`. Production bundles don't pay any
|
||||
byte cost.
|
||||
@@ -0,0 +1,83 @@
|
||||
# cdn-adapter.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `CdnAdapterOptions`, the base interface that provider-specific
|
||||
adapter option bags extend. The interface fixes the two-URL split every
|
||||
real CDN exhibits — a private `originBaseUrl` for writes and a public
|
||||
`edgeBaseUrl` for reads — and a `credentials` slot each provider
|
||||
narrows to its own concrete shape.
|
||||
|
||||
Without this base type, each provider would invent its own option-bag
|
||||
shape from scratch, and code that wants to write provider-agnostic
|
||||
helpers (e.g. validating that the two URLs reference the same container)
|
||||
would have to reach for `any`.
|
||||
|
||||
## Features
|
||||
|
||||
- Two distinct URL fields so the origin / edge split is enforced at the
|
||||
type level.
|
||||
- `credentials: unknown` slot — provider packages narrow this to their
|
||||
own concrete shape via interface inheritance.
|
||||
- Both URLs are plain strings (not `URL` objects) so option bags
|
||||
serialise cleanly to JSON for config files.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { CdnAdapterOptions } from "@suaveplan/storage-cdn";
|
||||
|
||||
const base: CdnAdapterOptions = {
|
||||
originBaseUrl: "https://storage101.lon3.clouddrive.com/v1/AUTH_acct/container/",
|
||||
edgeBaseUrl: "https://abc.r88.cf6.rackcdn.com/",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { CdnAdapterOptions } from "@suaveplan/storage-cdn";
|
||||
|
||||
interface RackspaceCredentials {
|
||||
username: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface RackspaceCdnOptions extends CdnAdapterOptions {
|
||||
credentials: RackspaceCredentials;
|
||||
region: "LON" | "IAD" | "DFW" | "ORD" | "SYD" | "HKG";
|
||||
container: string;
|
||||
tempUrlKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface CdnAdapterOptions`
|
||||
|
||||
- `originBaseUrl: string` — required; the (often private) write
|
||||
endpoint. Must end with `/`.
|
||||
- `edgeBaseUrl: string` — required; the public read URL the CDN serves
|
||||
from. Must end with `/`.
|
||||
- `credentials?: unknown` — provider-specific bundle; each provider
|
||||
package narrows this.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The two URLs are deliberately separate string fields, not a single
|
||||
`baseUrl`. Every real CDN exhibits this split (Rackspace `storage101`
|
||||
vs. `r88.cf6.rackcdn`; BunnyCDN `storage.bunnycdn.com` vs.
|
||||
`<zone>.b-cdn.net`). Conflating them silently produces broken signed
|
||||
URLs (the signature is over the origin path but the caller hands the
|
||||
edge URL to a browser).
|
||||
- The trailing-slash invariant is documented but not enforced at this
|
||||
type level — provider packages enforce it via their Zod option
|
||||
schemas because Zod can validate string content but TypeScript types
|
||||
cannot.
|
||||
- `credentials: unknown` is the safest default. Tightening it to a
|
||||
union would either be premature (we don't know every provider's
|
||||
shape) or force every provider to pollute the union; leaving it
|
||||
`unknown` makes the field future-proof.
|
||||
- The interface intentionally has no optional fields. Provider
|
||||
packages add their own optional knobs (region defaults, retry tuning,
|
||||
etc.) on the extended interface.
|
||||
@@ -0,0 +1,93 @@
|
||||
# cdn-capabilities.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares `CdnCapabilities` — the boolean-flag matrix every concrete
|
||||
`CdnAdapter` subclass populates so consumers can test feature
|
||||
availability without trying-and-catching. Also exports
|
||||
`DEFAULT_CDN_CAPABILITIES`, the all-`false` defaults that any subclass
|
||||
can use as a starting point or that test fixtures use when they need a
|
||||
"no features" stub.
|
||||
|
||||
The four flags map directly to the four observable CDN behaviours
|
||||
worth distinguishing across providers: single-key purge, prefix purge,
|
||||
signed URLs, and origin uploads. Anything more granular (specific
|
||||
cache-control directives, log delivery, edge logic) is provider-specific
|
||||
and lives in the provider package.
|
||||
|
||||
## Features
|
||||
|
||||
- Four canonical boolean flags — `cdnPurge`, `cdnPrefixPurge`,
|
||||
`cdnSignedUrls`, `cdnOriginUploads`.
|
||||
- `DEFAULT_CDN_CAPABILITIES` constant with every flag `false` so
|
||||
subclasses can spread it and override only the flags they support.
|
||||
- Frozen at compile time via `readonly` so consumers can rely on the
|
||||
matrix not mutating during a session.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { CdnCapabilities } from "@suaveplan/storage-cdn";
|
||||
|
||||
const matrix: CdnCapabilities = {
|
||||
cdnPurge: true,
|
||||
cdnPrefixPurge: false,
|
||||
cdnSignedUrls: true,
|
||||
cdnOriginUploads: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
type CdnCapabilities,
|
||||
DEFAULT_CDN_CAPABILITIES,
|
||||
} from "@suaveplan/storage-cdn";
|
||||
|
||||
class MyCdn extends CdnAdapter {
|
||||
public override readonly capabilities = {
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
cdn: {
|
||||
...DEFAULT_CDN_CAPABILITIES,
|
||||
cdnPurge: true,
|
||||
cdnSignedUrls: true,
|
||||
} satisfies CdnCapabilities,
|
||||
} as const;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface CdnCapabilities`
|
||||
|
||||
All `readonly` boolean fields:
|
||||
|
||||
- `cdnPurge` — single-key cache invalidation.
|
||||
- `cdnPrefixPurge` — prefix / wildcard purge in one API call.
|
||||
- `cdnSignedUrls` — short-lived signed URLs (GET / PUT / HEAD).
|
||||
- `cdnOriginUploads` — origin-side uploads via a separate write endpoint.
|
||||
|
||||
### `DEFAULT_CDN_CAPABILITIES: CdnCapabilities`
|
||||
|
||||
Every flag `false`. Use as a starting point when authoring a provider
|
||||
adapter or as a no-op fixture in tests.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All flags are `readonly` because the capabilities matrix represents
|
||||
what the underlying provider can do — it cannot change between calls
|
||||
in a single session. If a provider's feature set changes (e.g. a
|
||||
customer upgrades their plan), the consumer constructs a new adapter
|
||||
instance.
|
||||
- The four flags are deliberately conservative — every real provider
|
||||
fits into them. Adding a fifth flag means defining what it means
|
||||
across Rackspace, BunnyCDN, R2, Cloudflare, Fastly, CloudFront, etc.,
|
||||
which is a major design change, not a minor extension.
|
||||
- `DEFAULT_CDN_CAPABILITIES` is exported as `CdnCapabilities` (not
|
||||
`Readonly<CdnCapabilities>`) so spreading it preserves the readonly
|
||||
shape of the field types.
|
||||
@@ -0,0 +1,86 @@
|
||||
# sign-url.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the option shape callers pass to `CdnAdapter.signUrl` —
|
||||
`SignUrlOptions` — and the supporting `SignUrlMethod` union. Provider
|
||||
adapters validate the option bag via `SignUrlOptionsSchema` and the
|
||||
`parseSignUrlOptions` helper, then translate the parsed values into
|
||||
their own provider-specific signing parameters.
|
||||
|
||||
The shape is intentionally small: a TTL, a method, and two
|
||||
response-rewrite fields (the documented S3/R2/Azure pattern). Anything
|
||||
more provider-specific belongs in the provider package's extended
|
||||
option bag.
|
||||
|
||||
## Features
|
||||
|
||||
- `SignUrlMethod` whitelist — `"GET" | "PUT" | "HEAD"`. Other HTTP
|
||||
methods are not meaningful for signed-URL semantics across major
|
||||
providers.
|
||||
- `ttlSeconds` is the only required field — provider defaults fill
|
||||
everything else.
|
||||
- Response-rewrite fields are optional — most callers don't set them
|
||||
and providers default to the stored content type.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { SignUrlOptions } from "@suaveplan/storage-cdn";
|
||||
|
||||
const options: SignUrlOptions = { ttlSeconds: 60 };
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { SignUrlMethod, SignUrlOptions } from "@suaveplan/storage-cdn";
|
||||
|
||||
function buildReadOptions(ttl: number, method: SignUrlMethod = "GET"): SignUrlOptions {
|
||||
return {
|
||||
ttlSeconds: ttl,
|
||||
method,
|
||||
responseContentType: "application/octet-stream",
|
||||
responseContentDisposition: 'attachment; filename="download.bin"',
|
||||
};
|
||||
}
|
||||
|
||||
const opts = buildReadOptions(300);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type SignUrlMethod = "GET" | "PUT" | "HEAD"`
|
||||
|
||||
Method whitelist for signed URLs. Providers that don't support PUT
|
||||
(BunnyCDN Pull Zone Token Auth, Rackspace TempURL GET-only mode) raise
|
||||
`STORAGE_CDN.SIGN_URL_NOT_SUPPORTED` or a provider-specific error at
|
||||
the signing site.
|
||||
|
||||
### `interface SignUrlOptions`
|
||||
|
||||
- `ttlSeconds: number` — required; validity window in whole seconds
|
||||
(> 0).
|
||||
- `method?: SignUrlMethod` — defaults to `"GET"` at the provider site.
|
||||
- `responseContentType?: string` — optional response-content-type
|
||||
override.
|
||||
- `responseContentDisposition?: string` — optional
|
||||
response-content-disposition override.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The TTL is in seconds, not milliseconds — every real provider's signing
|
||||
format uses seconds (S3 `X-Amz-Expires`, Swift TempURL `temp_url_expires`,
|
||||
BunnyCDN `expires`). Forcing milliseconds would only inflict
|
||||
per-provider unit conversion.
|
||||
- The method whitelist is closed (`GET | PUT | HEAD`). Adding methods
|
||||
requires defining their semantics across providers — out of scope for
|
||||
v0 and a major change for v1.
|
||||
- The response-rewrite fields are deliberately named after the AWS
|
||||
S3 query parameters (`response-content-type`,
|
||||
`response-content-disposition`) because that is the de-facto
|
||||
cross-provider naming and the spec consumers expect.
|
||||
- The interface intentionally has no provider-specific fields.
|
||||
Provider packages with extra signing knobs (e.g. Rackspace
|
||||
`temp-url-prefix` mode) expose those via their own helper
|
||||
functions, not by extending this option bag.
|
||||
@@ -0,0 +1,88 @@
|
||||
# @suaveplan/storage-file - Node.js filesystem AbstractStorage adapter for @suaveplan/storage: atomic writes via temp-rename, AbortSignal everywhere, strict key sanitisation, native Web Streams, partial reads, transient-error retry via @suaveplan/retry, full OTel surface.
|
||||
|
||||
## Summary
|
||||
|
||||
Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every async method, strict key sanitisation, transient-error retry via `@suaveplan/retry`, native Web Streams via `node:fs.createReadStream`/`createWriteStream`, and positioned partial reads. Full OpenTelemetry surface: per-op spans, latency histograms, byte-flow histograms, kind-split counters.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [bun-fs](./src/bun-fs/bun-fs.md) — `bun-fs` is a sibling of `node-fs` that satisfies the same structural `NodeFsLike` contract consumed by `NodeFsStorage`…
|
||||
- [bun-fs](./src/bun-fs/bun-fs.md) — `bun-fs` is a sibling of `node-fs` that satisfies the same structural `NodeFsLike` contract consumed by `NodeFsStorage`…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - node-fs-storage.ops — Houses the file-system operat…
|
||||
- [node-fs-storage.helpers](./src/node-fs-storage/node-fs-storage.helpers.md) — Houses the pure helpers consumed by `NodeFsStorage` so the orchestrator class stays under the file-size budget and the…
|
||||
- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - node-fs-storage.ops — Houses the file-system operat…
|
||||
- [node-fs-storage.ops](./src/node-fs-storage/node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Defines the frozen `STORAGE_FILE_CODES` registry and the `StorageFileError` class used by every public path in `@suavep…
|
||||
<!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-fs-storage.helpers](./src/node-fs-storage/node-fs-storage.helpers.md) — Houses the pure helpers consumed by `NodeFsStorage` so the orchestrator class stays under the file-size budget and the…
|
||||
- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-fs-storage.ops](./src/node-fs-storage/node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_ - [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_- [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_ - [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen `STORAGE_FILE_CODES` registry and the `StorageFileError` class used by every public path in `@suavep…
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-file
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const storage = new NodeFsStorage({ rootDir: "/var/lib/app/storage" });
|
||||
|
||||
await storage.set("orders/42.json", new TextEncoder().encode("{\"id\":42}"));
|
||||
const bytes = await storage.get("orders/42.json");
|
||||
console.log(new TextDecoder().decode(bytes!));
|
||||
|
||||
// Streams + partial reads
|
||||
const writer = storage.createWriteStream("large.bin").getWriter();
|
||||
await writer.write(new Uint8Array(1024 * 1024));
|
||||
await writer.close();
|
||||
const slice = await storage.getPartial("large.bin", 0, 16);
|
||||
|
||||
// Factory registration (optional)
|
||||
import { registerNodeFsProvider } from "@suaveplan/storage-file";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
registerNodeFsProvider();
|
||||
const viaFactory = createStorage({ type: "file", config: { rootDir: "/tmp/store" } });
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes`](src/error-codes.md) — `STORAGE_FILE_CODES`, `StorageFileError`, `StorageFileErrorCode`
|
||||
- [`src/key-sanitizer/key-sanitizer`](src/key-sanitizer/key-sanitizer.md) — `sanitizeKey` strict boundary
|
||||
- [`src/node-fs/node-fs`](src/node-fs/node-fs.md) — `defaultNodeFs` adapter wrapping `node:fs/promises`
|
||||
- [`src/bun-fs/bun-fs`](src/bun-fs/bun-fs.md) — `defaultBunFs` / `pickDefaultFs` — Bun-native `Bun.file` / `Bun.write` fast path
|
||||
- [`src/retry-helper/retry-helper`](src/retry-helper/retry-helper.md) — `withFsRetry`, transient classifier
|
||||
- [`src/otel/otel`](src/otel/otel.md) — OTel instruments and `recordOp`
|
||||
- [`src/streams/streams`](src/streams/streams.md) — Web Streams bridge with atomic write
|
||||
- [`src/node-fs-storage/node-fs-storage`](src/node-fs-storage/node-fs-storage.md) — main `NodeFsStorage` class
|
||||
- [`src/node-fs-storage/node-fs-storage.helpers`](src/node-fs-storage/node-fs-storage.helpers.md) — pure helpers
|
||||
- [`src/registry/registry`](src/registry/registry.md) — factory registration hook
|
||||
- [`src/types/fs-adapter.types`](src/types/fs-adapter.types.md) — `NodeFsLike` interface
|
||||
- [`src/types/node-fs-storage.types`](src/types/node-fs-storage.types.md) — options + Zod schema
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Tier**: server (consumes `node:fs/promises` and `node:fs` stream constructors). Browser consumers should pick `@suaveplan/storage/memory`, an HTTP/IndexedDB adapter, or `@suaveplan/storage-s3`.
|
||||
- **Base class**: extends `AbstractStorage` from `@suaveplan/storage/abstract`. Every consumer that talks to `AbstractStorage` (or the `StorageLike` shim) works against this adapter unchanged.
|
||||
- **Atomicity**: every public write (`set`, metadata sidecar, `createWriteStream.close`) writes to a temp file in the destination directory and `fs.rename`s onto the destination. POSIX rename is atomic within a filesystem.
|
||||
- **Cancellation**: every public async method accepts an optional `AbortSignal`. Pre-aborted signals throw `STORAGE_FILE.ABORTED` before any IO call.
|
||||
- **Resilience**: transient errors (`EBUSY`, `EMFILE`, `ENFILE`, `EAGAIN`) are retried via `@suaveplan/retry` with exponential backoff and injectable `TimersLike`. Non-transient errors surface as `STORAGE_FILE.IO_FAILED` or `STORAGE_FILE.PERMISSION_DENIED`.
|
||||
- **Security**: every public method passes its key through `sanitizeKey`. Path-traversal, absolute paths, drive-letter prefixes, null bytes, control chars, and over-length keys all surface as `STORAGE_FILE.INVALID_KEY`/`KEY_TOO_LONG` before any filesystem call.
|
||||
- **Observability**: OTel counters, histograms, and a tracer emit from production paths. Tests assert against a real in-memory MeterProvider — no `vi.mock`.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# `bun-fs` — Bun-native `NodeFsLike` adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`bun-fs` is a sibling of [`node-fs`](../node-fs/node-fs.md) that satisfies the
|
||||
same structural `NodeFsLike` contract consumed by `NodeFsStorage`. Where
|
||||
`defaultNodeFs()` wires every method to `node:fs/promises` and the two
|
||||
`node:fs` stream constructors, `defaultBunFs()` overrides only the two
|
||||
methods that have a measurably faster Bun-native path — `readFile` (routed
|
||||
through `Bun.file(path).bytes()`) and `writeFile` (routed through
|
||||
`Bun.write(path, data)`). Every other method delegates to the Node base
|
||||
because under Bun those calls already dispatch to Bun's native (Zig)
|
||||
implementation transparently via the Node-compat layer, leaving no perf on
|
||||
the table.
|
||||
|
||||
This module exists so Suaveplan — which targets Bun as its first
|
||||
runtime — does not pay the Node-compat reshaping cost on the hot read/write
|
||||
loop. It is additive: no behaviour changes for callers that inject a custom
|
||||
`fs`, and the Node adapter remains the explicit choice when running outside
|
||||
Bun or when parity testing under Bun.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultBunFs(): NodeFsLike` — Bun-native adapter. Throws a plain `Error`
|
||||
if called outside a Bun runtime.
|
||||
- `pickDefaultFs(): NodeFsLike` — runtime-probed selector. Returns
|
||||
`defaultBunFs()` when `globalThis.Bun` is defined, otherwise
|
||||
`defaultNodeFs()`. The probe runs on every call (never cached) so tests
|
||||
can switch adapters deterministically.
|
||||
- Returns a fresh object per call so callers can selectively override any
|
||||
single method without mutating shared state.
|
||||
- Zero runtime branching inside individual methods — composition over
|
||||
conditionals.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NodeFsStorage, defaultBunFs } from "@suaveplan/storage-file";
|
||||
|
||||
const storage = new NodeFsStorage({
|
||||
rootDir: "/var/data",
|
||||
fs: defaultBunFs(),
|
||||
});
|
||||
|
||||
await storage.set("hello", new TextEncoder().encode("world"));
|
||||
const bytes = await storage.get("hello");
|
||||
console.log(new TextDecoder().decode(bytes!)); // "world"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Combine runtime detection with a custom override — useful when you want
|
||||
the Bun fast path everywhere except a tracing wrapper around `writeFile`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
NodeFsStorage,
|
||||
pickDefaultFs,
|
||||
type NodeFsLike,
|
||||
} from "@suaveplan/storage-file";
|
||||
|
||||
function tracedFs(): NodeFsLike {
|
||||
const base = pickDefaultFs();
|
||||
return {
|
||||
...base,
|
||||
async writeFile(path, data) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await base.writeFile(path, data);
|
||||
} finally {
|
||||
console.info("writeFile", path, performance.now() - start);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const storage = new NodeFsStorage({
|
||||
rootDir: "/var/data",
|
||||
fs: tracedFs(),
|
||||
});
|
||||
```
|
||||
|
||||
When you construct `NodeFsStorage` without passing `fs`, `resolveConfig`
|
||||
calls `pickDefaultFs()` for you — Bun-first, with Node fallback.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultBunFs(): NodeFsLike`
|
||||
|
||||
- **Returns** a fresh `NodeFsLike` whose `readFile` is implemented as
|
||||
`Bun.file(path).bytes()` and whose `writeFile` is implemented as
|
||||
`Bun.write(path, data)`. Every other method (`mkdir`, `readdir`,
|
||||
`rename`, `unlink`, `stat`, `access`, `open`, `createReadStream`,
|
||||
`createWriteStream`) delegates to the `defaultNodeFs()` base.
|
||||
- **Throws** a plain `Error` with the message
|
||||
`defaultBunFs() requires a Bun runtime (globalThis.Bun is undefined)` if
|
||||
the probe finds no Bun global. The intended entry point for adapter
|
||||
selection is `pickDefaultFs`, which performs the probe first.
|
||||
|
||||
### `pickDefaultFs(): NodeFsLike`
|
||||
|
||||
- **Returns** `defaultBunFs()` when `globalThis.Bun` is an object, otherwise
|
||||
`defaultNodeFs()`. Consumed by `resolveConfig` in
|
||||
`node-fs-storage.helpers.ts` to wire the default `fs` for `NodeFsStorage`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Streams are NOT routed through `Bun.file(path).stream()`. `BunFile.stream()`
|
||||
returns a Web `ReadableStream`, but `NodeFsLike.createReadStream` is typed
|
||||
as a Node `Readable`. Translating between the two introduces a copy that
|
||||
erases the speedup, so the Node `node:fs` constructor is used unchanged —
|
||||
it already dispatches to Bun's native impl under Bun.
|
||||
- The detection probe uses `typeof globalThis.Bun === "object"` rather than
|
||||
`"undefined" !== typeof Bun`. The former works without TypeScript ambient
|
||||
globals and is robust to module-scope mocking via
|
||||
`(globalThis as { Bun?: unknown }).Bun = undefined`.
|
||||
- `Bun.write` accepts both `Uint8Array` and `string`, matching
|
||||
`NodeFsLike.writeFile`'s union type. No buffer copying is performed by
|
||||
the adapter.
|
||||
- The adapter has zero `@suaveplan/*` runtime dependencies. It is internally
|
||||
consumed by `node-fs-storage.helpers.resolveConfig`, which is the only
|
||||
module that should call `pickDefaultFs` directly.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `error-codes`
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the frozen `STORAGE_FILE_CODES` registry and the `StorageFileError` class used by every public path in `@suaveplan/storage-file`. The registry is the contract: every code is intentionally thrown from at least one production path, and downstream consumers assert on `error.code` rather than parsing message text. The class extends `@suaveplan/error.SuaveplanError` so it integrates with the OTel span-event adapter and the error-envelope wire format.
|
||||
|
||||
## Features
|
||||
|
||||
- 8 frozen codes covering missing keys, permissions, IO failures, invalid input, atomic-write rollback, option validation, abort, and key length overrun.
|
||||
- `StorageFileErrorCode` union type derived from the registry — exhaustive `switch` statements get compile-time enforcement.
|
||||
- `StorageFileError` carries an optional `key` discriminant so callers can correlate a failure with the offending key without parsing strings.
|
||||
- All codes are namespaced `STORAGE_FILE.*` for unambiguous registry filtering in centralised error reporters.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StorageFileError, STORAGE_FILE_CODES } from "@suaveplan/storage-file";
|
||||
|
||||
try {
|
||||
await storage.get("../../etc/passwd");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageFileError && err.code === STORAGE_FILE_CODES.INVALID_KEY) {
|
||||
// caller-friendly message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { StorageFileError, STORAGE_FILE_CODES, type StorageFileErrorCode } from "@suaveplan/storage-file";
|
||||
|
||||
function classify(code: StorageFileErrorCode): "client" | "server" {
|
||||
switch (code) {
|
||||
case STORAGE_FILE_CODES.INVALID_KEY:
|
||||
case STORAGE_FILE_CODES.KEY_TOO_LONG:
|
||||
case STORAGE_FILE_CODES.INVALID_OPTIONS:
|
||||
return "client";
|
||||
case STORAGE_FILE_CODES.NOT_FOUND:
|
||||
case STORAGE_FILE_CODES.PERMISSION_DENIED:
|
||||
case STORAGE_FILE_CODES.IO_FAILED:
|
||||
case STORAGE_FILE_CODES.ATOMIC_WRITE_FAILED:
|
||||
case STORAGE_FILE_CODES.ABORTED:
|
||||
return "server";
|
||||
}
|
||||
}
|
||||
|
||||
throw new StorageFileError(STORAGE_FILE_CODES.NOT_FOUND, "no such key", {
|
||||
key: "/orders/42.json",
|
||||
cause: undefined,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `STORAGE_FILE_CODES` — frozen object map of code names to fully-qualified strings (`STORAGE_FILE.NOT_FOUND`, etc.). Exhaustive: 8 codes.
|
||||
- `StorageFileErrorCode` — union derived from `(typeof STORAGE_FILE_CODES)[keyof typeof STORAGE_FILE_CODES]`.
|
||||
- `StorageFileError` — class extending `SuaveplanError<StorageFileErrorCode>`. Constructor: `(code, message, options?)`. Properties: inherited `code`, inherited `cause`, additional `key?: string`.
|
||||
- `StorageFileErrorOptions` — extends `SuaveplanErrorOptions` with optional `key`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are registered by `defineCodes("STORAGE_FILE", [...])`. The registration is idempotent: importing this module multiple times does not re-register or throw.
|
||||
- The list is closed by design — adding a new failure mode requires an spec change so consumers can update their handlers in lockstep.
|
||||
- `name` is set to `"StorageFileError"` so `instanceof` and `error.name` checks both work.
|
||||
@@ -0,0 +1,59 @@
|
||||
# `key-sanitizer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates and normalises every caller-supplied storage key before any filesystem call. The sanitiser is the security boundary that prevents a caller-controlled key (`../../etc/passwd`) from escaping the configured `rootDir`. Every public method on `NodeFsStorage` passes its key through `sanitizeKey` before resolving any path or issuing any `node:fs` call. Failing the sanitiser is unrecoverable from the caller's perspective — it always reflects a programming error or a malicious input.
|
||||
|
||||
## Features
|
||||
|
||||
- Rejects empty strings, `..` traversal segments, POSIX absolute paths (`/foo`), Windows drive-letter prefixed paths (`C:\foo`, `c:/foo`).
|
||||
- Rejects null bytes (`\x00`) and any control char in the C0 range (`0x00`–`0x1F`) plus DEL (`0x7F`).
|
||||
- Enforces a maximum length of `MAX_KEY_LENGTH` (1024) characters; over-length keys surface as `STORAGE_FILE.KEY_TOO_LONG` (a distinct code from `INVALID_KEY` so callers can hint at the truncation).
|
||||
- Normalises Windows `\` separators to POSIX `/` so the same key resolves identically on every platform.
|
||||
- Pure function — no allocation beyond the normalised string return; safe to call on every operation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey } from "@suaveplan/storage-file";
|
||||
|
||||
const safe = sanitizeKey("orders/2026/05/12.json");
|
||||
// → "orders/2026/05/12.json"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey, StorageFileError, STORAGE_FILE_CODES } from "@suaveplan/storage-file";
|
||||
|
||||
function safeKey(input: string): string | null {
|
||||
try {
|
||||
return sanitizeKey(input);
|
||||
} catch (err) {
|
||||
if (err instanceof StorageFileError && (err.code === STORAGE_FILE_CODES.INVALID_KEY || err.code === STORAGE_FILE_CODES.KEY_TOO_LONG)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const normalised = sanitizeKey("orders\\2026\\05\\12.json");
|
||||
// → "orders/2026/05/12.json"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sanitizeKey(key: string): string` — returns the normalised POSIX-separator form on success.
|
||||
- Throws `StorageFileError` with code `STORAGE_FILE.INVALID_KEY` on empty input, control chars, absolute paths, drive-letter prefix, or `..` traversal.
|
||||
- Throws `StorageFileError` with code `STORAGE_FILE.KEY_TOO_LONG` when input length exceeds `MAX_KEY_LENGTH` (1024).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The control-char check iterates the string code unit by code unit rather than using a regex with a control-char range. This keeps biome's `noControlCharactersInRegex` lint happy without a suppression comment and is also marginally faster on short keys.
|
||||
- `..` is rejected segment-by-segment after `\\` normalisation, so both `foo\\..\\bar` and `foo/../bar` are caught.
|
||||
- Empty segments produced by consecutive separators (`foo//bar`) are NOT rejected — they are allowed for caller convenience and resolve to the same path. The host filesystem normalises them internally.
|
||||
- The key argument is not constrained beyond the rules above; arbitrary Unicode (including emoji and CJK) is permitted because `node:fs` accepts them and most modern filesystems do too.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `node-fs-storage.helpers`
|
||||
|
||||
## Purpose
|
||||
|
||||
Houses the pure helpers consumed by `NodeFsStorage` so the orchestrator class stays under the file-size budget and the helpers can be unit-tested without instantiating the full adapter. Every function here is either IO-free or wraps exactly one `NodeFsLike` call with documented error mapping.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildTempPath(forPath)` — same-directory temp filename with the canonical `.tmp-` prefix, a base-36 timestamp, and 8 chars of entropy. Co-located placement is required for POSIX `rename` atomicity.
|
||||
- `buildListPage(args)` — pure key-extraction + pagination over a `NodeFsLike.readdir` result. Sorts keys lexicographically before paginating.
|
||||
- `resolveListOptions(options, total)` — normalises `StorageListOptions` undefined-aware so callers don't repeat `?? 0` / `?? prefix`.
|
||||
- `readMetadataWithStatFallback(fs, metaPath, dataPath)` — reads the sidecar metadata file when present; falls back to synthesising `{ createdAt, modifiedAt, size }` from `fs.stat(dataPath)` when the sidecar is missing; returns `null` only when the data file is also missing.
|
||||
- `syntheticMetadata(clock, size)` — minimal `{ createdAt, modifiedAt, size }` triple built from the injectable clock.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildTempPath, buildListPage } from "@suaveplan/storage-file";
|
||||
|
||||
const temp = buildTempPath("/tmp/store/key.data");
|
||||
const page = buildListPage({ entries, fileExtension: ".data", prefix: "user/", offset: 0, limit: 50, continuationToken: undefined });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { readMetadataWithStatFallback, defaultNodeFs } from "@suaveplan/storage-file";
|
||||
|
||||
const fs = defaultNodeFs();
|
||||
const meta = await readMetadataWithStatFallback(fs, "/tmp/store/k.meta", "/tmp/store/k.data");
|
||||
if (meta === null) {
|
||||
// nothing exists for this key
|
||||
} else {
|
||||
// either a real sidecar or a synthesised stat record
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildTempPath(forPath: string): string` — temp filename in the same directory as `forPath`.
|
||||
- `buildListPage(args: ListPageArgs): StorageListResult` — pure pagination over readdir entries.
|
||||
- `resolveListOptions(options, total): ResolvedListOptions` — defaults `{prefix:"", offset:0, limit:total||1, continuationToken: undefined}`.
|
||||
- `readMetadataWithStatFallback(fs, metaPath, dataPath): Promise<StorageMetadata | null>` — metadata read with stat fallback.
|
||||
- `syntheticMetadata(clock: Clock, size: number): StorageMetadata` — synthetic timestamped record.
|
||||
- `ListPageArgs`, `ResolvedListOptions` — explicit interfaces.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The temp filename embeds the destination base name so debugging an orphaned temp file in the directory is easy: `ls .tmp-*` lists every aborted write with its target.
|
||||
- The `limit` default in `resolveListOptions` is `max(total, 1)` rather than `total` so an empty store still returns a meaningful page (zero keys, no continuation) without divide-by-zero or `slice(0, 0)` ambiguity.
|
||||
- `readMetadataWithStatFallback` swallows ENOENT on both files (`return null`) but rethrows every other errno so callers don't need to repeat the classification.
|
||||
- All helpers are stateless and re-entrant; they can be called concurrently against the same `NodeFsLike`.
|
||||
@@ -0,0 +1,77 @@
|
||||
# `node-fs-storage`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - node-fs-storage.ops — Houses the file-system operat…
|
||||
- [node-fs-storage.ops](./node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
|
||||
- [node-fs-storage.ops](./node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
|
||||
|
||||
## Purpose
|
||||
|
||||
`NodeFsStorage` is the package's headline export: a full `AbstractStorage` implementation backed by the Node.js filesystem with atomic writes, AbortSignal everywhere, strict key sanitisation, retry on transient errors, native Web Streams, and partial reads. It is the server-tier sibling of `MemoryStorage` from `@suaveplan/storage` and `S3` from `@suaveplan/storage-s3` — every consumer that talks to `AbstractStorage` works with it unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `AbstractStorage` and implements every abstract method (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`, `getMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`).
|
||||
- Atomic writes via temp-file-then-`fs.rename`. The temp file lives in the same directory as the destination so the rename is POSIX-atomic; rollback unlinks the temp on any failure.
|
||||
- AbortSignal threaded through every async method; pre-aborted signals surface as `STORAGE_FILE.ABORTED` before any IO call.
|
||||
- Strict key sanitisation: `sanitizeKey` rejects empty keys, `..` traversal, absolute paths, drive-letter prefix, control chars, null bytes, and over-length keys before any path resolution.
|
||||
- `@suaveplan/retry` integration on `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` with injectable `TimersLike` for deterministic test timing.
|
||||
- Native Web Streams via `node:fs.createReadStream`/`createWriteStream`. Streamed writes use the same temp-rename atomic policy.
|
||||
- Partial reads via positioned `fs.read` on a `FileHandle` — O(length) memory, never O(file-size).
|
||||
- Side-car metadata file (`.meta`) for `StorageOptions.metadata`/`ttl`. Missing metadata falls back to a synthetic record from `fs.stat`.
|
||||
- Full OTel surface — duration histogram, byte-flow histogram, per-kind counters, per-op tracer.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const storage = new NodeFsStorage({ rootDir: "/tmp/store" });
|
||||
await storage.set("hello", new TextEncoder().encode("world"));
|
||||
const bytes = await storage.get("hello");
|
||||
console.log(new TextDecoder().decode(bytes!));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createNodeFsStorage, defaultNodeFs } from "@suaveplan/storage-file";
|
||||
import { fakeClock } from "@suaveplan/testing/runner";
|
||||
|
||||
const fc = fakeClock();
|
||||
const storage = createNodeFsStorage({
|
||||
rootDir: "/tmp/store",
|
||||
mkdirp: true,
|
||||
fileExtension: ".bin",
|
||||
metadataExtension: ".manifest",
|
||||
retry: { maxAttempts: 5, baseDelayMs: 25, maxDelayMs: 1_000 },
|
||||
timers: { setTimer: fc.setTimeout, clearTimer: fc.clearTimeout },
|
||||
clock: () => fc.now(),
|
||||
fs: defaultNodeFs(),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const writer = storage.createWriteStream("large", controller.signal).getWriter();
|
||||
await writer.write(new Uint8Array(1024 * 1024));
|
||||
await writer.close();
|
||||
|
||||
const slice = await storage.getPartial("large", 1024, 256, controller.signal);
|
||||
console.log(slice?.byteLength); // → 256
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class NodeFsStorage extends AbstractStorage` — constructor `(options: NodeFsStorageOptions)`. Throws `STORAGE_FILE.INVALID_OPTIONS` on schema-validation failure.
|
||||
- `createNodeFsStorage(options): NodeFsStorage` — convenience factory.
|
||||
- `capabilities` — `{ partialReads: true, partialWrites: false, nativeStreams: true, atomicWrites: true, contentAddressable: false }`.
|
||||
- All abstract `AbstractStorage` methods with the exact signatures declared on the base class.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Public methods sanitise their key argument before any internal operation. The sanitised form is what reaches `dataPath`/`metaPath`/`atomicWrite` so directory-traversal cannot be re-introduced by a private helper.
|
||||
- `setPartial` delegates to `AbstractStorage.defaultSetPartial`, which performs a read-modify-write. Native random-access write is a follow-up — the reference path is correct but loads the full value into memory.
|
||||
- `getSize` enumerates `list()` keys and reads each `getMetadata` — `O(N)` calls. This matches the upstream contract; future work may add a single-pass `du`-like helper.
|
||||
- The metadata sidecar file is written atomically using the same temp-rename helper as the data file. A failed metadata write does NOT rollback the data write: the data is durable, the metadata is best-effort. Callers requiring the two to commit together can perform the second write themselves under `set(..., { metadata })`.
|
||||
- `createReadStream` and `createWriteStream` return WHATWG Web Streams; consumers that prefer Node's Readable can pipe via `Readable.fromWeb`.
|
||||
- The OTel `op_duration_ms` histogram timing is wall-clock via `Date.now()` — accurate at the millisecond resolution that OTel histograms typically bucket at. For sub-millisecond benchmarks, use the dedicated mitata suite under `src/__benchmarks__/`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# `node-fs-storage.ops`
|
||||
|
||||
## Purpose
|
||||
|
||||
Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call with documented error mapping; the orchestrator class delegates so the class itself stays under the source-file size budget. Splitting these out also makes the operations independently testable in isolation.
|
||||
|
||||
## Features
|
||||
|
||||
- `readPositioned` — `fs.open` + `handle.read(offset, length)` + `handle.close()` with `null` on ENOENT.
|
||||
- `assertPositiveInt` — argument-shape guard for the `offset`/`length` pair on `getPartial`.
|
||||
- `buildReadStream` / `buildWriteStream` — thin wrappers that thread the optional `signal` into the underlying stream factories.
|
||||
- `assertKeyExists` — pluggable strict variant of `exists` that throws `STORAGE_FILE.NOT_FOUND` when the key is absent.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { readPositioned, defaultNodeFs } from "@suaveplan/storage-file";
|
||||
|
||||
const fs = defaultNodeFs();
|
||||
const slice = await readPositioned({ fs, path: "/tmp/k.data", offset: 0, length: 16, key: "k" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { assertKeyExists, NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const storage = new NodeFsStorage({ rootDir: "/tmp/store" });
|
||||
await assertKeyExists("config", (k, signal) => storage.exists(k, signal));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `readPositioned(args: PartialReadArgs): Promise<Uint8Array | null>` — positioned read.
|
||||
- `assertPositiveInt(key, offset, length): void` — argument-shape guard.
|
||||
- `buildReadStream(args: ReadStreamArgs): ReadableStream<Uint8Array>` — read stream.
|
||||
- `buildWriteStream(args: WriteStreamArgs): WritableStream<Uint8Array>` — write stream.
|
||||
- `assertKeyExists(key, exists, signal?): Promise<void>` — strict key check.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `readPositioned` always closes the handle via a `finally` block; the `.catch(() => undefined)` on `close()` swallows close-only failures so the caller never loses the read result.
|
||||
- `buildWriteStream` fires `fs.mkdir` as a fire-and-forget — the stream constructor needs to return synchronously, so the `mkdir` runs in the background and any failure is swallowed (the subsequent stream open will surface the real error).
|
||||
- `assertKeyExists` accepts the `exists` function as a parameter rather than the storage instance so the helper is decoupled from the class hierarchy and can be tested in isolation.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `node-fs`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the default `NodeFsLike` implementation backed by `node:fs/promises` and the two stream constructors from `node:fs`. The factory is the single binding point between this package and Node's filesystem APIs — every other module talks to the structural `NodeFsLike` interface so tests can substitute real in-memory or hooked adapters without `vi.mock`.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh `NodeFsLike` object per call so callers can spread-and-override individual methods without mutating shared state.
|
||||
- Forwards `readFile` results through `new Uint8Array(buffer, byteOffset, byteLength)` so consumers get a stable `Uint8Array` view regardless of the underlying Buffer subclass.
|
||||
- Wraps `node:fs/promises.open` so the returned handle matches the structural `NodeFsFileHandleLike` interface (only `read` + `close`).
|
||||
- Translates `readdir(path, { recursive: true, withFileTypes: true })` Dirent entries into the lighter `NodeFsReaddirEntry` shape exposed to the rest of the package.
|
||||
- Translates `stat` into the lighter `NodeFsStatLike` shape exposing only the four fields the adapter reads.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultNodeFs } from "@suaveplan/storage-file";
|
||||
|
||||
const fs = defaultNodeFs();
|
||||
await fs.mkdir("/tmp/store", { recursive: true });
|
||||
await fs.writeFile("/tmp/store/hello.data", new TextEncoder().encode("hi"));
|
||||
const data = await fs.readFile("/tmp/store/hello.data");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defaultNodeFs, NodeFsStorage } from "@suaveplan/storage-file";
|
||||
import type { NodeFsLike } from "@suaveplan/storage-file/types";
|
||||
|
||||
const base = defaultNodeFs();
|
||||
|
||||
// Wrap stat to ensure mtimes never go backwards under wall-clock skew.
|
||||
const monotonicStat: NodeFsLike = {
|
||||
...base,
|
||||
async stat(path) {
|
||||
const s = await base.stat(path);
|
||||
return { ...s, mtimeMs: Math.max(s.mtimeMs, Date.now()) };
|
||||
},
|
||||
};
|
||||
|
||||
const storage = new NodeFsStorage({ rootDir: "/tmp/store", fs: monotonicStat });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultNodeFs(): NodeFsLike` — returns a fresh adapter object.
|
||||
|
||||
Every method on the returned object forwards to its `node:fs/promises` counterpart with minimal shape translation. See `fs-adapter.types.md` for the full method list.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory pattern (vs a module-level singleton) is deliberate: returning a fresh object lets tests construct independent adapters per `it(...)` block and verify ownership semantics.
|
||||
- The factory is dependency-free at module evaluation time — every Node import is at the top of the file, but `defaultNodeFs()` itself doesn't reach into globals or open any handles. Callers can build the storage adapter at import time without paying any filesystem cost.
|
||||
- The lazy buffer-view translation in `readFile` avoids a copy when callers operate on the result via DataView or pass it back into a hash function.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `otel`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises OpenTelemetry instrumentation for `@suaveplan/storage-file`. Every public path in `NodeFsStorage` records the operation outcome via `recordOp`, so a deployment that installs an OTel SDK gets per-operation counters, latency histograms, and byte-flow histograms for free — no plumbing in user code. The instruments are bound lazily so consumers can install a custom `MeterProvider`/`TracerProvider` before the first operation runs and have it picked up automatically.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 counters: `storage_file.reads_total`, `storage_file.writes_total`, `storage_file.deletes_total`, `storage_file.errors_total` — all attributed by `op` and `outcome`.
|
||||
- 2 histograms: `storage_file.op_duration_ms{op,outcome}` and `storage_file.io_bytes{op,direction}`.
|
||||
- 1 tracer: `@suaveplan/storage-file` (spans created at the call site, not auto-recorded here, so a caller can decorate them with key/extras).
|
||||
- Lazy instrument cache — `getInstruments()` builds the set on first call and reuses it thereafter. `resetInstruments()` blows the cache so tests can rebuild against a freshly-installed in-memory MeterProvider.
|
||||
- Outcome enum closed to `success`/`error`/`aborted` — bounded cardinality for dashboards.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordOp } from "@suaveplan/storage-file";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fs.writeFile(path, data);
|
||||
recordOp({ op: "set", outcome: "success", durationMs: performance.now() - start, bytes: data.byteLength, direction: "write" });
|
||||
} catch (err) {
|
||||
recordOp({ op: "set", outcome: "error", durationMs: performance.now() - start });
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { resetInstruments, getInstruments } from "@suaveplan/storage-file";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
class CollectingMeter { /* … real in-memory MeterProvider … */ }
|
||||
|
||||
const provider = new CollectingMeter();
|
||||
metrics.setGlobalMeterProvider(provider as never);
|
||||
resetInstruments(); // rebuild against the freshly-installed provider
|
||||
const inst = getInstruments();
|
||||
// emit ops through your call site, then read the recorded values via the provider's collector.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getInstruments(force?: boolean): StorageFileInstruments` — returns the lazily-built instrument set. Pass `force: true` to rebuild after installing a new MeterProvider.
|
||||
- `recordOp(input: RecordOpInput): void` — emits the counter increment and histogram observations for one operation.
|
||||
- `resetInstruments(): void` — clears the cache; the next `getInstruments()` rebuilds.
|
||||
- `StorageFileOp` — closed string union enumerating the 14 instrumented operations.
|
||||
- `StorageFileOpOutcome` — `"success" | "error" | "aborted"`.
|
||||
- `RecordOpInput` — `{ op, outcome, durationMs, bytes?, direction? }`.
|
||||
- `StorageFileInstruments` — `{ tracer, meter, readsTotal, writesTotal, deletesTotal, errorsTotal, opDurationMs, ioBytes }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The counters are split by operation kind (reads/writes/deletes) rather than rolled into one total counter so dashboards can compute hit/miss/error rates without joining histograms.
|
||||
- `io_bytes` is recorded under a `direction` attribute (`"read"` or `"write"`) to keep both flows in one histogram while remaining splittable.
|
||||
- `errors_total` counts both `outcome=error` and `outcome=aborted` ops — aborted operations are still failures from the caller's perspective.
|
||||
- `resetInstruments` is exported only because tests need it; production code never calls it.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `registry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Registers `NodeFsStorage` against the module-augmentation registry maintained by `@suaveplan/storage/factory`. After `registerNodeFsProvider()` runs once, the `createStorage({ type: "file", config: NodeFsStorageOptions })` factory dispatches to `new NodeFsStorage(config)` with full TypeScript narrowing on the `file` slot.
|
||||
|
||||
## Features
|
||||
|
||||
- Registers the `file` provider type with the storage factory.
|
||||
- Idempotent — repeated `registerNodeFsProvider()` calls are no-ops, so the function is safe to call from any consumer's startup path without worrying about double-registration.
|
||||
- Declaration-merges `StorageProviderRegistry` and `StorageProviderConfig` so `createStorage` is fully typed.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { registerNodeFsProvider } from "@suaveplan/storage-file";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerNodeFsProvider();
|
||||
const storage = createStorage({ type: "file", config: { rootDir: "/tmp/store" } });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { registerNodeFsProvider, NodeFsStorage } from "@suaveplan/storage-file";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
// Safe in any startup module — idempotent.
|
||||
registerNodeFsProvider();
|
||||
registerNodeFsProvider();
|
||||
|
||||
const storage = createStorage({ type: "file", config: { rootDir: "/var/lib/app/storage", retry: { maxAttempts: 5 } } });
|
||||
console.log(storage instanceof NodeFsStorage); // → true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerNodeFsProvider(): void` — register the `file` provider. Idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registration delegates to `registerStorageProvider("file", NodeFsStorage)` from `@suaveplan/storage/factory`. The cast through `unknown` is required because the factory's loose `ProviderCtor` signature accepts `unknown` for config — `NodeFsStorage` accepts a stricter type but is structurally compatible.
|
||||
- Registration is NOT triggered on import — consumers must call `registerNodeFsProvider()` explicitly. This keeps `import "@suaveplan/storage-file"` side-effect-free per `"sideEffects": false`.
|
||||
- The module-augmentation `declare module "@suaveplan/storage/factory"` is type-only — it does not require the consumer to import this file at runtime to benefit from the typing.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `retry-helper`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/retry.retry` with the transient-error classifier specific to filesystem operations and the error-mapping policy specific to this package. Every public `NodeFsStorage` operation that performs IO routes through `withFsRetry` so the retry policy lives in one place. Tests can drive the retry loop deterministically by injecting a `TimersLike` pair (typically the FakeClock-backed pair from `@suaveplan/testing`).
|
||||
|
||||
## Features
|
||||
|
||||
- Classifies `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` as transient; every other errno is fatal.
|
||||
- Maps `EACCES`/`EPERM` to `STORAGE_FILE.PERMISSION_DENIED` after exhausting retries.
|
||||
- Maps any other terminal error to `STORAGE_FILE.IO_FAILED` and preserves `cause`.
|
||||
- Lets a previously-thrown `StorageFileError` bubble through unwrapped so error codes don't get clobbered.
|
||||
- Defaults to `globalThis.setTimeout`/`clearTimeout` but accepts an injectable `TimersLike` pair for deterministic timing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withFsRetry } from "@suaveplan/storage-file";
|
||||
|
||||
const data = await withFsRetry(() => fs.readFile("/tmp/store/k.data"));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { withFsRetry, type WithFsRetryOptions } from "@suaveplan/storage-file";
|
||||
import { fakeClock } from "@suaveplan/testing/runner";
|
||||
|
||||
const fc = fakeClock();
|
||||
const options: WithFsRetryOptions = {
|
||||
retry: { maxAttempts: 5, baseDelayMs: 10, maxDelayMs: 200 },
|
||||
timers: { setTimer: fc.setTimeout, clearTimer: fc.clearTimeout },
|
||||
signal: controller.signal,
|
||||
key: "k",
|
||||
};
|
||||
|
||||
await withFsRetry(async () => {
|
||||
throw Object.assign(new Error("EBUSY"), { code: "EBUSY" });
|
||||
}, options).catch((err) => {
|
||||
// → StorageFileError(STORAGE_FILE.IO_FAILED, cause: { code: "EBUSY" })
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `withFsRetry<T>(fn, options?): Promise<T>` — runs `fn` with retry on transient fs errors. Throws `StorageFileError` with `IO_FAILED` or `PERMISSION_DENIED` after exhaustion.
|
||||
- `WithFsRetryOptions` — `{ retry?, timers?, signal?, key? }`. `retry.maxAttempts` default 3, `retry.baseDelayMs` default 50, `retry.maxDelayMs` default 5000.
|
||||
- `isTransientFsError(err): boolean` — true when `err.code` is one of `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN`.
|
||||
- `isPermissionFsError(err): boolean` — true when `err.code` is `EACCES` or `EPERM`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The exponential backoff is built via `createExponentialBackoff` from `@suaveplan/retry`. The jitter policy is the package default (equal jitter).
|
||||
- `signal` is forwarded to the inner `retry` call so an abort surfaces as a `RetryAbortedError` from the retry package; the outer `catch` rewraps it via the IO_FAILED path.
|
||||
- `key` (when provided) is propagated into the rethrown `StorageFileError` so OTel attribute extraction works downstream without additional context plumbing.
|
||||
- Pre-existing `StorageFileError` instances are NOT rewrapped — this keeps a `STORAGE_FILE.INVALID_KEY` raised inside `fn` visible as `INVALID_KEY` at the call site, not as a meaningless `IO_FAILED` wrapper.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `streams`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridges `node:fs` Readable/Writable streams to Web `ReadableStream`/`WritableStream` for use by `NodeFsStorage.createReadStream`/`createWriteStream`. The bridge threads AbortSignal end-to-end and applies the same temp-file-then-rename atomic-write policy as the buffered `set` path, so a large streamed write never leaves a partial file at the destination path.
|
||||
|
||||
## Features
|
||||
|
||||
- `createNodeReadStream` returns a `ReadableStream<Uint8Array>` backed by `fs.createReadStream`. Forwards `data`/`end`/`error` events; surfaces both transport failures and abort signals as `StorageFileError`.
|
||||
- `createNodeWriteStream` returns a `WritableStream<Uint8Array>` backed by `fs.createWriteStream` writing to a caller-supplied `tempPath`. On `close` it `fs.rename`s onto `destinationPath`. On abort or error it unlinks the temp file.
|
||||
- Both bridges destroy the underlying Node stream on cancel/abort so file descriptors don't leak. On the read path, `cancel()` also installs a no-op error listener on the inner Node stream before destroying it — when cancel races an in-flight `open()`, Node may still emit a late `"error"` (typically ENOENT if the file was removed between creation and cancel) after our own error handler has been torn down. Without the absorber that error would surface as an unhandled exception and could crash a vitest worker.
|
||||
- The temp-path validation enforces the canonical `.tmp-` prefix at boundary entry — passing a malformed path surfaces as `STORAGE_FILE.ATOMIC_WRITE_FAILED` before any IO.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createNodeReadStream, defaultNodeFs } from "@suaveplan/storage-file";
|
||||
|
||||
const fs = defaultNodeFs();
|
||||
const reader = createNodeReadStream({ fs, path: "/tmp/store/large.data", key: "large" });
|
||||
for await (const chunk of reader) {
|
||||
console.log(chunk.byteLength);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createNodeWriteStream, defaultNodeFs } from "@suaveplan/storage-file";
|
||||
|
||||
const fs = defaultNodeFs();
|
||||
const controller = new AbortController();
|
||||
const writer = createNodeWriteStream({
|
||||
fs,
|
||||
destinationPath: "/tmp/store/k.data",
|
||||
tempPath: "/tmp/store/.tmp-k.data",
|
||||
signal: controller.signal,
|
||||
key: "k",
|
||||
});
|
||||
|
||||
const sink = writer.getWriter();
|
||||
await sink.write(new Uint8Array(1024 * 1024));
|
||||
await sink.write(new Uint8Array(1024 * 1024));
|
||||
await sink.close();
|
||||
// → /tmp/store/k.data now contains 2 MiB; no .tmp-k.data remains.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createNodeReadStream({ fs, path, signal?, key }): ReadableStream<Uint8Array>` — Web ReadableStream over `node:fs.createReadStream(path)`.
|
||||
- `createNodeWriteStream({ fs, destinationPath, tempPath, signal?, key }): WritableStream<Uint8Array>` — Web WritableStream that writes to `tempPath` and renames onto `destinationPath` on close. Throws `STORAGE_FILE.ATOMIC_WRITE_FAILED` if `tempPath` does not contain the canonical temp prefix.
|
||||
- `CreateReadStreamArgs`, `CreateWriteStreamArgs` — explicit interface bags for the two factories.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The read bridge copies each chunk into a fresh `Uint8Array(buffer, byteOffset, byteLength)` view so consumers don't accidentally retain a reference to Node's Buffer pool.
|
||||
- The write bridge installs the abort listener inside `start` and removes it in `close`/`abort` — both branches are exercised by the test suite.
|
||||
- `abort()` on the WritableStream is best-effort: the temp file unlink is `.catch(() => undefined)` so we never throw an abort error and lose the cleanup signal.
|
||||
- On a successful `close` the temp file is renamed atomically. On a failed rename the temp file is removed and an `ATOMIC_WRITE_FAILED` surfaces — never both an orphaned temp and an incomplete destination.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `fs-adapter.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `NodeFsLike` structural interface that `NodeFsStorage` consumes for every filesystem call. The interface is the seam that keeps the adapter injectable in tests without `vi.mock`/`vi.stubGlobal`: production code resolves to a real `node:fs/promises`-backed implementation, tests substitute a real in-memory or hooked implementation that points at a real `fs.mkdtemp` directory or selectively overrides one method to simulate a transient `EBUSY`.
|
||||
|
||||
## Features
|
||||
|
||||
- Mirrors only the subset of `node:fs` methods `NodeFsStorage` actually uses — no leaky abstraction.
|
||||
- Returns Node's `Readable` and `Writable` from the two stream constructors so consumers can convert to/from Web Streams without `as` casts.
|
||||
- `NodeFsStatLike` exposes only `size`, `mtimeMs`, `birthtimeMs`, `isFile()`, `isDirectory()` — the four properties the adapter reads.
|
||||
- `NodeFsFileHandleLike` exposes the positioned `read` + `close` pair needed for `getPartial`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { NodeFsLike } from "@suaveplan/storage-file/types";
|
||||
import { defaultNodeFs } from "@suaveplan/storage-file";
|
||||
|
||||
const fs: NodeFsLike = defaultNodeFs();
|
||||
const stat = await fs.stat("/tmp/example.data");
|
||||
console.log(stat.size);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { NodeFsLike } from "@suaveplan/storage-file/types";
|
||||
import { defaultNodeFs, NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
// A hooked adapter that injects a single transient failure on writeFile.
|
||||
function hookOneEbusy(base: NodeFsLike): NodeFsLike {
|
||||
let fired = false;
|
||||
return {
|
||||
...base,
|
||||
async writeFile(path, data) {
|
||||
if (!fired) {
|
||||
fired = true;
|
||||
const err = Object.assign(new Error("EBUSY"), { code: "EBUSY" });
|
||||
throw err;
|
||||
}
|
||||
return base.writeFile(path, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const storage = new NodeFsStorage({ rootDir: "/tmp/store", fs: hookOneEbusy(defaultNodeFs()) });
|
||||
await storage.set("k", new Uint8Array([1, 2, 3])); // retried once, succeeds
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NodeFsLike` — structural interface with 11 methods (`mkdir`, `readFile`, `writeFile`, `rename`, `unlink`, `readdir`, `stat`, `access`, `open`, `createReadStream`, `createWriteStream`).
|
||||
- `NodeFsStatLike` — `{ size, mtimeMs, birthtimeMs, isFile(), isDirectory() }`.
|
||||
- `NodeFsFileHandleLike` — `{ read(buffer, offset, length, position), close() }`.
|
||||
- `NodeFsReaddirEntry` — `{ name, isFile(), isDirectory() }`.
|
||||
- `NodeFsCreateReadStreamOptions` — `{ start?, end?, highWaterMark? }`.
|
||||
- `NodeFsCreateWriteStreamOptions` — `{ flags?, highWaterMark? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Declared as `interface`s (not `type` aliases) so downstream consumers can declaration-merge if they need to extend the surface for a specialised backend (e.g. a fuse mount with a custom flag).
|
||||
- Returns `Promise<string | undefined>` from `mkdir` to match Node's API — `recursive: true` returns the first path created or `undefined` if nothing was created. The adapter never reads the value.
|
||||
- `readdir` is fixed to `withFileTypes: true` for type narrowing; the adapter never uses the legacy string-array shape.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `node-fs-storage.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the option shape and Zod schema consumed by `NodeFsStorage`'s constructor. The schema is the runtime boundary — every option that flows into the adapter is parsed exactly once, at construction time, and a parse failure surfaces as `STORAGE_FILE.INVALID_OPTIONS`. Co-locating the type and the schema in a single file keeps inferred type ⇄ schema in lockstep across edits.
|
||||
|
||||
## Features
|
||||
|
||||
- `NodeFsStorageOptions` interface enumerating every constructor option with inline JSDoc.
|
||||
- `NodeFsStorageOptionsSchema` Zod object schema validating the option bag at the public boundary.
|
||||
- `NodeFsStorageRetryOptions` carrying the retry knobs forwarded to `@suaveplan/retry`.
|
||||
- `TimersLike` interface — the injectable timer pair the retry loop consumes (`setTimer`/`clearTimer`) — declared here once so consumers can ship deterministic FakeClock-backed fakes without importing test-only helpers.
|
||||
- Loose `unknown` shape for the injectable adapters (`fs`, `timers`, `clock`) — Zod cannot fully describe their structural shape without losing the structural contract, so we accept any value and rely on TypeScript at the call site.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NodeFsStorageOptionsSchema } from "@suaveplan/storage-file";
|
||||
|
||||
const parsed = NodeFsStorageOptionsSchema.parse({ rootDir: "/tmp/store" });
|
||||
console.log(parsed.rootDir);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NodeFsStorage, type NodeFsStorageOptions } from "@suaveplan/storage-file";
|
||||
import { fakeClock } from "@suaveplan/testing/runner";
|
||||
|
||||
const fc = fakeClock({ now: 1_700_000_000_000 });
|
||||
const options: NodeFsStorageOptions = {
|
||||
rootDir: "/tmp/store",
|
||||
mkdirp: true,
|
||||
fileExtension: ".bin",
|
||||
retry: { maxAttempts: 5, baseDelayMs: 25, maxDelayMs: 1_000 },
|
||||
clock: () => fc.now(),
|
||||
timers: { setTimer: fc.setTimeout, clearTimer: fc.clearTimeout },
|
||||
};
|
||||
const storage = new NodeFsStorage(options);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NodeFsStorageOptions` — interface; required `rootDir`, optional `mkdirp`, `fileExtension`, `metadataExtension`, `retry`, `fs`, `timers`, `clock`.
|
||||
- `NodeFsStorageRetryOptions` — interface; optional `maxAttempts`, `baseDelayMs`, `maxDelayMs`.
|
||||
- `NodeFsStorageOptionsSchema` — Zod schema for `NodeFsStorageOptions`. Throws on missing `rootDir`, empty `rootDir`/`fileExtension`/`metadataExtension`, non-positive `maxAttempts`/`maxDelayMs`, or negative `baseDelayMs`.
|
||||
- `NodeFsStorageRetryOptionsSchema` — Zod schema for the retry sub-bag.
|
||||
- `TimersLike` — interface; `setTimer(handler, ms)` and `clearTimer(id)`. Compatible with `globalThis.setTimeout`/`clearTimeout` (the default).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `rootDir` is validated `min(1)` — empty strings are rejected to avoid ambiguous "current directory" semantics.
|
||||
- `fileExtension` and `metadataExtension` are also `min(1)` so callers cannot accidentally collide every key into the same path.
|
||||
- The adapter validates `fs`/`timers`/`clock` only structurally — passing `{} as never` will surface as a missing-method TypeError on the first call. This is intentional: forcing every option to have a Zod-describable shape would prevent injecting real callable functions.
|
||||
- The schema accepts `retry: undefined` and `retry: {}` — both produce default retry behaviour.
|
||||
@@ -0,0 +1,135 @@
|
||||
# @suaveplan/storage-http - Universal HTTP/CDN AbstractStorage adapter for @suaveplan/storage: GET/HEAD/Range reads against any baseUrl + key origin, ETag cache, AbortSignal everywhere, strict key sanitisation, native Web Streams, transient-status retry with Retry-After, optional writes via pluggable HttpUploader, full OTel surface.
|
||||
|
||||
Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional writes through a pluggable `HttpUploader`. Threads `AbortSignal` everywhere, retries transient outcomes honouring `Retry-After`, supports conditional reads via an ETag cache, and ships native `ReadableStream` streaming — all in a single tier-pure package that runs unmodified under Node 18+, Bun, Deno, browsers, and Cloudflare Workers.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [constants](./src/constants/constants.md) — Centralises the tunable defaults used across `@suaveplan/storage-http`: key-length cap, retry attempts/backoff, ETag-ca…
|
||||
- [constants](./src/constants/constants.md) — Centralises the tunable defaults used across `@suaveplan/storage-http`: key-length cap, retry attempts/backoff, ETag-ca…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_HTTP` error namespace used by every module in this package. The codes are produced via…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the canonical `STORAGE_HTTP` error namespace used by every module in this package. The codes are produced via…
|
||||
- [etag-cache](./src/etag-cache/etag-cache.md) — In-memory LRU keyed by sanitised storage key, valued by `{ etag, body, lastModified }`. Backs `HttpStorage`'s condition…
|
||||
- [etag-cache](./src/etag-cache/etag-cache.md) — In-memory LRU keyed by sanitised storage key, valued by `{ etag, body, lastModified }`. Backs `HttpStorage`'s condition…
|
||||
- [fetch-adapter](./src/fetch-adapter/fetch-adapter.md) — Wraps `globalThis.fetch` with the contract `HttpStorage` actually consumes. Translates raw network rejections (DNS fail…
|
||||
- [fetch-adapter](./src/fetch-adapter/fetch-adapter.md) — Wraps `globalThis.fetch` with the contract `HttpStorage` actually consumes. Translates raw network rejections (DNS fail…
|
||||
- [http-storage](./src/http-storage/http-storage.md) — `HttpStorage` is the universal `AbstractStorage` adapter that maps every storage operation onto an HTTP request against…
|
||||
- [http-storage.helpers](./src/http-storage/http-storage.helpers.md) — Internal helpers used by `HttpStorage`. Splits boundary concerns (option parsing, URL composition, header resolution, a…
|
||||
- [http-storage](./src/http-storage/http-storage.md) — `HttpStorage` is the universal `AbstractStorage` adapter that maps every storage operation onto an HTTP request against…
|
||||
- [http-storage.ops](./src/http-storage/http-storage.ops.md) — Reusable read-side operations used by `HttpStorage`: full-body `GET` with ETag-cache support, `HEAD` for metadata, `Ran…
|
||||
- [http-storage.streams](./src/http-storage/http-storage.streams.md) — Builds the `ReadableStream` and `WritableStream` instances `HttpStorage` returns from `createReadStream` / `createWrite…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before they reach any `fetch` call, and provides the default per-…
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — Validates and normalises caller-supplied storage keys before they reach any `fetch` call, and provides the default per-…
|
||||
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-http`: counters (`reads_total`, `writes_total`, `deletes_…
|
||||
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument set for `@suaveplan/storage-http`: counters (`reads_total`, `writes_total`, `deletes_…
|
||||
- [range](./src/range/range.md) — Encodes HTTP `Range` semantics for `HttpStorage.getPartial`. Builds the request header from `(offset, length)` and inte…
|
||||
- [range](./src/range/range.md) — Encodes HTTP `Range` semantics for `HttpStorage.getPartial`. Builds the request header from `(offset, length)` and inte…
|
||||
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `http`…
|
||||
- [registry](./src/registry/registry.md) — Module-augments `@suaveplan/storage/factory`'s `StorageProviderRegistry` and `StorageProviderConfig` to add the `http`…
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests issued by `HttpStorage`. Distinguishes transient outcomes (HTTP `408`/`425`/`…
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — Coordinates retry logic for HTTP requests issued by `HttpStorage`. Distinguishes transient outcomes (HTTP `408`/`425`/`…
|
||||
- [uploader](./src/uploader/uploader.md) — Defines the pluggable write strategy for `HttpStorage` and ships the only generic uploader implementation: `presignedPu…
|
||||
- [uploader](./src/uploader/uploader.md) — Defines the pluggable write strategy for `HttpStorage` and ships the only generic uploader implementation: `presignedPu…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-http
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { HttpStorage } from "@suaveplan/storage-http";
|
||||
|
||||
const storage = new HttpStorage({
|
||||
baseUrl: "https://cdn.example.com/start/here/",
|
||||
});
|
||||
|
||||
const bytes = await storage.get("a/b/test.doc");
|
||||
```
|
||||
|
||||
Writes require an injected `HttpUploader`:
|
||||
|
||||
```ts
|
||||
import { HttpStorage, presignedPutUploader } from "@suaveplan/storage-http";
|
||||
|
||||
const storage = new HttpStorage({
|
||||
baseUrl: "https://cdn.example.com/start/here/",
|
||||
uploader: presignedPutUploader({
|
||||
signer: async (key) => ({ url: await signMyProvider("PUT", key, 60) }),
|
||||
}),
|
||||
});
|
||||
|
||||
await storage.set("a/b/test.doc", new Uint8Array([1, 2, 3]));
|
||||
```
|
||||
|
||||
`list`, `clear`, and `getSize` always throw `STORAGE_HTTP.READ_ONLY` because HTTP origins do not expose a uniform directory protocol.
|
||||
|
||||
## Composition recipe — disk-backed ETag cache
|
||||
|
||||
For cross-process cache survival, layer `@suaveplan/storage-file`'s `NamespacedStorage` underneath:
|
||||
|
||||
```ts
|
||||
import { HttpStorage } from "@suaveplan/storage-http";
|
||||
import { NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const local = new NodeFsStorage({ rootDir: "/var/cache/cdn-mirror" });
|
||||
const remote = new HttpStorage({
|
||||
baseUrl: "https://cdn.example.com/start/here/",
|
||||
etagCache: { capacity: 1024 },
|
||||
});
|
||||
|
||||
async function readCached(key: string): Promise<Uint8Array | undefined> {
|
||||
const cached = await local.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const remoteBody = await remote.get(key);
|
||||
if (remoteBody !== undefined) await local.set(key, remoteBody);
|
||||
return remoteBody;
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [error-codes](./src/error-codes.md) — `STORAGE_HTTP_CODES` registry + `StorageHttpError`.
|
||||
- [constants](./src/constants/constants.md) — tunable defaults.
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — strict key validation + per-segment encoder.
|
||||
- [fetch-adapter](./src/fetch-adapter/fetch-adapter.md) — `defaultFetch()` wrapping `globalThis.fetch`.
|
||||
- [range](./src/range/range.md) — `Range` header + 206/200/416 disambiguation.
|
||||
- [etag-cache](./src/etag-cache/etag-cache.md) — in-memory LRU for conditional GETs.
|
||||
- [uploader](./src/uploader/uploader.md) — `HttpUploader` contract + `presignedPutUploader` helper.
|
||||
- [retry-helper](./src/retry-helper/retry-helper.md) — transient classifier + `Retry-After` honour.
|
||||
- [otel](./src/otel/otel.md) — meter, tracer, counters, histograms.
|
||||
- [http-storage](./src/http-storage/http-storage.md) — the `HttpStorage` class itself.
|
||||
- [registry](./src/registry/registry.md) — module-augmentation slot for `createStorage({ type: "http", ... })`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal purity** — no `node:*` or DOM-only imports. Runs unmodified everywhere Node 18+ runs.
|
||||
- **Full `AbstractStorage` surface** — `get`, `set`, `delete`, `exists`, `getMetadata`, `getWithMetadata`, `getItemSize`, `getPartial`, `createReadStream`, `createWriteStream`, `setPartial`. `list`/`clear`/`getSize` throw `READ_ONLY`.
|
||||
- **Native streaming** — `createReadStream` returns `Response.body` directly (no client-side buffering).
|
||||
- **Range reads with fallback metric** — 200-when-Range-was-asked is sliced client-side and counted under `storage_http.range_fallback_total`.
|
||||
- **ETag-cached conditional reads** — `If-None-Match` on hit, `304` returns cached body.
|
||||
- **AbortSignal everywhere** — pre-aborted signal short-circuits before fetch; mid-flight abort propagates to `fetch.init.signal`.
|
||||
- **Retry on transient outcomes** — `408`/`425`/`429`/`5xx` and network rejection retried with exponential backoff or `Retry-After`.
|
||||
- **Pluggable writes** — `HttpUploader` slot keeps the core read-only-by-default while permitting any write strategy.
|
||||
- **OTel built-in** — duration / IO bytes histograms with `status_class` attribute; counters per outcome; spans per call.
|
||||
- **100 / 100 / 100 / 100 Istanbul coverage** — see `bunx turbo test:coverage --filter=@suaveplan/storage-http`.
|
||||
|
||||
## Capabilities matrix
|
||||
|
||||
```ts
|
||||
{
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE` at the repo root.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises the tunable defaults used across `@suaveplan/storage-http`: key-length cap, retry attempts/backoff, ETag-cache capacity, and the transient-status set. Every other module reads from this file rather than embedding magic numbers, so operators have one knob to turn when tuning behaviour for a specific origin.
|
||||
|
||||
## Features
|
||||
|
||||
- `MAX_KEY_LENGTH = 2048` — conservative intersection of major CDN path limits.
|
||||
- `DEFAULT_RETRY_MAX_ATTEMPTS = 3`, `DEFAULT_RETRY_BASE_DELAY_MS = 100`, `DEFAULT_RETRY_MAX_DELAY_MS = 30_000` — used when callers don't override.
|
||||
- `RETRY_AFTER_MAX_DELAY_MS = 60_000` — hard cap on honouring `Retry-After` to protect against malicious origins that try to stall callers indefinitely.
|
||||
- `DEFAULT_ETAG_CACHE_CAPACITY = 256` — LRU bound for the in-memory ETag cache.
|
||||
- `TRANSIENT_HTTP_STATUSES` — frozen `Set` of non-`5xx` transient codes (`408`, `425`, `429`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MAX_KEY_LENGTH } from "@suaveplan/storage-http";
|
||||
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new Error(`Key too long: ${key.length}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_RETRY_MAX_ATTEMPTS,
|
||||
RETRY_AFTER_MAX_DELAY_MS,
|
||||
TRANSIENT_HTTP_STATUSES,
|
||||
} from "@suaveplan/storage-http";
|
||||
|
||||
function classify(status: number): "transient" | "fatal" {
|
||||
if (status >= 500 || TRANSIENT_HTTP_STATUSES.has(status)) return "transient";
|
||||
return "fatal";
|
||||
}
|
||||
|
||||
const retryAfter = Math.min(parsedSeconds * 1000, RETRY_AFTER_MAX_DELAY_MS);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MAX_KEY_LENGTH: number` — character limit for sanitised keys.
|
||||
- `DEFAULT_RETRY_MAX_ATTEMPTS: number` — default retry attempts.
|
||||
- `DEFAULT_RETRY_BASE_DELAY_MS: number` — initial exponential-backoff delay.
|
||||
- `DEFAULT_RETRY_MAX_DELAY_MS: number` — exponential-backoff ceiling.
|
||||
- `RETRY_AFTER_MAX_DELAY_MS: number` — server-supplied `Retry-After` ceiling.
|
||||
- `DEFAULT_ETAG_CACHE_CAPACITY: number` — LRU capacity for ETag cache.
|
||||
- `TRANSIENT_HTTP_STATUSES: ReadonlySet<number>` — non-`5xx` transient codes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `RETRY_AFTER_MAX_DELAY_MS` is **not** the maximum backoff for the adapter as a whole — `DEFAULT_RETRY_MAX_DELAY_MS` controls exponential-backoff. The 60s ceiling specifically caps the value honoured from the server's `Retry-After` header.
|
||||
- `5xx` is intentionally NOT in the `TRANSIENT_HTTP_STATUSES` set — the retry classifier checks the range directly (`status >= 500 && status <= 599`) so callers reading the set don't have to enumerate 100 entries.
|
||||
- `MAX_KEY_LENGTH` is per-key, not per-segment; segment URL-encoding may inflate the wire length beyond this number, but that is the encoder's concern, not the sanitiser's.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user